-
-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement support for Arrays #104
Implement support for Arrays #104
Conversation
Not sure how you feel about the approach I took here, but the gist is that merging arrays by index is weird -- we need to convert the arrays to objects with index-keys, making the objects "ArrayLike" so that we can merge two objects, rather than two arrays, which we already know how to do. I added some utilities in a new file to help out with this. Some outstanding questions:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this PR! I think I am generally in favor of this PR. A few more assertions to battle test this would be helpful.
Note we also have some preliminary checks before merging you can run locally - npm run test:ember-changeset && npm run test:ember-changeset-validations
options.safeSet(target, prop, {}); | ||
} else if (isObj && isChange(target[prop])) { | ||
} else if (isComplex && isChange(target[prop])) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initially, I thought this arm would be for a Change only - which would have an object shape. Are you indicating target[prop]
might be an array? Another way to say it - would getChangeValue
below work with a type of array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you indicating target[prop] might be an array?
yup, the object-array thing of today works with array's index access, which is cool. thanks implicit type casting! (for reals tho, this is one of the few times where I like this part of JS lol)
} | ||
|
||
return result; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
very nice!
test/index.test.ts
Outdated
expect(dummyChangeset.get('contact.emails')).toEqual(['bob@email.com', 'the_bob@email.com']); | ||
|
||
dummyChangeset.set('contact.emails.0', 'fred@email.com'); | ||
dummyChangeset.set('contact.emails.1', 'the_fred@email.com'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we have an assertion where get/set
is out of range?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh right. But do you think it is worthwhile adding a test for this custom implementation when contact.emails is only len 2 but we do the following?
dummyChangeset.set('contact.emails.10', 'the_fred@email.com');
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yup! I'm working on adding more tests now (as soon as I figure out how to make changeset.get('some array').toEqual(some array)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
96e4512
to
a87271e
Compare
Also added:
|
src/utils/object-tree-node.ts
Outdated
@@ -11,13 +12,24 @@ const objectProxyHandler = { | |||
*/ | |||
get(node: ProxyHandler, key: string): any { | |||
if (typeof key === 'symbol') { | |||
if (key === Symbol.iterator) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this feels fishy -- all of this is to make jest's expect work... which I feel like is kinda lying -- cause if you console.log(changeset.get('some array'), you get the ObjectNodeProxy, which.. I think is fine and it makes sense that you'd have to call unwrap on it to get the human-readable value out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking pretty sound!
@@ -118,11 +131,19 @@ class ObjectTreeNode implements ProxyHandler { | |||
if (isObject(content)) { | |||
changes = normalizeObject(changes, this.isObject); | |||
return { ...content, ...changes }; | |||
} else if (Array.isArray(content)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems safe for ember-data. B/c we aren't using the Ember provided isArray
.
|
||
changeset.set('contact.emails.1', null); | ||
|
||
expect(changeset.get('contact.emails').unwrap()).toEqual([null, null]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
idk if this is technically "deleting", but it made me wonder if changeset needs some post-processing callback to run before "save".
cause for some uses, you may want to collapse all the falsey values in an array, like,
data.array = data.array.filter(Boolean);
but I could also see some use cases where you'd want the falsey values
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case, the recommended way would be to .set('contact.emails', [])
? I'm happy leaving the nulls in there if that is what the set
did!
…and array-like objects
@snewcomer We should be good to go 👍 |
const nestedKeys = | ||
Array.isArray(target) || isArrayObject(target) | ||
? keys.slice(i + 1, keys.length).join('.') // remove first key segment as well as the index | ||
: keys.slice(1, keys.length).join('.'); // remove first key segment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if the object case needs to also be based off the index, but for the ArrayLike situation, the dynamic slice made sense
@@ -228,6 +228,112 @@ describe('Unit | Utility | set deep', () => { | |||
}); | |||
}); | |||
|
|||
it('set on nested properties within an empty object', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to add this test, because my intuitive feelings about how arrays should work wasn't working.
idk if this is intended, or why it would be intended, but intuitively, I would have thoughts this would have been the case:
// ...
expect(value).toEqual({
top: {
name: new Change('zoo'),
foo: new Change({
other: 'baz'
})
}
});
because the foo
is an entirely new object,
and how this correlates to arrays:
instead of
it('sets when the array is initially empty', () => {
const objA = {
top: []
};
let value = setDeep(objA, 'top.0.name', new Change('zoo'));
expect(value).toEqual({
top: [{ name: new Change('zoo') }]
});
value = setDeep(value, 'top.0.foo.other', new Change('baz'));
expect(value).toEqual({
top: [
{
name: new Change('zoo'),
foo: {
other: new Change('baz')
}
}
]
});
});
maybe:
it('sets when the array is initially empty', () => {
const objA = {
top: []
};
let value = setDeep(objA, 'top.0.name', new Change('zoo'));
expect(value).toEqual({
top: [new Change({ name: 'zoo' })]
});
value = setDeep(value, 'top.0.foo.other', new Change('baz'));
// the big difference here -- the first entry remains an entire single Change
expect(value).toEqual({
top: [
new Change({
name: 'zoo',
foo: {
other: 'baz'
}
})
]
});
});
}); | ||
}); | ||
|
||
describe('arrays at various nestings within an object', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've started using describe
blocks for groups of this stuff so I can debug scoped down sections of the test suite, so:
yarn test:debug:named "arrays at various nestings within an object"
allows me to debug just this subset of tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really really nice PR. Thank you and let me know when you are comfortable for me to merge and release!
|
||
changeset.set('contact.emails.1', null); | ||
|
||
expect(changeset.get('contact.emails').unwrap()).toEqual([null, null]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case, the recommended way would be to .set('contact.emails', [])
? I'm happy leaving the nulls in there if that is what the set
did!
Yeah, I'm good with merging. I think at this point, I need to get our internal work merged (pending releases of this, ember changeset, and ember validated changeset). Anything I run in to, I can open bugfix prs for 🎉 |
I found an inconsistency depending on the depth of a change: changeset.get('contacts.emails') // => requires unwrap
changeset.get('emails') // does not have unwrap I have a use case for wanting to access non-leaf data: iterating over the data to display navigation to different forms that would open a form to edit each array entry (reason being that when generating links and form fields, the index needs to be correct) -- so... I'm now wondering if the implemented approach is correct at all? I'm might open another PR and point it at the array-entry-setting branch to keep the ideas isolated. The main thing I need to solve right now, which I was suprised to find doesn't work with the current implementation is top level arrays as a value of some top level property, example: |
Turns out, I'm getting faster at this :D We should be good to go again :D |
I just keep finding issues. O.o @snewcomer I added another test for what I'm running in to now. Idk if you want to take a stab at it. But I wonder if some refactoring might need to happen in order to make this easier? We also need a consistent way to get the "current value of the subtree at 'this' path", like.. I was thinking maybe a My recent issues have come from top level properties having inconsistent need for unwrap. Example:
idk what the consequences of this are yet, but:
Maybe we can keep |
Just found out there is something in this PR that fixes normal object behavior. So maybe it is best to merge it and iterate later? thoughts? |
primary: 'primary0@email.com' | ||
}, | ||
null | ||
]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this test pass if you add these assertions (similar to the test below)?
expect(changeset.get('emails.0.fun')).toEqual('fun0@email.com');
expect(changeset.get('emails.0.primary')).toEqual('primary0@email.com');
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a more general bug though, and why my work stuff can't use the published validated changeset.
sometimes when accessing data via get
, an object returns.
In this case, the object is just totally incorrect. 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so same as the one below (was just checking if the value
was the culprit).
Overall, this is a really great PR and I think we are really close. I think we can focus on .get
or ObjectTreeNode
may need to be modified. I can certainly jump in and see where the last few improvements need to be made.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll take another stab, and see what happens :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed it -- wasn't so bad after all :D
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm really glad the tests are passing! Just a few questions/comments before we can get this in!
@@ -39,6 +39,7 @@ import { | |||
ValidatorAction, | |||
ValidatorMap | |||
} from './types'; | |||
import { isArrayObject } from './utils/array-object'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✂️
Should make sure this is caught by linting in CI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can PR tomorrow that adds / updates linting in the CI config tomorrow?
const tree = new ObjectTreeNode(subChanges, subContent, this.getDeep, this.isObject); | ||
return tree.proxy; | ||
} else if (typeof result !== 'undefined') { | ||
return result; | ||
} | ||
const baseContent = this.safeGet(content, baseKey); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the value of result
here for the array case? I thought one of the if expressions above would have been tripped. It looks like there might be some minor refactors here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
result
is undefined
here. It happens mostly when working with new array entries or previously deleted entries, iirc
// give back an object that can further retrieve changes and/or content | ||
const tree = new ObjectTreeNode(subChanges, baseContent, this.getDeep, this.isObject); | ||
|
||
return tree; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the array case, do we need to return an ObjectTreeNode? Just a little background just in case there is some intersting information for this discussion...
The main reason for the ObjectTreeNode
is to ensure we don't lose sibling keys. So an object with both changes
and unchanged content are able to be retrieved at any arbitrary level of the object.
{
changeValue: ...,
original: ...
}
Now for the array scenario, in one case we have changes
we want to return. In the other, we have content
we want to return. However, in both cases, I imagine that the type is an array and we don't have to deal with merging sibling keys.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the array case, do we need to return an ObjectTreeNode?
yeah, cause atm, ObjectTreeNode is the only thing that knows that changes are on arrays and can convert between arrays and array objects.
however this changes the behavior of changeset.get('prop.someArray')
where it now needs unwrap
, where it previously did not -- but this is more consistent with the deep object changes.
. However, in both cases, I imagine that the type is an array and we don't have to deal with merging sibling keys.
🤷
The overloaded behavior of this method is dizzying to me :(
Repro of some weird behavior: https://runkit.com/nullvoxpopuli/validated-changeset-with-deep-arrays
I have a complex object with objects as array entries that I'd like to manage with a changeset. :D
the example I have at work is:
I suppose creating arrays when the property is undefined doesn't really make sense, because you can't know if the user does indeed want an array, or a if they want an object with numbered keys that would behave kinda like an array.
I propose that if a value is an array, we set at indicies, rather than properties on an object