Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Last active December 21, 2019 19:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jamesarosen/cb88d09244d7061398463fc10f208000 to your computer and use it in GitHub Desktop.
Save jamesarosen/cb88d09244d7061398463fc10f208000 to your computer and use it in GitHub Desktop.
Ember-Data: hasZeroOrOne

Background

In our app, we have a number of different has-zero-or-one relationships where the foreign object may or may not exist. For example, a Customer may or may not have a CreditCard on file, but it won't have more than one.

We started with something like

// app/models/customer.js
export default DS.Model.extend({
  creditCard: DS.belongsTo('credit-card', { async: true }),
})

// app/routes/customer/credit-card.js
import { isNotFoundError } from 'ember-ajax/errors'

export Ember.Route.extend({
  model() {
    const customer = this.modelFor('customer')
  
    return customer.get('creditCard').catch((e) => {
      if (!isNotFoundError(e)) { throw e }

      return this.get('store').createRecord('credit-card', { customer })
    })
  },
})

It's a bit cumbersome to have that error handling every time we fetch one of these models, though.

Use the Adapter

My first attempt was to use ember-data's adapter to solve this:

// app/adapters/application.js
export DS.JSONAPIAdapter.extend({
  findBelongsTo(store, snapshot, url, relationship) {
    return this._super(...arguments).catch((response) => {
      if (isNotFoundError(response) && relationship.options.blankOn404) {
        return {
          data: {
            id: snapshot.id,
            type: relationship.type
          }
        }
      }
      throw response;
    })
  },
})

Then I can declare any relationship as an upsert-to-blank relationship:

// app/models/customer.js
creditCard: DS.belongsTo('credit-card', { async: true, blankOn404: true }),

And finally, I can clean up my route:

// app/routes/customer/credit-card.js
model() {
  const customer = this.modelFor('customer')
  return customer.get('creditCard')
}

This works great!

Except now the creditCard record is never new. I can't, for example, do

<button type=submit>{{if creditCard.isNew 'Create' 'Update'}}</button>

And when I try to call creditCard.save(), it will always make a PATCH, never a POST.

I can get around this by giving the model a special id and mixing in some logic to the models

// app/adapters/application.js
return {
  data: {
    id: `${snapshot.id}--empty`,
    type: relationship.type
  }
}

// app/mixins/empty-support.js
export default Ember.Mixin.create({
  isEmpty: Ember.computed('id', function() {
    return /--empty$/.test(this.get('id'))
  })
})

// app/models/credit-card.js
import EmptySupport from '../mixins/empty-support'
export default DS.Model.extend(EmptySupport)

That sort of works, but it means I also need to change the adapter to check isEmpty when deciding whether to do a POST or a PATCH.

fetchOrBuild Mixin

My second idea was to create a mixin:fetch-or-build and be more explicit about when I'm invoking that logic:

// app/mixins/fetch-or-build.js
export default Ember.Mixin.create({
  fetchOrBuild(relationshipName) {
    const relationship = this.relationshipFor(relationshipName)
    Ember.assert(`Cannot find relationship ${relationshipName}`, relationship)

    const foreignObject = this.get(relationshipName)

    if (!foreignObject.catch) { return foreignObject } // not a promise-proxy

    return foreignObject.catch((e) => {
      if (!isNotFoundError(e)) { throw e }

      const foreignType = relationship.type
      const thisType = this.constructor.modelName
      const attributes = { [thisType]: this }

      return this.store.createRecord(foreignType, attributes);
    })
  }
})

// app/models/customer.js
import FetchOrBuild from '../fetch-or-build'
export default Ember.Model.extend(FetchOrBuild, {
  creditCard: DS.belongsTo('credit-card', { async: true })
})

// app/routes/customer/credit-card.js
model() {
  const customer = this.modelFor('customer')
  return customer.fetchOrBuild('creditCard')
}

This also works... ish.

The major downside I've found is that if I wait until after ember-data has finished the fetch to catch the error, then the error logs to the console. I suspect that this is because of an Ember.run.join or an Ember.RSVP.resolve in ember-data's code.

Solve it at the Server

Of course, we could change the server to always return a 200 for GET /customers/CUSTOMER_ID/credit-card, even if no object exists in the database. The server would have to accept PATCHes when there's no record as well, since the 200 indicates to the outside world that the record already exists.

This is probably the most elegant solution, but it probably isn't the easiest, especially given the large number of has-zero-or-one relationships we have in our application.

Other Ideas?

If you have other suggestions, I'd love to hear them! Comment in the doobly doo below 👇

@mwpastore
Copy link

mwpastore commented Sep 14, 2017

I might approach it like this:

// app/macros/belongs-to-with-default.js
import Ember from 'ember'

import { isNotFoundError } from 'ember-ajax/errors'

const ObjectPromiseProxy = Ember.ObjectProxy.extend(Ember.PromiseProxyMixin)

export default function(dependentKey, defaultValues={}) {
  return Ember.computed(dependentKey, {
    get() {
      const promise = this.get(dependentKey).catch((e) => {
        if (!isNotFoundError(e)) { throw e }

        const { type } = this.get('constructor.relationshipsByName').get(dependentKey)
        const model = this.get('store').createRecord(type, defaultValues)

        return this.set(dependentKey, model)
      })

      return ObjectPromiseProxy.create({ promise })
    },

    set(_, model) {
      return this.set(dependentKey, model)
    }
  })
}

// app/models/customer.js
import DS from 'ember-data'

import belongsToWithDefault from '../macros/belongs-to-with-default'

export default DS.Model.extend({
  creditCard: DS.belongsTo('credit-card'),

  creditCardWithDefault: belongsToWithDefault('creditCard')
})

// app/routes/customer/credit-card.js
import Ember from 'ember'

export default Ember.Route.extend({
  model() {
    return this.modelFor('customer').get('creditCardWithDefault')
  }
})

@jamesarosen
Copy link
Author

@mwpastore thanks for the suggestion! It's sooo close to working. Two problems:

  1. modelClass.get doesn't exit; it has to be Ember.get(modelClass, 'relationshipsByName').get(dependentKey)
  2. When you call store.createRecord(relatedType, { [inverseKey]: this }), it causes ember-data to create the related object (yay!). That then causes ember-data to set the related object on this, which causes a property-change on dependentKey, which invokes this getter again, and we get an infinite loop (boo!). I haven't figured out a good solution for that. I might be able to break the chain by manually caching the upstream this.get(`${dependentKey}.content`):
get(propertyName) {
  const cachedUpstream = this.get(`${dependentKey}.content`)
  const cacheUpstreamId = Ember.guidFor(cachedUpstream)
  if (this[`_${dependentKey}_id`] === cacheUpstreamId) {
    return this[`_${propertyName}_promiseProxy`]
  }

  ...
  const record = store.createRecord(...)
  this[`_${propertyName}_promiseProxy`] = record
  return record
  ...
}

@jamesarosen
Copy link
Author

🎉

@mwpastore
Copy link

mwpastore commented Sep 15, 2017

@jamesarosen Okay, I was able to break the cycle and greatly simplify the whole thing. Calling this.set(dependentKey, ..) from the CP lets Ember Data handle the association step (instead of setting the inverseKey at create time as in the original version). This still invalidates the CP right away, but now this.get(dependentKey) returns the recently-created-and-associated record instead of throwing another 404. Here's my stupid Twiddle that shows it working and setting the association correctly. Let me know what you think!

@urbany
Copy link

urbany commented Sep 15, 2017

@jamesarosen using @mwpastore solution you can also do something like this:

// app/serializers/customer.js
import ApplicationSerializer from './application';

export default ApplicationSerializer.extend({
  attrs: {
    _creditCard: { key: 'creditCard' }, // not sure if key should be creditCard or credit-card
  },
});

// app/models/customer.js
import DS from 'ember-data'

import belongsToWithDefault from '../macros/belongs-to-with-default'

export default DS.Model.extend({
  _creditCard: DS.belongsTo('credit-card'),

  creditCard: belongsToWithDefault('_creditCard')
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment