ember-data-model-fragments 7.0.3 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ import { assert } from '@ember/debug';
2
+ import { getOwner } from '@ember/application';
3
+ import JSONAPISerializer from '@ember-data/serializer/json-api';
4
+ import RESTSerializer from '@ember-data/serializer/rest';
5
+
6
+ /**
7
+ * Helper function to implement fragment transform lookup.
8
+ * Used by FragmentSerializer, FragmentRESTSerializer, and FragmentJSONAPISerializer.
9
+ *
10
+ * @param {Serializer} serializer - The serializer instance
11
+ * @param {String} attributeType - The attribute type to get the transform for
12
+ * @param {Function} superTransformFor - The parent class's transformFor method
13
+ * @return {Transform}
14
+ * @private
15
+ */
16
+ export function fragmentTransformFor(
17
+ serializer,
18
+ attributeType,
19
+ superTransformFor,
20
+ ) {
21
+ if (attributeType.indexOf('-mf-') !== 0) {
22
+ return superTransformFor.call(serializer, attributeType);
23
+ }
24
+
25
+ const owner = getOwner(serializer);
26
+ const containerKey = `transform:${attributeType}`;
27
+
28
+ if (!owner.hasRegistration(containerKey)) {
29
+ const match = attributeType.match(
30
+ /^-mf-(fragment|fragment-array|array)(?:\$([^$]+))?(?:\$(.+))?$/,
31
+ );
32
+ assert(
33
+ `Failed parsing ember-data-model-fragments attribute type ${attributeType}`,
34
+ match != null,
35
+ );
36
+ const transformName = match[1];
37
+ const type = match[2];
38
+ const polymorphicTypeProp = match[3];
39
+ let transformClass = owner.factoryFor(`transform:${transformName}`);
40
+ transformClass = transformClass && transformClass.class;
41
+ transformClass = transformClass.extend({
42
+ type,
43
+ polymorphicTypeProp,
44
+ store: serializer.store,
45
+ });
46
+ owner.register(containerKey, transformClass);
47
+ }
48
+ return owner.lookup(containerKey);
49
+ }
50
+
51
+ /**
52
+ * Helper function to implement fragment-aware applyTransforms.
53
+ * Used by FragmentSerializer, FragmentRESTSerializer, and FragmentJSONAPISerializer.
54
+ *
55
+ * @param {Serializer} serializer - The serializer instance
56
+ * @param {Class} typeClass - The model class
57
+ * @param {Object} data - The data to apply transforms to
58
+ * @return {Object} The transformed data
59
+ * @private
60
+ */
61
+ export function fragmentApplyTransforms(serializer, typeClass, data) {
62
+ const attributes = typeClass.attributes;
63
+
64
+ // Handle regular @attr transforms
65
+ typeClass.eachTransformedAttribute((key, attrType) => {
66
+ if (data[key] === undefined) {
67
+ return;
68
+ }
69
+
70
+ const transform = serializer.transformFor(attrType);
71
+ const transformMeta = attributes.get(key);
72
+ data[key] = transform.deserialize(data[key], transformMeta.options, data);
73
+ });
74
+
75
+ // Also handle @array computed properties with transforms
76
+ // These are not in eachTransformedAttribute because they're computed properties,
77
+ // not regular @attr attributes
78
+ typeClass.eachComputedProperty((key, meta) => {
79
+ if (data[key] === undefined) {
80
+ return;
81
+ }
82
+
83
+ // Only handle @array attributes that have a transform type (arrayTransform)
84
+ // meta.arrayTransform is the child transform type (e.g., 'string')
85
+ // meta.type is the full transform type string (e.g., '-mf-array$string')
86
+ if (meta?.isFragment && meta.kind === 'array' && meta.arrayTransform) {
87
+ // Use the full transform type string from meta.type
88
+ const transform = serializer.transformFor(meta.type);
89
+ data[key] = transform.deserialize(data[key], meta.options, data);
90
+ }
91
+ });
92
+
93
+ return data;
94
+ }
95
+
96
+ function isFragmentAttribute(meta) {
97
+ return (
98
+ meta &&
99
+ meta.isFragment &&
100
+ (meta.kind === 'fragment' ||
101
+ meta.kind === 'fragment-array' ||
102
+ meta.kind === 'array')
103
+ );
104
+ }
105
+
106
+ function serializedAttributesHash(serializer, snapshot, payload) {
107
+ // JSON:API: fragment attributes belong inside payload.data.attributes.
108
+ // If the model has no regular @attr fields, JSONAPISerializer#serialize
109
+ // omits `attributes` entirely, so we create it here. We dispatch by
110
+ // serializer type rather than payload shape, because a model could
111
+ // legitimately declare an @attr named `data` under RESTSerializer /
112
+ // JSONSerializer and we must not clobber it.
113
+ if (serializer instanceof JSONAPISerializer) {
114
+ if (payload?.data && typeof payload.data === 'object') {
115
+ payload.data.attributes ??= {};
116
+ return payload.data.attributes;
117
+ }
118
+ return payload;
119
+ }
120
+
121
+ // RESTSerializer: payload is keyed by the model's payload key, e.g.
122
+ // `{ person: { ...attrs } }`.
123
+ if (serializer instanceof RESTSerializer) {
124
+ const rootKey = serializer.payloadKeyFromModelName?.(snapshot.modelName);
125
+ if (rootKey && payload?.[rootKey] && typeof payload[rootKey] === 'object') {
126
+ return payload[rootKey];
127
+ }
128
+ return payload;
129
+ }
130
+
131
+ // JSONSerializer (default): flat hash keyed by attribute names.
132
+ return payload;
133
+ }
134
+
135
+ /**
136
+ * Helper function to serialize computed fragment attributes that newer
137
+ * ember-data versions no longer include via eachAttribute.
138
+ *
139
+ * @param {Serializer} serializer
140
+ * @param {Snapshot} snapshot
141
+ * @param {Object} payload
142
+ * @return {Object}
143
+ * @private
144
+ */
145
+ export function fragmentSerialize(serializer, snapshot, payload) {
146
+ const attributes = serializedAttributesHash(serializer, snapshot, payload);
147
+ const modelClass = serializer.store.modelFor(snapshot.modelName);
148
+
149
+ modelClass.eachComputedProperty((key, meta) => {
150
+ if (!isFragmentAttribute(meta)) {
151
+ return;
152
+ }
153
+
154
+ const value = snapshot.attr(key);
155
+
156
+ if (value === undefined) {
157
+ return;
158
+ }
159
+
160
+ const attributeKey = serializer.keyForAttribute(key, 'serialize');
161
+ const transform = serializer.transformFor(meta.type);
162
+
163
+ attributes[attributeKey] = transform.serialize(
164
+ value,
165
+ meta.options,
166
+ snapshot,
167
+ );
168
+ });
169
+
170
+ return payload;
171
+ }
172
+
173
+ /**
174
+ * Helper function to extract attributes including fragment attributes.
175
+ * The default extractAttributes only iterates modelClass.eachAttribute which
176
+ * doesn't include fragment attributes (they're computed properties).
177
+ *
178
+ * Used by FragmentSerializer and FragmentRESTSerializer.
179
+ *
180
+ * @param {Serializer} serializer - The serializer instance
181
+ * @param {Class} modelClass - The model class
182
+ * @param {Object} resourceHash - The raw resource data from the server
183
+ * @param {Function} superExtractAttributes - The parent's extractAttributes method
184
+ * @return {Object} The extracted attributes
185
+ * @private
186
+ */
187
+ export function fragmentExtractAttributes(
188
+ serializer,
189
+ modelClass,
190
+ resourceHash,
191
+ superExtractAttributes,
192
+ ) {
193
+ // First, call parent to get regular attributes
194
+ const attributes = superExtractAttributes.call(
195
+ serializer,
196
+ modelClass,
197
+ resourceHash,
198
+ );
199
+
200
+ // Then, add fragment attributes
201
+ modelClass.eachComputedProperty((key, meta) => {
202
+ if (isFragmentAttribute(meta)) {
203
+ const attributeKey = serializer.keyForAttribute(key, 'deserialize');
204
+ if (resourceHash[attributeKey] !== undefined) {
205
+ attributes[key] = resourceHash[attributeKey];
206
+ }
207
+ }
208
+ });
209
+
210
+ return attributes;
211
+ }
212
+
213
+ /**
214
+ * Helper function to extract attributes including fragment attributes for JSON:API.
215
+ * JSON:API serializers have attributes nested under resourceHash.attributes.
216
+ *
217
+ * Used by FragmentJSONAPISerializer.
218
+ *
219
+ * @param {Serializer} serializer - The serializer instance
220
+ * @param {Class} modelClass - The model class
221
+ * @param {Object} resourceHash - The raw resource data from the server
222
+ * @param {Function} superExtractAttributes - The parent's extractAttributes method
223
+ * @return {Object} The extracted attributes
224
+ * @private
225
+ */
226
+ export function fragmentExtractAttributesJSONAPI(
227
+ serializer,
228
+ modelClass,
229
+ resourceHash,
230
+ superExtractAttributes,
231
+ ) {
232
+ // First, call parent to get regular attributes
233
+ const attributes = superExtractAttributes.call(
234
+ serializer,
235
+ modelClass,
236
+ resourceHash,
237
+ );
238
+
239
+ // For JSON:API serializers, attributes are nested under resourceHash.attributes
240
+ const attrHash = resourceHash.attributes || resourceHash;
241
+
242
+ // Then, add fragment attributes
243
+ modelClass.eachComputedProperty((key, meta) => {
244
+ if (isFragmentAttribute(meta)) {
245
+ const attributeKey = serializer.keyForAttribute(key, 'deserialize');
246
+ if (attrHash[attributeKey] !== undefined) {
247
+ attributes[key] = attrHash[attributeKey];
248
+ }
249
+ }
250
+ });
251
+
252
+ return attributes;
253
+ }
package/addon/store.js ADDED
@@ -0,0 +1,301 @@
1
+ import { assert } from '@ember/debug';
2
+ import { getOwner } from '@ember/application';
3
+ import Store from 'ember-data/store';
4
+ import {
5
+ macroCondition,
6
+ dependencySatisfies,
7
+ importSync,
8
+ } from '@embroider/macros';
9
+ import FragmentCache from './cache/fragment-cache';
10
+ import { default as Fragment } from './fragment';
11
+ import { installCacheManagerCompat } from './util/fragment-cache';
12
+
13
+ // Import side-effects to ensure monkey-patches are applied
14
+ // These must be imported before any store instances are created
15
+ import './ext'; // Applies Snapshot monkey-patch for fragment serialization
16
+
17
+ /**
18
+ FragmentStore is the base store class for ember-data-model-fragments.
19
+
20
+ To use this addon, you must create an application store service that extends FragmentStore:
21
+
22
+ ```js
23
+ // app/services/store.js
24
+ import FragmentStore from 'ember-data-model-fragments/store';
25
+
26
+ export default class extends FragmentStore {}
27
+ ```
28
+
29
+ Your application serializer should also extend one of the fragment-aware serializers:
30
+
31
+ ```js
32
+ // app/serializers/application.js
33
+ import FragmentSerializer from 'ember-data-model-fragments/serializer';
34
+
35
+ export default class extends FragmentSerializer {}
36
+ ```
37
+
38
+ @class FragmentStore
39
+ @extends Store
40
+ @public
41
+ */
42
+ export default class FragmentStore extends Store {
43
+ get cache() {
44
+ return installCacheManagerCompat(this, super.cache);
45
+ }
46
+
47
+ /**
48
+ * Override createCache to return our FragmentCache
49
+ * This is the V2 Cache hook introduced in ember-data 4.7+
50
+ *
51
+ * @method createCache
52
+ * @param {Object} storeWrapper
53
+ * @return {FragmentCache}
54
+ * @public
55
+ */
56
+ createCache(storeWrapper) {
57
+ return new FragmentCache(storeWrapper);
58
+ }
59
+
60
+ /**
61
+ * Override createSchemaService to provide fragment-aware schema for ember-data 4.13+
62
+ *
63
+ * In ember-data 4.13, a new schema service architecture was introduced that only
64
+ * recognizes attributes with `kind: 'attribute'`. Fragment attributes use different
65
+ * kinds ('fragment', 'fragment-array', 'array'), so they need special handling.
66
+ *
67
+ * This method is only called in ember-data 4.13+. For ember-data 4.12, this method
68
+ * doesn't exist in the Store class, so it's never invoked.
69
+ *
70
+ * The `macroCondition` ensures build-time optimization:
71
+ * - For 4.12 builds: This code is completely removed (tree-shaking)
72
+ * - For 4.13 builds: Only the FragmentSchemaService path remains
73
+ *
74
+ * @method createSchemaService
75
+ * @return {FragmentSchemaService|undefined}
76
+ * @public
77
+ */
78
+ createSchemaService() {
79
+ if (macroCondition(dependencySatisfies('ember-data', '>=4.13.0-alpha.0'))) {
80
+ const { buildSchema } = importSync('@ember-data/model/hooks');
81
+
82
+ const FragmentSchemaService = importSync('./schema-service').default;
83
+
84
+ return new FragmentSchemaService(this, buildSchema(this));
85
+ }
86
+ // For ember-data 4.12, this method is never called (doesn't exist in Store base class)
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Override teardownRecord to handle fragments in a disconnected state.
92
+ * In ember-data 4.12+, fragments can end up disconnected during unload,
93
+ * and the default teardownRecord fails when trying to destroy them.
94
+ *
95
+ * @method teardownRecord
96
+ * @param {Model} record
97
+ * @public
98
+ */
99
+ teardownRecord(record) {
100
+ // Check if record is a fragment (by checking if it has no id or by model type)
101
+ // We need to handle the case where the fragment's store is disconnected
102
+ if (record.isDestroyed || record.isDestroying) {
103
+ return;
104
+ }
105
+ try {
106
+ record.destroy();
107
+ } catch (e) {
108
+ // If the error is about disconnected state, just let it go
109
+ // The fragment will be cleaned up by ember's garbage collection
110
+ if (
111
+ e?.message?.includes?.('disconnected state') ||
112
+ e?.message?.includes?.('cannot utilize the store')
113
+ ) {
114
+ return;
115
+ }
116
+ throw e;
117
+ }
118
+ }
119
+
120
+ /**
121
+ Create a new fragment that does not yet have an owner record.
122
+ The properties passed to this method are set on the newly created
123
+ fragment.
124
+
125
+ To create a new instance of the `name` fragment:
126
+
127
+ ```js
128
+ store.createFragment('name', {
129
+ first: 'Alex',
130
+ last: 'Routé'
131
+ });
132
+ ```
133
+
134
+ @method createFragment
135
+ @param {String} modelName - The type of fragment to create
136
+ @param {Object} props - A hash of properties to set on the newly created fragment
137
+ @return {Fragment} fragment
138
+ @public
139
+ */
140
+ createFragment(modelName, props) {
141
+ assert(
142
+ `The '${modelName}' model must be a subclass of MF.Fragment`,
143
+ this.isFragment(modelName),
144
+ );
145
+ // Create a new identifier for the fragment
146
+ const identifier = this.identifierCache.createIdentifierForNewRecord({
147
+ type: modelName,
148
+ });
149
+ // Signal to cache that this is a new record
150
+ this.cache.clientDidCreate(identifier, props || {});
151
+ if (macroCondition(dependencySatisfies('ember-data', '>=5.8.0'))) {
152
+ const record = this._instanceCache.getRecord(identifier);
153
+
154
+ if (props) {
155
+ const definitions =
156
+ this.getSchemaDefinitionService().fields(identifier);
157
+
158
+ for (const [key, value] of Object.entries(props)) {
159
+ if (!definitions.has(key)) {
160
+ record.set(key, value);
161
+ }
162
+ }
163
+ }
164
+
165
+ return record;
166
+ }
167
+
168
+ // Get the record instance
169
+ return this._instanceCache.getRecord(identifier, props);
170
+ }
171
+
172
+ /**
173
+ Override `serializerFor` so fragment models never fall back to
174
+ `serializer:application`.
175
+
176
+ Why: a typical app's `serializer:application` is a REST or JSON:API
177
+ serializer that does not know how to normalize a raw fragment hash. In
178
+ particular, a JSON:API application serializer would trip the assert in
179
+ `FragmentTransform.deserializeSingle` and break fragment deserialization
180
+ on ember-data 4.12 (and any other path that runs the fragment transform
181
+ pipeline).
182
+
183
+ Resolution order for fragment model names:
184
+ 1. `serializer:{modelName}` (if the app registered a per-fragment serializer)
185
+ 2. `serializer:-fragment` (consumer-overridable global fragment serializer)
186
+ 3. `serializer:-mf-fragment` (lazily-registered default `FragmentSerializer`,
187
+ which extends `JSONSerializer` and is what the fragment pipeline expects)
188
+
189
+ Non-fragment lookups defer to the parent `serializerFor`, preserving normal
190
+ app behavior (including `serializer:application` fallback).
191
+
192
+ Implementation note: in ember-data 5.x, `serializerFor` is defined on the
193
+ parent `Store` as a class-field arrow function, which is assigned per
194
+ instance during the parent constructor and would shadow any prototype-level
195
+ override declared on this subclass. To handle both shapes (class field on
196
+ 5.x, prototype method on 4.12) we install the override in the constructor,
197
+ which runs after the parent constructor and therefore wins in either case.
198
+ The original `serializerFor` is captured and used for non-fragment lookups.
199
+
200
+ This restores the pre-4.13 behavior that was lost when the previous
201
+ `Store.reopen({ serializerFor })` from `addon/ext.js` was removed.
202
+
203
+ @method serializerFor
204
+ @param {String} modelName
205
+ @return {Serializer}
206
+ @public
207
+ */
208
+ constructor(...args) {
209
+ super(...args);
210
+
211
+ const parentSerializerFor =
212
+ typeof this.serializerFor === 'function'
213
+ ? this.serializerFor.bind(this)
214
+ : null;
215
+
216
+ this.serializerFor = (modelName) => {
217
+ if (typeof modelName === 'string' && this._isFragmentSafe(modelName)) {
218
+ return this._fragmentSerializerFor(modelName);
219
+ }
220
+ if (parentSerializerFor) {
221
+ return parentSerializerFor(modelName);
222
+ }
223
+ return null;
224
+ };
225
+ }
226
+
227
+ /**
228
+ Resolve a serializer for a fragment model name.
229
+
230
+ @private
231
+ */
232
+ _fragmentSerializerFor(modelName) {
233
+ const owner = getOwner(this);
234
+
235
+ // 1. Per-fragment-type serializer (e.g. app/serializers/name.js).
236
+ // `owner.lookup('serializer:<name>')` returns `undefined` when no
237
+ // registration / app file exists, so a non-undefined result means a
238
+ // consumer actually provided one.
239
+ const perTypeKey = `serializer:${modelName}`;
240
+ let serializer = owner.lookup(perTypeKey);
241
+ if (serializer !== undefined) {
242
+ return serializer;
243
+ }
244
+
245
+ // 2. Consumer-provided global fragment serializer.
246
+ serializer = owner.lookup('serializer:-fragment');
247
+ if (serializer !== undefined) {
248
+ return serializer;
249
+ }
250
+
251
+ // 3. Lazily register and return our default FragmentSerializer.
252
+ // This is JSON-based (not REST/JSON:API), which is what the fragment
253
+ // pipeline expects.
254
+ const FALLBACK_KEY = 'serializer:-mf-fragment';
255
+ if (!owner.hasRegistration(FALLBACK_KEY)) {
256
+ const FragmentSerializer = importSync('./serializers/fragment').default;
257
+ owner.register(FALLBACK_KEY, FragmentSerializer);
258
+ }
259
+ return owner.lookup(FALLBACK_KEY);
260
+ }
261
+
262
+ /**
263
+ Like `isFragment`, but never throws for unknown model names. `serializerFor`
264
+ is called with synthetic names (e.g. `-default`, `application`, transform
265
+ types, etc.) so we can't let `modelFor` blow up.
266
+
267
+ @private
268
+ */
269
+ _isFragmentSafe(modelName) {
270
+ if (
271
+ !modelName ||
272
+ modelName === 'application' ||
273
+ modelName === '-default' ||
274
+ modelName.charAt(0) === '-'
275
+ ) {
276
+ return false;
277
+ }
278
+ try {
279
+ return this.isFragment(modelName);
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ /**
286
+ Returns true if the modelName is a fragment, false if not
287
+
288
+ @method isFragment
289
+ @param {String} modelName - The modelName to check if a fragment
290
+ @return {Boolean}
291
+ @public
292
+ */
293
+ isFragment(modelName) {
294
+ if (modelName === 'application' || modelName === '-default') {
295
+ return false;
296
+ }
297
+
298
+ const type = this.modelFor(modelName);
299
+ return Fragment.detect(type);
300
+ }
301
+ }
@@ -1,6 +1,4 @@
1
- import { assert } from '@ember/debug';
2
1
  import Transform from '@ember-data/serializer/transform';
3
- import JSONAPISerializer from '@ember-data/serializer/json-api';
4
2
  import { service } from '@ember/service';
5
3
  import { recordIdentifierFor } from '@ember-data/store';
6
4
 
@@ -80,13 +78,11 @@ const FragmentTransform = Transform.extend({
80
78
  deserializeSingle(data, options, parentData) {
81
79
  const store = this.store;
82
80
  const modelName = this.modelNameFor(data, options, parentData);
81
+ // `FragmentStore#serializerFor` guarantees a JSON-based serializer
82
+ // (FragmentSerializer / JSONSerializer) for fragment model names, so we
83
+ // do not need to defend against REST/JSON:API serializers here.
83
84
  const serializer = store.serializerFor(modelName);
84
85
 
85
- assert(
86
- 'The `JSONAPISerializer` is not suitable for model fragments, please use `JSONSerializer`',
87
- !(serializer instanceof JSONAPISerializer),
88
- );
89
-
90
86
  const typeClass = store.modelFor(modelName);
91
87
  const serialized = serializer.normalize(typeClass, data);
92
88
 
@@ -0,0 +1,59 @@
1
+ const CACHE_METHODS = [
2
+ 'createFragmentRecordData',
3
+ 'getFragment',
4
+ 'hasFragment',
5
+ 'setDirtyFragment',
6
+ 'isFragmentDirty',
7
+ 'getFragmentOwner',
8
+ 'setFragmentOwner',
9
+ 'newFragmentIdentifierForKey',
10
+ 'getFragmentArrayCache',
11
+ 'setFragmentArrayCache',
12
+ 'rollbackFragment',
13
+ 'hasChangedFragments',
14
+ 'changedFragments',
15
+ 'getFragmentCanonicalState',
16
+ 'getFragmentCurrentState',
17
+ ];
18
+
19
+ function installCacheManagerCompat(store, rawCache = store.cache) {
20
+ const cacheManager = rawCache;
21
+ const cache = cacheManager?.___cache;
22
+
23
+ if (!cacheManager || !cache || cacheManager.__mfCompatInstalled) {
24
+ return cache || cacheManager;
25
+ }
26
+
27
+ Object.defineProperty(cacheManager, '__mfCompatInstalled', {
28
+ value: true,
29
+ configurable: true,
30
+ });
31
+
32
+ Object.defineProperty(cacheManager, '__innerCache', {
33
+ get() {
34
+ return cache.__innerCache;
35
+ },
36
+ configurable: true,
37
+ });
38
+
39
+ CACHE_METHODS.forEach((methodName) => {
40
+ if (typeof cacheManager[methodName] === 'function') {
41
+ return;
42
+ }
43
+
44
+ Object.defineProperty(cacheManager, methodName, {
45
+ value(...args) {
46
+ return cache[methodName](...args);
47
+ },
48
+ configurable: true,
49
+ });
50
+ });
51
+
52
+ return cache;
53
+ }
54
+
55
+ export { installCacheManagerCompat };
56
+
57
+ export default function fragmentCacheFor(store) {
58
+ return installCacheManagerCompat(store);
59
+ }