Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ jobs:
- ember-beta
- ember-canary
- ember-data-4.12
- ember-data-4.13
- embroider-safe
- embroider-optimized

Expand Down
24 changes: 24 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
####################
# super strict mode
####################
auto-install-peers=false
# Relaxed for ember-try compatibility - ember-try swaps dependency versions
# which naturally creates peer dependency mismatches during testing
strict-peer-dependents=false
resolve-peers-from-workspace-root=false

################
# Optimizations
################
# Less strict, but required for tooling to not barf on duplicate peer trees.
# (many libraries declare the same peers, which resolve to the same
# versions)
dedupe-peer-dependents=true
public-hoist-pattern[]=ember-source

################
# Compatibility
################
# highest is what everyone is used to, but
# not ensuring folks are actually compatible with declared ranges.
resolution-mode=highest
85 changes: 53 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,6 @@ Use the following table to decide which version of this project to use with your
| >= v3.28.x < v4.7.x | v6.0.x | 14+ |
| v4.12.x | v7.x | 18+ |

### Upgrading to ember-data 4.12

When using ember-data 4.12, you must add the following entry to your app's deprecation workflow to allow the app to boot. This addon uses `reopenClass` internally, which ember-data 4.12 deprecates:

```javascript
// config/deprecation-workflow.js
self.deprecationWorkflow = self.deprecationWorkflow || {};
self.deprecationWorkflow.config = {
workflow: [
{ handler: "silence", matchId: "ember-data:deprecate-model-reopenclass" },
],
};
```

## Installation

To install as an Ember CLI addon:
Expand All @@ -51,6 +37,36 @@ $ ember generate fragment foo someAttr:string anotherAttr:boolean

Which will create the module `app/models/foo.js` which exports a `Fragment` class with the given attributes.

## Setup

This addon requires you to extend the provided `FragmentStore` and a fragment-aware serializer in your application.

### Store

Create or update your application's store service to extend `FragmentStore`:

```javascript
// app/services/store.js

import FragmentStore from "ember-data-model-fragments/store";

export default class Store extends FragmentStore {}
```

### Serializer

Create or update your application serializer to extend one of the fragment-aware serializers:

```javascript
// app/serializers/application.js

import FragmentSerializer from "ember-data-model-fragments/serializer";

export default class ApplicationSerializer extends FragmentSerializer {}
```

See the [Serialization](#serialization) section for more options if you're using `RESTSerializer` or `JSONAPISerializer`.

## Example

```javascript
Expand Down Expand Up @@ -297,31 +313,36 @@ export default class NameSerializer extends JSONSerializer {
}
```

Since fragment deserialization uses the value of a single attribute in the parent model, the `normalizeResponse` method of the serializer is never used. And since the attribute value is not a full-fledged [JSON API](http://jsonapi.org/) response, `JSONAPISerializer` cannot be used with fragments. Because of this, auto-generated fragment serializers **do not use the application serializer** and instead use `JSONSerializer`.
Since fragment deserialization uses the value of a single attribute in the parent model, the `normalizeResponse` method of the serializer is never used. And since the attribute value is not a full-fledged [JSON API](http://jsonapi.org/) response, `JSONAPISerializer` cannot be used directly with fragments.

If common logic must be added to auto-generated fragment serializers, apps can register a custom `serializer:-fragment` with the application in an initializer.
Your application serializer should extend one of the fragment-aware serializers provided by this addon:

```javascript
// app/serializers/fragment.js
// app/serializers/application.js

import JSONSerializer from "@ember-data/serializer/json";
import FragmentSerializer from "ember-data-model-fragments/serializer";

export default class FragmentSerializer extends JSONSerializer {}
export default class ApplicationSerializer extends FragmentSerializer {}
```

If you're using `RESTSerializer`, use `FragmentRESTSerializer` instead:

```javascript
// app/initializers/fragment-serializer.js
// app/serializers/application.js

import FragmentSerializer from "../serializers/fragment";
import { FragmentRESTSerializer } from "ember-data-model-fragments/serializer";

export function initialize(application) {
application.register("serializer:-fragment", FragmentSerializer);
}
export default class ApplicationSerializer extends FragmentRESTSerializer {}
```

If you're using `JSONAPISerializer`, use `FragmentJSONAPISerializer`:

export default {
name: "fragment-serializer",
initialize: initialize,
};
```javascript
// app/serializers/application.js

import { FragmentJSONAPISerializer } from "ember-data-model-fragments/serializer";

export default class ApplicationSerializer extends FragmentJSONAPISerializer {}
```

If custom serialization of the owner record is needed, fragment [snapshots](http://emberjs.com/api/data/classes/DS.Snapshot.html) can be accessed using the [`Snapshot#attr`](http://emberjs.com/api/data/classes/DS.Snapshot.html#method_attr) method. Note that this differs from how relationships are accessed on snapshots (using `belongsTo`/`hasMany` methods):
Expand All @@ -330,9 +351,9 @@ If custom serialization of the owner record is needed, fragment [snapshots](http
// apps/serializers/person.js
// Fragment snapshots are accessed using `snapshot.attr()`

import JSONSerializer from "@ember-data/serializer/json";
import FragmentSerializer from "ember-data-model-fragments/serializer";

export default JSONSerializer.extend({
export default class PersonSerializer extends FragmentSerializer {
serialize(snapshot, options) {
const json = super.serialize(...arguments);

Expand All @@ -355,8 +376,8 @@ export default JSONSerializer.extend({
json.title_count = titlesSnapshot.length;

return json;
},
});
}
}
```

## Nesting
Expand Down
51 changes: 7 additions & 44 deletions addon/array/stateful.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,12 @@ import EmberObject, { get } from '@ember/object';
import { isArray } from '@ember/array';
import MutableArray from '@ember/array/mutable';
import { assert } from '@ember/debug';
import { diffArray } from '@ember-data/model/-private';
import { copy } from '../util/copy';
import { gte } from 'ember-compatibility-helpers';

/**
@module ember-data-model-fragments
*/

/**
* Whether the current version of ember supports array observers.
* Array observers were deprecated in ember 3.26 and removed in 4.0.
* @see https://deprecations.emberjs.com/v3.x#toc_array-observers
* @see https://github.com/emberjs/ember.js/pull/19833
* @type {boolean}
* @private
*/
export const HAS_ARRAY_OBSERVERS = !gte('4.0.0');

/**
A state-aware array that is tied to an attribute of a `DS.Model` instance.

Expand Down Expand Up @@ -88,14 +76,10 @@ const StatefulArray = EmberObject.extend(MutableArray, {

notify() {
this._isDirty = true;
if (HAS_ARRAY_OBSERVERS && this.hasArrayObservers && !this._hasNotified) {
this.retrieveLatest();
} else {
this._hasNotified = true;
this.notifyPropertyChange('[]');
this.notifyPropertyChange('firstObject');
this.notifyPropertyChange('lastObject');
}
this._hasNotified = true;
this.notifyPropertyChange('[]');
this.notifyPropertyChange('firstObject');
this.notifyPropertyChange('lastObject');
},

get length() {
Expand Down Expand Up @@ -181,30 +165,9 @@ const StatefulArray = EmberObject.extend(MutableArray, {

this._isDirty = false;
this._isUpdating = true;
if (HAS_ARRAY_OBSERVERS && this.hasArrayObservers && !this._hasNotified) {
// diff to find changes
const diff = diffArray(this.currentState, currentState);
// it's null if no change found
if (diff.firstChangeIndex !== null) {
// we found a change
this.arrayContentWillChange(
diff.firstChangeIndex,
diff.removedCount,
diff.addedCount,
);
this._length = currentState.length;
this.currentState = currentState;
this.arrayContentDidChange(
diff.firstChangeIndex,
diff.removedCount,
diff.addedCount,
);
}
} else {
this._hasNotified = false;
this._length = currentState.length;
this.currentState = currentState;
}
this._hasNotified = false;
this._length = currentState.length;
this.currentState = currentState;
this._isUpdating = false;
},

Expand Down
5 changes: 3 additions & 2 deletions addon/attributes/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export default function array(type, options) {
options,
};

// eslint-disable-next-line ember/require-computed-property-dependencies
return computed({
// Use computed with a dependency on hasDirtyAttributes which changes on rollback
// This ensures the computed property is re-evaluated when dirty state changes
return computed('currentState', 'hasDirtyAttributes', 'store.cache', {
get(key) {
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
Expand Down
5 changes: 3 additions & 2 deletions addon/attributes/fragment-array.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ export default function fragmentArray(type, options) {
options,
};

// eslint-disable-next-line ember/require-computed-property-dependencies
return computed({
// Use computed with a dependency on hasDirtyAttributes which changes on rollback
// This ensures the computed property is re-evaluated when dirty state changes
return computed('currentState', 'hasDirtyAttributes', 'store.cache', {
get(key) {
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
Expand Down
100 changes: 55 additions & 45 deletions addon/attributes/fragment.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,53 +60,63 @@ export default function fragment(type, options) {
options,
};

return computed('store.{_instanceCache,cache}', {
get(key) {
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
const fragmentIdentifier = cache.getFragment(identifier, key);
if (fragmentIdentifier === null) {
return null;
}
// Get the fragment record from the identifier
return this.store._instanceCache.getRecord(fragmentIdentifier);
},
set(key, value) {
assert(
'You must pass a fragment or null to set a fragment',
value === null || isFragment(value) || typeOf(value) === 'object',
);
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
if (value === null) {
cache.setDirtyFragment(identifier, key, null);
return null;
}
if (isFragment(value)) {
// Use computed with a dependency on hasDirtyAttributes which changes on rollback
// This ensures the computed property is re-evaluated when dirty state changes
const cp = computed(
'currentState',
'hasDirtyAttributes',
'store.{_instanceCache,cache}',
{
get(key) {
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
const fragmentIdentifier = cache.getFragment(identifier, key);
if (fragmentIdentifier === null) {
return null;
}
// Get the fragment record from the identifier
return this.store._instanceCache.getRecord(fragmentIdentifier);
},
set(key, value) {
assert(
`You can only set '${type}' fragments to this property`,
isInstanceOfType(this.store.modelFor(type), value),
'You must pass a fragment or null to set a fragment',
value === null || isFragment(value) || typeOf(value) === 'object',
);
const fragmentIdentifier = recordIdentifierFor(value);
setFragmentOwner(value, identifier, key);
cache.setDirtyFragment(identifier, key, fragmentIdentifier);
return value;
}
// Value is a plain object - update existing fragment or create new one
const fragmentIdentifier = cache.getFragment(identifier, key);
const actualType = getActualFragmentType(type, options, value, this);
if (fragmentIdentifier?.type !== actualType) {
// Create a new fragment
const fragment = this.store.createFragment(actualType, value);
const newFragmentIdentifier = recordIdentifierFor(fragment);
setFragmentOwner(fragment, identifier, key);
cache.setDirtyFragment(identifier, key, newFragmentIdentifier);
const identifier = recordIdentifierFor(this);
const cache = this.store.cache;
if (value === null) {
cache.setDirtyFragment(identifier, key, null);
return null;
}
if (isFragment(value)) {
assert(
`You can only set '${type}' fragments to this property`,
isInstanceOfType(this.store.modelFor(type), value),
);
const fragmentIdentifier = recordIdentifierFor(value);
setFragmentOwner(value, identifier, key);
cache.setDirtyFragment(identifier, key, fragmentIdentifier);
return value;
}
// Value is a plain object - update existing fragment or create new one
const fragmentIdentifier = cache.getFragment(identifier, key);
const actualType = getActualFragmentType(type, options, value, this);
if (fragmentIdentifier?.type !== actualType) {
// Create a new fragment
const fragment = this.store.createFragment(actualType, value);
const newFragmentIdentifier = recordIdentifierFor(fragment);
setFragmentOwner(fragment, identifier, key);
cache.setDirtyFragment(identifier, key, newFragmentIdentifier);
return fragment;
}
// Update existing fragment
const fragment =
this.store._instanceCache.getRecord(fragmentIdentifier);
fragment.setProperties(value);
return fragment;
}
// Update existing fragment
const fragment = this.store._instanceCache.getRecord(fragmentIdentifier);
fragment.setProperties(value);
return fragment;
},
},
}).meta(meta);
).meta(meta);

return cp;
}
Loading