PDA

View Full Version : Saving objects that are linked hasMany relation with a single Store



emilianm
27 Jul 2011, 11:58 PM
We are using a main object (A) that has a hasMany relation with a list of B objects.
For the A object we have a store with specific url api for reading and for updating data.

I have to mention that we have this URL configured only in the Store object A.
When we use the .load() method on the A store we receive a json stream that will contain also a list of B objects and this objects are very well fetched in B list form A object.


After we modify A object and some of the related B objects, and after we call .sync() method on AStore object we discover that the related B objects are not present in the generated json stream, so are not sent to the server together with A object.


Do you have any idea how can I synchronize with a single call the main A objects(along with the list of B objects) with the server?

Thank you very much for any idea regarding this.

emilianm
28 Jul 2011, 5:04 AM
We verified in the documentation and in the source code and it seems that the reader is able to pre-load with data the objects linked with hasMany form the parent store, but the writer is not taking into account the hasMany relations, is putting in the final json stream only the fields directly attached to the model.

In conclusion, to achieve the synchronization of the main object and also of the associated objects we need to provide a custom implementation of the default Ext.data.writer.Writer

It seems that this method needs to take into account the has Many associations
getRecordData: function(record)

If you already encountered this issue please do not hesitate to share your information.
Thanks.

chrisface
29 Jul 2011, 5:08 AM
This is something I had to do as well myself and I took the same approach of overriding the getRecordData function to take into account the associations. I also added some code to take care of deleted records as well. I'm adding a forDeletion property only for deleted records so that I can keep it out of the fields on the models.



Ext.data.writer.Json.override({
/*
* This function overrides the default implementation of json writer. Any hasMany relationships will be submitted
* as nested objects. When preparing the data, only children which have been newly created, modified or marked for
* deletion will be added. To do this, a depth first bottom -> up recursive technique was used.
*/
getRecordData: function(record) {
//Setup variables
var me = this, i, association, childStore, data = record.data;

//Iterate over all the hasMany associations
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
data[association.name] = null;
childStore = record[association.storeName];

//Iterate over all the children in the current association
childStore.each(function(childRecord) {

if (!data[association.name]){
data[association.name] = [];
}

//Recursively get the record data for children (depth first)
var childData = this.getRecordData.call(this, childRecord);

/*
* If the child was marked dirty or phantom it must be added. If there was data returned that was neither
* dirty or phantom, this means that the depth first recursion has detected that it has a child which is
* either dirty or phantom. For this child to be put into the prepared data, it's parents must be in place whether
* they were modified or not.
*/
if (childRecord.dirty | childRecord.phantom | (childData != null)){
data[association.name].push(childData);
record.setDirty();
}
}, me);

/*
* Iterate over all the removed records and add them to the preparedData. Set a flag on them to show that
* they are to be deleted
*/
Ext.each(childStore.removed, function(removedChildRecord) {
//Set a flag here to identify removed records
removedChildRecord.set('forDeletion', true);
var removedChildData = this.getRecordData.call(this, removedChildRecord);
data[association.name].push(removedChildData);
record.setDirty();
}, me);
}

//Only return data if it was dirty, new or marked for deletion.
if (record.dirty | record.phantom | record.get('forDeletion')){
return data;
}
}
});


I'm not sure if this code will work under every condition but it's been fine for me so far. If you have any suggestions for improvements or a better way of doing it I'd like to hear your thoughts.

emilianm
29 Jul 2011, 5:35 AM
Thank you very much chrisface, we already put in place a custom Writer that is taking into accounts also the fields of hasMany relations. I will take a look at your solution and after we sill finalise ours we will publish here the code.

I think this should be integrated in the next ExtJs release.

cstrong@arielpartners.com
31 Aug 2011, 3:14 PM
Many thanks to chrisface for the code posting.
As I understand it, the objective here is to read and write an object graph using only a single round trip each.
I think I am still missing one piece...

Let's pretend I am dealing with User and Post. User hasMany Post
I am showing the User information in one place, and I am displaying Post records in their own separate grid.
Now I add a new Post to the Post grid, and then call sync() on the User store.
Nothing happens. Why? Because the User record was never marked dirty even though a new Post object was added like so:
user.posts().add( {} ); // <-- this should mark the user instance dirty, but doesn't
I can fake it by adding a 'datachanged' listener or by manually:
user.setDirty();
I am thinking this linkage should be an option to set up in the hasManyAssociations createStore()
method, to enable this kind of behavior, thus reducing the number of round-trips to the server.
What am I missing?

chrisface
2 Sep 2011, 7:32 AM
You're right cstrong, this wont send back your Posts. I do something a little bit different with my relationships which I should have mentioned for my override to work properly.

Instead of having a hasMany of "Post" models on the "User" I have a "UserPosts" model which I use instead. This model maps to the linker table that I use for saving which Posts belong to what User.

It looks a bit like this:
Id , UserId, PostId

What I then do is that I make my UserPosts model extend my Post model and I still have all the information I need. I also override the idProperty value to use the id for the linker table.

This means that when I'm assigning Posts to a User, what I'm actually doing is creating new UserPost models. They have a Post Id inside of them but no UserPost Id so they get picked up as phantom records.

I use my UserPosts model for handling the relationships and I use the Post model when doing CRUD on Posts. It does mean that there are some cases where they can't be used interchangeably but I either do a load using the Ids I have to get the correct kind of model or do a quick transformation between the objects which I have static methods for in the models.

It's probably not how Sencha intended the persistence of models to work but it was the only way I could get nested saving to work for me.

I ended up making more changes to the code as well because it caused some unforeseen bugs but there may be more that I've not seen yet :)



Ext.data.writer.Json.override({
/*
* This function overrides the default implementation of json writer. Any hasMany relationships will be submitted
* as nested objects. When preparing the data, only children which have been newly created, modified or marked for
* deletion will be added. To do this, a depth first bottom -> up recursive technique was used.
*/
getRecordData: function(record) {
//Setup variables
var me = this, i, association, childStore, data = {};
if(record.proxy.writer.writeAllFields){
data = record.data;
}
else {
var changes, name, field, fields = record.fields, nameProperty = this.nameProperty, key;
changes = record.getChanges();
for (key in changes) {
if (changes.hasOwnProperty(key)) {
field = fields.get(key);
name = field[nameProperty] || field.name;
data[name] = changes[key];
}
}
if (!record.phantom) {
// always include the id for non phantoms
data[record.idProperty] = record.getId();
}
}


//Iterate over all the hasMany associations
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
data[association.name] = [];
childStore = record[association.storeName];


//Iterate over all the children in the current association
childStore.each(function(childRecord) {


//Recursively get the record data for children (depth first)
var childData = this.getRecordData.call(this, childRecord);


/*
* If the child was marked dirty or phantom it must be added. If there was data returned that was neither
* dirty or phantom, this means that the depth first recursion has detected that it has a child which is
* either dirty or phantom. For this child to be put into the prepared data, it's parents must be in place whether
* they were modified or not.
*/
if (childRecord.dirty | childRecord.phantom | (childData != null)){
data[association.name].push(childData);
record.setDirty();
}
}, me);


/*
* Iterate over all the removed records and add them to the preparedData. Set a flag on them to show that
* they are to be deleted
*/
Ext.each(childStore.removed, function(removedChildRecord) {
//Set a flag here to identify removed records
removedChildRecord.set('forDeletion', true);
var removedChildData = this.getRecordData.call(this, removedChildRecord);
data[association.name].push(removedChildData);
record.setDirty();
}, me);
}


//Only return data if it was dirty, new or marked for deletion.
if (record.dirty | record.phantom | record.get('forDeletion')){
return data;
}
}
});


Ext.data.Store.override({
remove: function(records, /* private */ isMove) {
if (!Ext.isArray(records)) {
records = [records];
}


/*
* Pass the isMove parameter if we know we're going to be re-inserting this record
*/
isMove = isMove === true;
var me = this,
sync = false,
i = 0,
length = records.length,
isPhantom,
index,
record;


for (; i < length; i++) {
record = records[i];
index = me.data.indexOf(record);


if (me.snapshot) {
me.snapshot.remove(record);
}


if (index > -1) {
isPhantom = record.phantom === true;
if (!isMove && !isPhantom) {
// don't push phantom records onto removed
record.set('forDeletion', true);
me.removed.push(record);
}


record.unjoin(me);
me.data.remove(record);
sync = sync || !isPhantom;


me.fireEvent('remove', me, record, index);
}
}


me.fireEvent('datachanged', me);
if (!isMove && me.autoSync && sync) {
me.sync();
}
}
});
Ext.data.TreeStore.override({
onNodeRemove: function(parent, node) {
var removed = this.removed;


if (!node.isReplace && Ext.Array.indexOf(removed, node) == -1) {
node.set('forDeletion', true);
removed.push(node);
}
}
});

cstrong@arielpartners.com
2 Sep 2011, 9:25 AM
chrisface thanks again for the updated posting.
Only issue I see is that belongsTo associations trigger null reference. I added a small fix to work around that issue.
I added an if condition directly underneath your for loop where you loop through the associations to check the type:



//Iterate over all the hasMany associations
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
if (association.type == 'hasMany') { // <-- this avoids null reference issue
.....// rest of method goes here
}


I didn't grok your situation thoroughly, but I suspect you might be using a many-to-many approach (a join class), which might be why you didn't see the issue with belongsTo.
We have a one-to-many relationship, and the belongsTo association is quite helpful because, among other things, it automatically adds a reference to the parent model object inside the child.

We are namespacing our models, so we had to override several configuration parameters in order for the ext association code to generate reasonable getters and setters (otherwise you get nonsense like "Foo.models.MyModelBelongsToInstance" and "getFoo.model.Parent()" )

FWIW, here is the code I wrote to automatically mark the parent dirty when a child is modified, added, or deleted


associations : [
{
type : 'hasMany',
model : 'Foo.model.ChildOb',
name : 'childobs', // generates childobs() method
foreignKey : 'parent_id',
storeConfig : {
listeners: {
// Set parent object dirty if we added a new child.
add: function(store, records, index, opts) {
var obj = records[0]; // NOTE not fully general but works for our case!
if (obj.get('id') === 0) {
this.setParentDirty(obj.get('parent_id'));
}
},
// Set parent object dirty if we removed an existing child
remove: function(store, obj, index, opts) {
if (obj.get('id') != 0) {
obj.parent.setDirty();
}
},
// Set parent object dirty if we updated an existing child
update: function(store, obj, index, opts) {
if (obj.get('id') != 0) {
obj.parent.setDirty();
}
}
},
setParentDirty: function(parent_id) {
var store = Ext.data.StoreManager.lookup('Parents');
var pool = store.getById(parent_id);
if (parent) {
parent.setDirty();
}
}
}
},


Here is my child belongsTo association


associations: [
{
type : 'belongsTo',
model : 'Foo.model.Parent',
foreignKey : 'parent_id', // if not overridden you get 'Foo.model.Parent_id'
getterName : 'getParent', // if not overridden you get 'getFoo.model.Parent'
setterName : 'setParent', // if not overridden you get 'setFoo.model.Parent'
instanceName: 'parent' // if not overridden you get 'Foo.model.ParentBelongsToInstance'
}
]

ssamayoa
6 Sep 2011, 10:02 AM
I'm using this code (with slightly changes) but wonder if this could be included in the ExtJS's official release.

@chrisface: are you willing to give copyright to Sencha?

@Sencha: are you willing to make this code (as is or enhanced) official?

Regards.

ssamayoa
23 Sep 2011, 7:14 AM
BUMP.

jinggirl
8 Nov 2011, 10:20 AM
would anybody know if this has been fixed yet? i am facing a similar problem. i couldn't write the associated data.

nahurso
11 Nov 2011, 2:44 AM
any new on that Issue? if this going to be included in the ExtJS's official release?
Thanks

1099511627776
24 Jan 2012, 7:14 AM
Count me in,
I'm looking forward for this feature to be included in ExtJS

börn
1 Feb 2012, 11:25 PM
+1000

and I would call this a bug, because why do we have a model class system with associations, if we aren't able to save a complete object graph? And the scenario to always make a step-by-step saving (first save the store of the association, then save the root object) isn't the best saving method, isn't it?
If you have complex object graphs you have to ensure you are sending the complete graph to the server, so it saves this object transactionally to the the database - if we don't do this we have to skip our ORM and have to deal with foreign key problems and so on...

so... sencha? ;) any comments?

dllchrist
13 Feb 2012, 4:58 PM
Same issue here

@Sencha, Is this fixed in extjs 4.1?

dphunkt
2 Mar 2012, 9:09 AM
BUMP. Any news on this?

jbeich
6 Mar 2012, 12:28 PM
+1

reproman
12 Mar 2012, 12:25 PM
+1

fbrus001
16 Mar 2012, 4:10 AM
Really, this should be possible from within the framework. Otherwise, there's really no use using associations; it's probably less painful then to add another store and link everything manually. Is this planned for any near future releases?

einartg
16 Mar 2012, 5:30 AM
Has anyone tried the 4.1 RC - as the issue seems fixed there:

http://www.sencha.com/forum/showthread.php?187908-Ext-JS-4.1-RC1-is-Now-Available

Search for EXTJSIV-4464 - prepareAssociatedData method doesn't handle nested asssociations [Parature Inc]

Kind Regards
Einar

hardwickj
22 Mar 2012, 8:10 AM
At first take, this problem still appears to be happening for me w/ 4.1 RC1. I'm just using a model w/ a proxy (and not a store), but I don't think that should matter.

hardwickj
22 Mar 2012, 11:56 AM
So in 4.1 RC1, a method Model.getData(boolean) was added. Passing a value of true will correctly call the getAssociatedData and return a complete object graph.

However, upon further inspection of the Ext.data.writer.Writer.getRecordData() function, I see they are not making use of this but are instead only iterating over the non-association fields of the model object. I see no way of indicating to the Writer that it should utilize Model.getData(). I think as a default that this is the correct behavior since more often then not you do not want to return a complete object graph but instead persist the associated models independently. But we still need a means of passing an option such that we can indicate we DO want it to do this.

I think this is an absolute necessity and I'm kind of shocked ExtJS has gone this far without something being done about this. It's things like this that make me desperately miss Backbone.js.

hardwickj
23 Mar 2012, 6:00 AM
If anyone is interested, I solved this by making a custom Writer that is almost identical to the original Json writer but utilizes the new getData() method on Models for retrieving data to be written rather than just iterating over the fields. Then anytime you define a proxy on a model/store, if you wish for it to use this writer you just define it in the proxy config. See below.

Defines the custom Writer:


Ext.define('Ext.data.writer.DeepJson', {
extend:'Ext.data.writer.Json',
getRecordData:function (record, operation) {
var isPhantom = record.phantom === true,
writeAll = this.writeAllFields || isPhantom,
nameProperty = this.nameProperty,
fields = record.fields,
data = {},
changes,
name,
field,
key;

if (writeAll) {
// This is the branch that has been changed from the original Json Writer
data = record.getData(true);
} else {
changes = record.getChanges();
for (key in changes) {
if (changes.hasOwnProperty(key)) {
field = fields.get(key);
name = field[nameProperty] || field.name;
data[name] = changes[key];
}
}
}
if (isPhantom) {
if (operation && operation.records.length > 1) {
data[record.clientIdProperty] = record.internalId;
}
} else {
data[record.idProperty] = record.getId();
}

return data;
}
});

Then we add it to our proxy config:


proxy:{
type:'rest',
autoAbort:false,
url:'api/graph',
reader:{
type:'json'
},
writer:Ext.create('Ext.data.writer.DeepJson')
}

Doing it this way you still maintain the default behavior of having to persist associations independently, but now have the option of utilizing an entire graph in the event you have something abnormally large where you may not want to submit 100's of requests to the back end.

olivierpons
26 Mar 2012, 5:09 AM
Trying your class, I get this error:

Uncaught TypeError: Cannot read property 'persist' of undefined
Ext.define.afterEdit - ext-all-debug.js:46945
Ext.define.callStore - ext-all-debug.js:47942
Ext.define.afterEdit - ext-all-debug.js:47917
Ext.define.set - ext-all-debug.js:47590
Ext.define.insert- ext-all-debug.js:49114
Ext.define.add- ext-all-debug.js:49173
Ext.define.onValidateClick - liste.js:547
Ext.define.fireHandler- ext-all-debug.js:78194
Ext.define.onClick- ext-all-debug.js:78184
(anonymous function)
Ext.apply.createListenerWrap.wrap- ext-all-debug.js:897

hardwickj
26 Mar 2012, 7:15 AM
I'm not doing anything with "persist", I'm merely modifying which fields are collected for the request. A la, the following snippet from the original Ext.data.writer.Writer:



if (writeAll) {
fLen = fieldItems.length;

for (f = 0; f < fLen; f++) {
field = fieldItems[f];

if (field.persist) {
name = field[nameProperty] || field.name;
data[name] = record.get(field.name);
}
}
} else {...


has now become the following:



if (writeAll) {
data = record.getData(true);
} else {...


If you are getting the above errors I'm going to guess that you have problems elsewhere or in how/where you are defining the new writer, but I can't help you without seeing any code of your own.

olivierpons
26 Mar 2012, 11:43 PM
After searching a while, the solutions chrisface and cstrong have provided didn't work with ExtJS and record insertion.

So what I've done (and I think it's a bit safer) is calling first the parent's method, then after, add to the result the "hasMany" associations (if there are ones).

Here's my code, which works flawlessly. Please feel free to comment or to suggest:


var ExtDataWriterJsonOriginal_getRecordData = Ext.data.writer.Json.prototype.getRecordData;
Ext.data.writer.Json.override({
{*/*
* This function overrides the default implementation of
* json writer. Any hasMany relationships will be submitted
* as nested objects
*/*}
getRecordData: function(record) {
var me = this, i, association, childStore, data = {};
data = ExtDataWriterJsonOriginal_getRecordData(record);

/* Iterate over all the hasMany associations */
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
if (association.type == 'hasMany') {
data[association.name] = [];
childStore = eval('record.'+association.name+'()');

//Iterate over all the children in the current association
childStore.each(function(childRecord) {

//Recursively get the record data for children (depth first)
var childData = this.getRecordData.call(this, childRecord);
if (childRecord.dirty | childRecord.phantom | (childData != null)){
data[association.name].push(childData);
record.setDirty();
}
}, me);
}
}
return data;
}
});

ssamayoa
30 Mar 2012, 6:54 AM
Here's my code, which works flawlessly.

This is for 4.0.x or 4.1?

What about deleted records?

Original chrisface's code added deleted records with new field called "forDeletion".

Regards.

olivierpons
30 Mar 2012, 1:17 PM
The other didn't work for insertion with both Ext 4.0 and Ext 4.1, whereas my solution works for both.
But I still haven't tested yet deletion. I'll tell you.

olivierpons
10 Apr 2012, 1:24 AM
As for deleted records, I just send the whole "current" records, which implies deletion then re-write of records.

It's acceptable in all the situations where there's a very few recursive nested hasMany relationship (usually no recursive). But you're right, I should mention it.

In my case it's perfect and works like a charm, and it may help others.

Maybe someone could change my code just to add deleted record (or something).

bldoron
28 Jul 2012, 9:39 PM
I'm having trouble writing to the server anything with association.
Also nested items that have only been mapped (not associated):
like:
{
test: {
auto: {
users: 40;
delay: 60;
}
}
}

When I map it fir the reader as:
{name: 'autoUsers', mapping: 'auto.users', type: 'number'},
{name: 'autoDelay', mapping: 'auto.delay', type: 'number'}

The writer writes the submitted ajax as :
{
autoUsers: 40;
autoDelay: 60;
}

But I need it to be returned in the same way that he got it.
Any help?

dbrin
28 Jul 2012, 10:17 PM
Use nameProperty: mapping config for the writer - see docs

bldoron
28 Jul 2012, 11:45 PM
That won't do, as the nameProperty field will only except one level. So
It will change {AutoUsers:} to: {Auto.users:} for example, but not to: {auto: {users:}}

flanders
6 Sep 2012, 11:04 AM
Sorry for necro-ing this thread, but I hope this might be handy for anyone trying to safe a hasMany relation. I have made some adjustments to the code of @olivierpons. His code didn't work for me and I do not want to check for modified records but want to send everything. Code below was tested against 4.1.1



var ExtDataWriterJsonOriginal_getRecordData = Ext.data.writer.Json.prototype.getRecordData;
Ext.data.writer.Json.override({
getRecordData: function(record) {
var me = this, i, association, childStore, data = {};
data = ExtDataWriterJsonOriginal_getRecordData(record);


/* Iterate over all the hasMany associations */
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
if (association.type == 'hasMany') {
data[association.name] = [];
childStore = eval('record.'+association.name+'()');


//Iterate over all the children in the current association
childStore.each(function(childRecord) {
data[association.name].push(childRecord.getData());
}, me);
}
}
return data;
}
});

olivierpons
6 Sep 2012, 12:40 PM
Thank you very much for following this, I have small problems with grids, I suppose it has something to do with my code, so I'll try yours and if things come better I'll let you know :)

Olivier Pons

flanders
7 Sep 2012, 12:24 AM
I have updated the code so that it overrides as encouraged by Sencha and so that it still sends all fields, even those unchanged, which the previous version didn't (for some reason)



Ext.override(Ext.data.writer.Json, {
getRecordData: function(record) {
var me = this, i, association, childStore,
data = this.callParent(arguments);


/* Iterate over all the hasMany associations */
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
if (association.type == 'hasMany') {
data[association.name] = [];
childStore = eval('record.'+association.name+'()');


//Iterate over all the children in the current association
childStore.each(function(childRecord) {
data[association.name].push(childRecord.getData());
}, me);
}
}


return data;
}
});

stephenr85
5 Oct 2012, 11:19 AM
I landed here because I had this problem in Sencha Touch. Here's the code ported over to Touch-friendliness (2.0.1.1), for anyone interested.


Ext.define('Ext.data.writer.override.Json', { override: 'Ext.data.writer.Json',
getRecordData: function(record) {
var me = this, i, association, type, key, store, childData,
data = this.callParent(arguments),
associations = typeof record.getAssociations === 'function' ? record.getAssociations() : [];

for (i = 0; i < associations.length; i++){
association = associations.get(i);
type = association.getType();
key = association.getAssociationKey();

if (type.toLowerCase() == 'hasmany'){
store = record[association.getName()]();

if(store && store.getCount() > 0){
//Nest all of the store items.
data[key] = [];
store.each(function(childRecord) {
data[key].push(childRecord.getData());
}, me);

}
//If the store is empty and the key already exists with items, make it an empty array.
else if(data[key] && data[key].length){
data[key] = [];
}
}
}




return data;
}
});

rmetrich
9 Oct 2012, 8:00 AM
This code gets not called when only the associated data has been updated for a given record since Ext.data.Store.sync() will not find anything to update.

MikeMueller
12 Nov 2012, 6:32 AM
I used the code from flanders. It works only if the data of the main model is changed. If I only change the associated data, I need to call manually


record.dirt=true;
store.sync();


Has anyone a solution for that?

Thx Mike

flanders
12 Nov 2012, 6:35 AM
Have you explored the source? If you step through the call through sync() you can most likely see how it is determined which records are dirty. If you can override this method with an extra hook to check for changed parts your done.

MikeMueller
12 Nov 2012, 7:55 AM
Thx flanders.

I write two functions for the store to check all associations too



Ext.override(Ext.data.Store, {
filterNew:function(record) {
var me = this, i, association, childStore, childRecordPhantom=false;

if( record.phantom === false && record.associations.length )
{
for (i = 0; i < record.associations.length; i++)
{
association = record.associations.get(i);
if (association.type == 'hasMany')
{
childStore = eval('record.'+association.name+'()');
childStore.each(function(childRecord) {
childRecordPhantom = childRecord.phantom === true && childRecord.isValid();
if( childRecordPhantom )
{
return false;
}
}, me);
if( childRecordPhantom )
{
break;
}
}
}
}

return childRecordPhantom || record.phantom === true && record.isValid();
},
filterUpdated: function(record) {
var me = this, i, association, childStore, childRecordDirty=false;

if( record.dirty === false && record.phantom === false && record.associations.length )
{
for (i = 0; i < record.associations.length; i++)
{
association = record.associations.get(i);
if (association.type == 'hasMany')
{
childStore = eval('record.'+association.name+'()');
childStore.each(function(childRecord) {
childRecordDirty = childRecord.dirty === true && childRecord.phantom !== true && childRecord.isValid();
if( childRecordDirty )
{
return false;
}
}, me);
if( childRecordDirty )
{
break;
}
}
}
}

return childRecordDirty || record.dirty === true && record.phantom !== true && record.isValid();
}
});


Now I just have to call

store.sync();

But I cant find a way to use autostore on the master models store, so I only need to modfiy child model data. Have you another hint for me?
I find a function "afterEdit" on the model which fires the "afterEdit" event on the associated store, but this needs to bubble up to the master model store. I dont know how this can be done.

Thx Mike

flanders
12 Nov 2012, 9:39 AM
How about looking for the code that creates the childStore? At that point, you can add an afteredit listener to that store I think?

MikeMueller
13 Nov 2012, 2:49 AM
Thx again flanders, but I dont find the right code position to do so.
I find a solution that works for my problem. I add a listener to the master store which adds a datachange event to all childstores.



listeners: {
load: {
fn: function(store,aRecords)
{
var i, j, association, childStore,record;
for (j = 0; j < aRecords.length; j++)
{
for (i = 0; i < aRecords[j].associations.length; i++)
{
record = aRecords[j];
association = record.associations.get(i);
if (association.type == 'hasMany')
{
childStore = eval('record.'+association.name+'()');
childStore.addListener( 'datachanged', function(){record.store.sync();} );
}
}
}
}
}
},


Mike

anthony.jd
13 Feb 2013, 2:57 PM
This still seems to be an issue in the current version available to me: 4.1.1a. Thanks to Chrisface and others on this thread for the helpful feedback. Using Chrisface's code and modifying it with feedback & fixes from others, I came up with the extended class below, which I post here in case it saves anyone else some time:





Ext.define('MetaTools.lib.data.writer.DeepJson', {
extend: 'Ext.data.writer.Json',
getRecordData: function (record, operation) {
//Setup variables
var me = this,
i,
association,
childStore,
data;

data = me.callParent(arguments);

//Iterate over all the hasMany associations
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
if (association.type == 'hasMany') {
data[association.name] = null;
childStore = record[association.storeName];

//Iterate over all the children in the current association
childStore.each(function (childRecord) {

if (!data[association.name]) {
data[association.name] = [];
}

//Recursively get the record data for children (depth first)
var childData = this.getRecordData.call(this, childRecord);

/*
* If the child was marked dirty or phantom it must be added. If there was data returned that was neither
* dirty or phantom, this means that the depth first recursion has detected that it has a child which is
* either dirty or phantom. For this child to be put into the prepared data, it's parents must be in place whether
* they were modified or not.
*/
if (childRecord.dirty || childRecord.phantom || (childData != null)) {
data[association.name].push(childData);
record.setDirty();
}
}, me);

/*
* Iterate over all the removed records and add them to the preparedData. Set a flag on them to show that
* they are to be deleted
*/
Ext.each(childStore.removed, function (removedChildRecord) {
//Set a flag here to identify removed records
removedChildRecord.set('forDeletion', true);
var removedChildData = this.getRecordData.call(this, removedChildRecord);
data[association.name].push(removedChildData);
record.setDirty();
}, me);
}
}

//Only return data if it was dirty, new or marked for deletion.
if (record.dirty || record.phantom || record.get('forDeletion')) {
return data;
}
return null;
}
});

JoseCR
26 Feb 2013, 8:40 AM
Anthony.jd, for some reason your code isn't working for me. Specifically this piece of code (it's returning null), however the childRecord object does have the correct data.


var childData = this.getRecordData.call(this, childRecord);

anthony.jd
27 Feb 2013, 9:36 AM
Hi Jose,

Just from quickly scanning through the class, it looks like it would return null, "Only return data if it was dirty, new or marked for deletion," so if your record was neither new nor deleted nor dirty (edited), then this code would return null. It is designed to only return data that has changed and therefore needs to be synced with its remote source.

If you wanted it to return all data regardless of its status, you could alter the last few lines, replacing


//Only return data if it was dirty, new or marked for deletion.
if (record.dirty || record.phantom || record.get('forDeletion')) {
return data;
}
return null;


with simply:


return data;

rupamkhaitan
12 Apr 2013, 3:05 AM
hi chrisface,

Thanks for sharing your code. I have a doubt

Will it take care of multiple relationship?
Combination of hasOne & hasMany?

rupamkhaitan
12 Apr 2013, 3:07 AM
my last post was for chrisface who explained the below approach


This is something I had to do as well myself and I took the same approach of overriding the getRecordData function to take into account the associations. I also added some code to take care of deleted records as well. I'm adding a forDeletion property only for deleted records so that I can keep it out of the fields on the models.



Ext.data.writer.Json.override({
/*
* This function overrides the default implementation of json writer. Any hasMany relationships will be submitted
* as nested objects. When preparing the data, only children which have been newly created, modified or marked for
* deletion will be added. To do this, a depth first bottom -> up recursive technique was used.
*/
getRecordData: function(record) {
//Setup variables
var me = this, i, association, childStore, data = record.data;

//Iterate over all the hasMany associations
for (i = 0; i < record.associations.length; i++) {
association = record.associations.get(i);
data[association.name] = null;
childStore = record[association.storeName];

//Iterate over all the children in the current association
childStore.each(function(childRecord) {

if (!data[association.name]){
data[association.name] = [];
}

//Recursively get the record data for children (depth first)
var childData = this.getRecordData.call(this, childRecord);

/*
* If the child was marked dirty or phantom it must be added. If there was data returned that was neither
* dirty or phantom, this means that the depth first recursion has detected that it has a child which is
* either dirty or phantom. For this child to be put into the prepared data, it's parents must be in place whether
* they were modified or not.
*/
if (childRecord.dirty | childRecord.phantom | (childData != null)){
data[association.name].push(childData);
record.setDirty();
}
}, me);

/*
* Iterate over all the removed records and add them to the preparedData. Set a flag on them to show that
* they are to be deleted
*/
Ext.each(childStore.removed, function(removedChildRecord) {
//Set a flag here to identify removed records
removedChildRecord.set('forDeletion', true);
var removedChildData = this.getRecordData.call(this, removedChildRecord);
data[association.name].push(removedChildData);
record.setDirty();
}, me);
}

//Only return data if it was dirty, new or marked for deletion.
if (record.dirty | record.phantom | record.get('forDeletion')){
return data;
}
}
});


I'm not sure if this code will work under every condition but it's been fine for me so far. If you have any suggestions for improvements or a better way of doing it I'd like to hear your thoughts.

HriBB
17 Apr 2013, 6:55 PM
Any ideas on how to configure json reader to handle deepjson response from model.save() or store.sync()?

I have deepjson writer functional and working. Server receives all data, creates, updates, deletes as necessary ... And responds with the same data structure as it received, except that phantom records now have IDs, creation dates are there, and maybe some other parameters are present that are set on the server when record is first created.

So my question is ... how do you apply data returned from deepjson writer to the existing data and mark all those records !dirty and !phantom? Is json reader even capable of doing that? Will store.sync() take care of everything if readers are set up properly? If yes, how to configure reader?

I guess I'll have to go deep into source code ... :|

delebash
14 May 2013, 3:33 PM
Any comments from Sencha support on this issue. Handling change tracking using associated data, ie form has parent store and associated data store and store.save is for parent. So it only tracks parent fields dirty and not associated. Others have manually coded to handle some of this, but really I would think by now using 4.2 this would be built in?

HriBB
14 May 2013, 4:06 PM
http://docs.sencha.com/extjs/4.2.0/#!/api/Ext.data.Model-cfg-clientIdProperty

I think that if you set clientIdProperty: 'clientId' on Ext.data.Model and then return clientId parameter in the response object together with newly assigned idProperty, the framework should able to figure out which records are new and which phantom record to they belong to, even for deeply nested responses.

Response should look something like this:


{
id: 123,
children: [
{id: 123, clientId: -1, name: 'name1', ...},
{id: 124, clientId: -2, name: 'name2', ...},
{...},
{...}
]
}

I'm not 100% sure, it was very late when I tested it, so maybe you can give it a try and confirm this. Hopefully I'll find some time this week to thoroughly test this.

As for sending all data, just create a custom json writer. In one of my projects, I have a model with multiple hasMany associations and they have hasMany who have hasMany ... :) I just send the entire object graph with custom json writer, process data on server, and return complete object graph back. I know it's an overhead to send all data, but it works :) Of course in the future I plan to send only modified/new data ...

The model association framework still needs some work, especially with reading/writing deeply nested data. And some more documentation with advanced examples wouldn't hurt. But I'm sure that Sencha team will improve this :)

delebash
15 May 2013, 4:45 AM
I think I get the custom writer part, and have seen the examples here on how to implement. The problem I am having is just getting a simple combobox on an edit form that has its primary record data loaded via from.load from the grid or list view. The problem is 2 stores 1 that loads the primary record 1 that loads the data for the combobox, they are not linked, so for example when the form loads the data and I set the value of the combobox manually from the primary models associated data, when I change the value of the combobox the primary store is not marked as dirty and therefore does not sync when I try to save. Since no sync event fired I don't even get to the writer part.

In addition I am setting selected value of combobox manually but shouldn't this be automatic since I have association setup and valueField of combobox with the key id equal to key id of associated record? Tried using form.load twice once for primary and once for associated as I read some post that this may work, but it doesn't

Here is some code
Edit form list and edit functions

list: function () {

var mystore = this.getStore('Contacts')
mystore.proxy.extraParams = { $expand: 'ContactType'};
mystore.load({
params: {
},
callback: function(r,options,success) {
// debugger;
} //callback
}); //store.load

// mystore.proxy.extraParams = { $expand: 'ContactType'};
// var User = this.getContactModel();
// User.load(258, {
// success: function (user) {
// console.log("Loaded user 258: " + user.get('lastName'));
// }
// });
},
editContact: function (grid, record) {
var store = this.getStore('ContactTypes');
store.load({
params: {
},
callback: function(r,options,success) {
// debugger;
} //callback
}); //store.load

var view = Ext.widget('contactsedit');
var form = view.down('form')
var combo = form.down('combobox');

// combo.store.add({id:3, name:'expert wanna-be'});
form.loadRecord(record);
var mykey = record.getContactType().data.__KEY
combo.setValue(mykey)
//
// debugger;
// form.loadRecord(record.getContactType());
// debugger;
// form.loadRecord(records.baseModel());
//form.loadRecords(records.getAt(0)); this will load your form with id & name
// form.loadRecords(records.getAt(0).getterName();
//form.loadRecord(record);
this.addnew = false
},

Associations are working hasOne between Contact and ContactType as I can use
record.getContactType() to get the associated ContactType data for Contact.

Edit view combobox snippet

{
xtype: 'combobox',
fieldLabel: 'Contact Type',
store: 'ContactTypes',
valueNotFoundText: 'not found',
displayField: 'name',
valueField: '__KEY',
typeAhead: true,
queryMode: 'local',
emptyText: 'Select a type...'
}

Really I am a more than a bit frustrated this should be a simple task. I feel like I must be missing something major as this seems very difficult to achieve. I just want to have a simple grid view that a user clicks on a row, then edit view pops up with primary record fields set and a combobox that sets the associated records value and save back to server. This is my first time building an app with ExtJs, you guys who have already developed apps with Extjs, the scenario I am describing at least I think is very common, most basic add/edit with grid and edit form, nothing fancy.

How have you all accomplished this task?

Should I not use associations?

If not use associations, what is the best practice for this using Extjs 4.2 mvc, any working examples, I would so very much appreciate it!

Just to clarify, I have two tables Contact with basic fname and lname, then a ContactsType, this is just a lookup table, like a states or countries table. In fact I would add two more comboboxes just like ContactType with Countries and States as associated hasOne relations if I can get it working easily.

Really need some help, spent hours and hours reading forums, google, docs, ect. Trying different things here and there. One thing is I don't want to just hack this problem with my javascript. Gotta be a best practices example for this somewhere, right?

Sorry for the rant? Just beating my head against a brick wall right now........

HriBB
15 May 2013, 8:55 AM
I've had a similar problem with parent who hasMany children. I solved it by using grid cellediting plugin, which works great for editing parent data, and for associated children I created a custom editor which extends Ext.form.field.Picker and handles get/set manually. And I also use a custom template to render children in grid. In your case you could extend Ext.form.field.Combobox which also extends Ext.form.field.Picker and override get/set functions. But for that you'll need to get your hands dirty and dig into the source code. Hint:


Ext.util.Observable.capture(observable, function() {
console.log('capture', arguments);
});

Anyway, you'll have to handle this manually. But it would be cool if you could handle association editing through config.

delebash
15 May 2013, 12:00 PM
Thanks, for the hint, I guess I will go digging, yeah so much fun....