Skip to content
This repository has been archived by the owner on Mar 20, 2022. It is now read-only.

Support denormalizing from immutable entities #228

Merged
merged 4 commits into from
Feb 9, 2017

Conversation

jeffcarbs
Copy link
Contributor

@jeffcarbs jeffcarbs commented Jan 25, 2017

Fixes #222

Problem

First I want to say that I've been using and following this repo for quite a while and it's great to see denormalize finally land!

It seems that most places I've seen that suggest using normalizr also suggest using an immutable store for entities. However, in doing so you lose the ability to use denormalize. Adding immutable as a dependency would add complexity and bloat for users who are not using immutable data.

Solution

Update denormalize to handle both mutable and immutable entities without actually requiring immutable as a dependency.

Support is handled by using simple getIn, setIn, hasIn methods that defer to the immutable implementations if the object is immutable otherwise implement the same behavior on plain javascript objects/arrays. immutable-js is required as a dev dependency since it's needed for specs. I just updated the existing specs to also test with immutable entities.

The best part is that it only adds ~70 bytes (compressed) and ~40 bytes (minified + compressed) and doesn't add any external dependencies.

TODO

  • Add & update tests
  • Ensure CI is passing (lint, tests, flow)
  • Update relevant documentation

Size Diff

Before

Package:      dist/normalizr.js
Bundle Size:  18.06 KB
Compressed:   3.93 KB

Package:      dist/normalizr.min.js
Bundle Size:  8.76 KB
Compressed:   2.48 KB

After

Package:      dist/normalizr.js
Bundle Size:  19.31 KB
Compressed:   4.09 KB

Package:      dist/normalizr.min.js
Bundle Size:  9.25 KB
Compressed:   2.61 KB

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 15a013a on jcarbo:feature/immutable into e365e51 on paularmstrong:master.

Object {
"id": 1,
"name": "Milo",
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that immutable data structures are converted to plain objects when serialized into snapshots, see: jestjs/jest#1622. In this case, the Array should be an actual array since the test uses [1,2] as input but each object is actually an Immutable.Map (which I manually verified is the case).

@paularmstrong
Copy link
Owner

Nice work @jcarbo! For the most part this looks good. I have a few nit requests, I think, but I'd like to spend more time with it once I'm back (currently traveling till next week)

Copy link
Owner

@paularmstrong paularmstrong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need rebase, as denormalize has changed a bit to handle recursive schema definitions.

I'm also really concerned about edge-cases here, since the fallback checks are very much not fool-proof.

Couldn't we set a flag at the top level of the denormalize method that can pass down whether the entities provided are immutable or not? Is it safe to assume when one entity is immutable, that all are immutable as well?

const entity = typeof entityOrId === 'object' ? entityOrId : entities[this.key][entityOrId];
const entityCopy = { ...entity };
const entity = typeof entityOrId === 'object' ? entityOrId : getIn(entities, [ this.key, entityOrId ]);
let entityCopy = isImmutable(entity) ? entity : { ...entity };
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using let, please

Object.keys(this.schema).forEach((key) => {
if (entityCopy.hasOwnProperty(key)) {
if (hasIn(entityCopy, [ key ])) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the isImmutable(entity) method before doing getIn, hasIn, and setIn to avoid unnecessary overhead?

* @return {bool}
*/
export function isImmutable(object) {
return !!(object && object.getIn);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edge-case: what if someone has a key, getIn on their entity? (also true for setIn and hasIn.

@@ -13,14 +15,14 @@ export const normalize = (schema, input, parent, key, visit, addEntity) => {
};

export const denormalize = (schema, input, unvisit, entities) => {
const object = { ...input };
let inputCopy = isImmutable(input) ? input : { ...input };
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: avoid let statements.

@@ -1,3 +1,5 @@
import { getIn, hasIn, isImmutable, setIn } from './ImmutableUtils';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: import * as ImmutableUtils from './ImmutableUtils';

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 4debf74 on jcarbo:feature/immutable into b4dd248 on paularmstrong:master.

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling d2da777 on jcarbo:feature/immutable into b4dd248 on paularmstrong:master.

@jeffcarbs
Copy link
Contributor Author

jeffcarbs commented Feb 3, 2017

@paularmstrong - thanks for reviewing! I rebased and got the tests back to green.

Also, reading through your comments it seems that your (valid) concerns were around performance/overhead and robustness. The commit I just pushed up takes a slightly different approach and should address your concerns:

  • to reduce false positives, make the isImmutable check much more specific. This is a little brittle given it's relying on some internals of Immutable.Map and Immutable.Record but I think the tradeoff is more than worth it to avoid bringing in immutable as a dependency. Also the immutable library is one of the more stable ones I've seen in the javascript world so it's unlikely we'll be affected.
  • There was a lot indirection introduced so that the same denormalize functions could handle both immutable and mutable objects. Instead, at the top of the denormalize function in the Entity and Object schemas we first check if the object is immutable and, if so, pass it to a separate denormalizeImmutable record.

With these changes, the compressed size increased by another ~90 bytes. Still not that bad imho.

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 72365c1 on jcarbo:feature/immutable into b4dd248 on paularmstrong:master.

Copy link
Owner

@paularmstrong paularmstrong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. A couple nits marked inline, but I don't think we'll need any major changes. I'll test more when I get some time.

src/index.js Outdated
return entityOrId;
}

if (ImmutableUtils.isImmutable(entities)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be sent in as a flag, since entities is a wrapped argument in the getEntities function. This would save on this conditional over every entity iteration. Something like...

const getEntities = (entities, visitedEntities) => {
  const isImmutable = ImmutableUtils.isImmutable(entities);
  return (schema, entityOrId) => {
    // ...
    const entity = getEntity(entityOrId, schemaKey, entities, isImmutable);
    // ...
  };
};

this._getId = typeof idAttribute === 'function' ? idAttribute : (input) => input[idAttribute];
this._getId = typeof idAttribute === 'function' ?
idAttribute :
(input) => ImmutableUtils.isImmutable(input) ? input.get(idAttribute) : input[idAttribute];
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid nested ternaries, please? They get difficult to read.

return Object.keys(schema).reduce((object, key) => {
// Immutable maps cast keys to strings on write so we need to ensure
// we're accessing them using string keys.
const stringKey = typeof key === 'string' ? key : key.toString();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be safe to just force casting it:

const stringKey = `${key}`;

@jeffcarbs
Copy link
Contributor Author

Just pushed those changes 👍

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 2d379ab on jcarbo:feature/immutable into b4dd248 on paularmstrong:master.

@jeffcarbs
Copy link
Contributor Author

Friendly nudge on this when you get a chance :)

Copy link
Owner

@paularmstrong paularmstrong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the slowness and thanks for the nudge! We recently rolled new mobile.twitter.com out to a ton more people!.

Just one little nit request inline. Can you add a case for it? I can get this merged and deployed today if so!

export function isImmutable(object) {
return !!(object && (
object.hasOwnProperty('__ownerID') || // Immutable.Map
(object._map && object._map.object.hasOwnProperty('__ownerID')) // Immutable.Record
Copy link
Owner

@paularmstrong paularmstrong Feb 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is never hit by tests. Can you add a case for this?

@coveralls
Copy link

Coverage Status

Coverage remained the same at 100.0% when pulling 99baca4 on jcarbo:feature/immutable into b4dd248 on paularmstrong:master.

@jeffcarbs
Copy link
Contributor Author

Good call on that missing coverage, the conditional was actually broken!

@@ -58,16 +59,30 @@ const unvisit = (input, schema, getDenormalizedEntity) => {
return method(schema, input, unvisit, getDenormalizedEntity);
}

if (input === undefined || input === null) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wanted to call your attention to this change added in the last commit. This came up in the test with immutable records because it always instantiates all fields defined in the schema, using the default value if you didn't give it pass it one. E.g.

> Menu = new Immutable.Record({ id: null, food: null })
[Function: Record]
> menuData = { id: 2 }
{ id: 2 }
> nonRecordMenu = menuData
{ id: 2 }
> recordMenu = new Menu(menuData)
Record { "id": 2, "food": null }

So while denormalizing the record, it's going to try to unvisit food: null which currently throws an unexpected error in _getId

This assumes that you're not going to have undefined or null resolve to anything during the denormalization process. Is that an ok assumption to make?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good one and similar to #225. This is good. Thanks!

Copy link
Contributor Author

@jeffcarbs jeffcarbs Feb 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another related thing I just realized: this currently throws that same unexpected error if an entity is not found:

const mySchema = new schema.Entity('tacos');
const entities = {
  tacos: {
    1: { id: 1, type: 'foo' }
  }
};

> denormalize(3, mySchema, entities)
TypeError: Cannot read property 'id' of undefined
    at EntitySchema._getId (~/normalizr/dist/normalizr.js:226:65)
    at EntitySchema.getId (~/normalizr/dist/normalizr.js:245:19)
    at ~/normalizr/dist/normalizr.js:635:21
    at EntitySchema.denormalize (~/normalizr/dist/normalizr.js:273:20)
    at unvisit (~/normalizr/dist/normalizr.js:612:17)
    at Object.denormalize$1 [as denormalize] (~/normalizr/dist/normalizr.js:651:10)
    at repl:1:3
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)

I kinda feel like that should just return undefined. In my apps, I think I'd rather just ignore missing data, especially when denormalizing nested data.

I could also see it being configurable so you can handle how you want (e.g. initialize with empty object, return null,undefined, throw error)

denormalize(3, mySchema, entities, { onNotFound: (id, schema) => undefined })
denormalize(3, mySchema, entities, { onNotFound: (id, schema) => { id } })
denormalize(3, mySchema, entities, { onNotFound: (id, schema) => throw new Error(`could not find ${schema.name} with id='${id}'` })

Thoughts? If you agree I'd probably remove this line and just handle that below if an entity is not found in the store.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there was a PR for this and it was randomly closed... #229. Makes sense to fix. We don't ever run across that case, but I could see it being possible.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, already merged this PR... so maybe a new one?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, my brain isn't working today... I'd rather stay away from the extra configuration, as you proposed. I'd expect the application to handle receiving null for an entity elsewhere than pushing more into normalizr.

@paularmstrong
Copy link
Owner

Nice job on this! Looks like we've actually increased the performance since the initial release of normalizr2 and this addition doesn't have any ill effects! We used to be right at about 200%. Now looking at 220%.

normalzr2 x 111 ops/sec ±4.77% (70 runs sampled)
normalizr3 x 246 ops/sec ±4.60% (73 runs sampled)

@paularmstrong paularmstrong merged commit 99baca4 into paularmstrong:master Feb 9, 2017
@jeffcarbs jeffcarbs deleted the feature/immutable branch February 9, 2017 16:47
@jeffcarbs jeffcarbs mentioned this pull request Feb 9, 2017
3 tasks
@lock
Copy link

lock bot commented May 7, 2018

This thread has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@lock lock bot added the Outdated label May 7, 2018
@lock lock bot locked as resolved and limited conversation to collaborators May 7, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants