diff --git a/packages/legacy-compat/.eslintrc.cjs b/packages/legacy-compat/.eslintrc.cjs index b13a54eda67..9ab544c4853 100644 --- a/packages/legacy-compat/.eslintrc.cjs +++ b/packages/legacy-compat/.eslintrc.cjs @@ -16,7 +16,7 @@ const config = { base.rules(), imports.rules(), isolation.rules({ - allowedImports: ['@ember/debug', '@ember/application'], + allowedImports: ['@ember/debug', '@ember/string', '@ember/application'], }), {} ), diff --git a/packages/legacy-compat/package.json b/packages/legacy-compat/package.json index 8e9d5c5ef6c..b9a0f5c2f4a 100644 --- a/packages/legacy-compat/package.json +++ b/packages/legacy-compat/package.json @@ -94,6 +94,7 @@ "@ember-data/json-api": "workspace:5.4.0-alpha.52", "@ember-data/request": "workspace:5.4.0-alpha.52", "@ember-data/store": "workspace:5.4.0-alpha.52", + "@ember/string": "^3.1.1", "@warp-drive/core-types": "workspace:0.0.0-alpha.38" }, "peerDependenciesMeta": { diff --git a/packages/legacy-compat/rollup.config.mjs b/packages/legacy-compat/rollup.config.mjs index 53615e2d1d0..0caa661de7b 100644 --- a/packages/legacy-compat/rollup.config.mjs +++ b/packages/legacy-compat/rollup.config.mjs @@ -19,7 +19,7 @@ export default { plugins: [ // These are the modules that users should be able to import from your // addon. Anything not listed here may get optimized away. - addon.publicEntrypoints(['index.js', '-private.js']), + addon.publicEntrypoints(['index.js', 'builders.js', '-private.js']), nodeResolve({ extensions: ['.ts'] }), babel({ diff --git a/packages/legacy-compat/src/builders.ts b/packages/legacy-compat/src/builders.ts new file mode 100644 index 00000000000..5f5f457c4ad --- /dev/null +++ b/packages/legacy-compat/src/builders.ts @@ -0,0 +1,19 @@ +/** + Builders for migrating from `store` methods to `store.request`. + + These builders enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @module @ember-data/legacy-compat/builders + @main @ember-data/legacy-compat/builders + @deprecated +*/ + +export { findAllBuilder as findAll } from './builders/find-all'; + +export { findRecordBuilder as findRecord } from './builders/find-record'; + +export { queryBuilder as query, queryRecordBuilder as queryRecord } from './builders/query'; + +export { saveRecordBuilder as saveRecord } from './builders/save-record'; diff --git a/packages/legacy-compat/src/builders/find-all.ts b/packages/legacy-compat/src/builders/find-all.ts new file mode 100644 index 00000000000..39045c07a28 --- /dev/null +++ b/packages/legacy-compat/src/builders/find-all.ts @@ -0,0 +1,62 @@ +/** + * @module @ember-data/legacy-compat/builders + */ +import { assert } from '@ember/debug'; + +import type { StoreRequestInput } from '@ember-data/store'; +import type { FindAllOptions } from '@ember-data/store/-types/q/store'; +import type { TypedRecordInstance, TypeFromInstance } from '@warp-drive/core-types/record'; +import { SkipCache } from '@warp-drive/core-types/request'; + +import { normalizeModelName } from './utils'; + +type FindAllRequestInput = StoreRequestInput & { + op: 'findAll'; + data: { + type: T; + options: FindAllBuilderOptions; + }; +}; + +type FindAllBuilderOptions = FindAllOptions; + +/** + This function builds a request config to perform a `findAll` request for the given type. + When passed to `store.request`, this config will result in the same behavior as a `store.findAll` request. + Additionally, it takes the same options as `store.findAll`. + + All `@ember-data/legacy-compat` builders exist to enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @method findAll + @deprecated + @public + @static + @for @ember-data/legacy-compat/builders + @param {string} type the name of the resource + @param {object} query a query to be used by the adapter + @param {FindAllBuilderOptions} [options] optional, may include `adapterOptions` hash which will be passed to adapter.findAll + @return {FindAllRequestInput} request config +*/ +export function findAllBuilder( + type: TypeFromInstance, + options?: FindAllBuilderOptions +): FindAllRequestInput>; +export function findAllBuilder(type: string, options?: FindAllBuilderOptions): FindAllRequestInput; +export function findAllBuilder(type: string, options: FindAllBuilderOptions = {}): FindAllRequestInput { + assert(`You need to pass a model name to the findAll builder`, type); + assert( + `Model name passed to the findAll builder must be a dasherized string instead of ${type}`, + typeof type === 'string' + ); + + return { + op: 'findAll', + data: { + type: normalizeModelName(type), + options: options || {}, + }, + cacheOptions: { [SkipCache as symbol]: true }, + }; +} diff --git a/packages/legacy-compat/src/builders/find-record.ts b/packages/legacy-compat/src/builders/find-record.ts new file mode 100644 index 00000000000..d1b1514eb99 --- /dev/null +++ b/packages/legacy-compat/src/builders/find-record.ts @@ -0,0 +1,113 @@ +/** + * @module @ember-data/legacy-compat/builders + */ +import { assert } from '@ember/debug'; + +import type { StoreRequestInput } from '@ember-data/store'; +import { constructResource, ensureStringId } from '@ember-data/store/-private'; +import type { BaseFinderOptions, FindRecordOptions } from '@ember-data/store/-types/q/store'; +import type { TypedRecordInstance, TypeFromInstance } from '@warp-drive/core-types/record'; +import { SkipCache } from '@warp-drive/core-types/request'; +import type { ResourceIdentifierObject } from '@warp-drive/core-types/spec/raw'; + +import { isMaybeIdentifier, normalizeModelName } from './utils'; + +type FindRecordRequestInput = StoreRequestInput & { + op: 'findRecord'; + data: { + record: ResourceIdentifierObject; + options: FindRecordBuilderOptions; + }; +}; + +type FindRecordBuilderOptions = Omit; + +/** + This function builds a request config to find the record for a given identifier or type and id combination. + When passed to `store.request`, this config will result in the same behavior as a `store.findRecord` request. + Additionally, it takes the same options as `store.findRecord`, with the exception of `preload` (which is unsupported). + + **Example 1** + + ```ts + import { findRecord } from '@ember-data/legacy-compat/builders'; + const { content: post } = await store.request(findRecord('post', '1')); + ``` + + **Example 2** + + `findRecord` can be called with a single identifier argument instead of the combination + of `type` (modelName) and `id` as separate arguments. You may recognize this combo as + the typical pairing from [JSON:API](https://jsonapi.org/format/#document-resource-object-identification) + + ```ts + import { findRecord } from '@ember-data/legacy-compat/builders'; + const { content: post } = await store.request(findRecord({ type: 'post', id })); + ``` + + All `@ember-data/legacy-compat` builders exist to enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @method findRecord + @deprecated + @public + @static + @for @ember-data/legacy-compat/builders + @param {string|object} type - either a string representing the name of the resource or a ResourceIdentifier object containing both the type (a string) and the id (a string) for the record or an lid (a string) of an existing record + @param {string|number|object} id - optional object with options for the request only if the first param is a ResourceIdentifier, else the string id of the record to be retrieved + @param {FindRecordBuilderOptions} [options] - if the first param is a string this will be the optional options for the request. See examples for available options. + @return {FindRecordRequestInput} request config +*/ +export function findRecordBuilder( + resource: TypeFromInstance, + id: string, + options?: FindRecordBuilderOptions +): FindRecordRequestInput>; +export function findRecordBuilder( + resource: string, + id: string, + options?: FindRecordBuilderOptions +): FindRecordRequestInput; +export function findRecordBuilder( + resource: ResourceIdentifierObject>, + options?: FindRecordBuilderOptions +): FindRecordRequestInput>; +export function findRecordBuilder( + resource: ResourceIdentifierObject, + options?: FindRecordBuilderOptions +): FindRecordRequestInput; +export function findRecordBuilder( + resource: string | ResourceIdentifierObject, + idOrOptions?: string | FindRecordBuilderOptions, + options?: FindRecordBuilderOptions +): FindRecordRequestInput { + assert( + `You need to pass a modelName or resource identifier as the first argument to the findRecord builder`, + resource + ); + if (isMaybeIdentifier(resource)) { + options = idOrOptions as BaseFinderOptions | undefined; + } else { + assert( + `You need to pass a modelName or resource identifier as the first argument to the findRecord builder (passed ${resource})`, + typeof resource === 'string' + ); + const type = normalizeModelName(resource); + const normalizedId = ensureStringId(idOrOptions as string | number); + resource = constructResource(type, normalizedId); + } + + options = options || {}; + + assert('findRecord builder does not support options.preload', !(options as FindRecordOptions).preload); + + return { + op: 'findRecord' as const, + data: { + record: resource, + options, + }, + cacheOptions: { [SkipCache as symbol]: true }, + }; +} diff --git a/packages/legacy-compat/src/builders/query.ts b/packages/legacy-compat/src/builders/query.ts new file mode 100644 index 00000000000..25794265e3a --- /dev/null +++ b/packages/legacy-compat/src/builders/query.ts @@ -0,0 +1,135 @@ +/** + * @module @ember-data/legacy-compat/builders + */ +import { assert } from '@ember/debug'; + +import type { StoreRequestInput } from '@ember-data/store'; +import type { QueryOptions } from '@ember-data/store/-types/q/store'; +import type { TypedRecordInstance, TypeFromInstance } from '@warp-drive/core-types/record'; +import { SkipCache } from '@warp-drive/core-types/request'; + +import { normalizeModelName } from './utils'; + +type QueryRequestInput = StoreRequestInput & { + op: 'query'; + data: { + type: T; + query: Record; + options: QueryBuilderOptions; + }; +}; + +type QueryBuilderOptions = QueryOptions; + +/** + This function builds a request config for a given type and query object. + When passed to `store.request`, this config will result in the same behavior as a `store.query` request. + Additionally, it takes the same options as `store.query`. + + All `@ember-data/legacy-compat` builders exist to enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @method query + @deprecated + @public + @static + @for @ember-data/legacy-compat/builders + @param {string} type the name of the resource + @param {object} query a query to be used by the adapter + @param {QueryBuilderOptions} [options] optional, may include `adapterOptions` hash which will be passed to adapter.query + @return {QueryRequestInput} request config +*/ +export function queryBuilder( + type: TypeFromInstance, + query: Record, + options?: QueryBuilderOptions +): QueryRequestInput>; +export function queryBuilder( + type: string, + query: Record, + options?: QueryBuilderOptions +): QueryRequestInput; +export function queryBuilder( + type: string, + query: Record, + options: QueryBuilderOptions = {} +): QueryRequestInput { + assert(`You need to pass a model name to the query builder`, type); + assert(`You need to pass a query hash to the query builder`, query); + assert( + `Model name passed to the query builder must be a dasherized string instead of ${type}`, + typeof type === 'string' + ); + + return { + op: 'query' as const, + data: { + type: normalizeModelName(type), + query, + options: options, + }, + cacheOptions: { [SkipCache as symbol]: true }, + }; +} + +type QueryRecordRequestInput = StoreRequestInput & { + op: 'queryRecord'; + data: { + type: T; + query: Record; + options: QueryBuilderOptions; + }; +}; + +/** + This function builds a request config for a given type and query object. + When passed to `store.request`, this config will result in the same behavior as a `store.queryRecord` request. + Additionally, it takes the same options as `store.queryRecord`. + + All `@ember-data/legacy-compat` builders exist to enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @method queryRecord + @deprecated + @public + @static + @for @ember-data/legacy-compat/builders + @param {string} type the name of the resource + @param {object} query a query to be used by the adapter + @param {QueryBuilderOptions} [options] optional, may include `adapterOptions` hash which will be passed to adapter.query + @return {QueryRecordRequestInput} request config +*/ +export function queryRecordBuilder( + type: TypeFromInstance, + query: Record, + options?: QueryBuilderOptions +): QueryRecordRequestInput>; +export function queryRecordBuilder( + type: string, + query: Record, + options?: QueryBuilderOptions +): QueryRecordRequestInput; +export function queryRecordBuilder( + type: string, + query: Record, + options?: QueryBuilderOptions +): QueryRecordRequestInput { + assert(`You need to pass a model name to the queryRecord builder`, type); + assert(`You need to pass a query hash to the queryRecord builder`, query); + assert( + `Model name passed to the queryRecord builder must be a dasherized string instead of ${type}`, + typeof type === 'string' + ); + + return { + op: 'queryRecord', + data: { + type: normalizeModelName(type), + query, + options: options || {}, + }, + cacheOptions: { [SkipCache as symbol]: true }, + }; +} diff --git a/packages/legacy-compat/src/builders/save-record.ts b/packages/legacy-compat/src/builders/save-record.ts new file mode 100644 index 00000000000..28850bc5e70 --- /dev/null +++ b/packages/legacy-compat/src/builders/save-record.ts @@ -0,0 +1,93 @@ +/** + * @module @ember-data/legacy-compat/builders + */ +import { assert } from '@ember/debug'; + +import { recordIdentifierFor, storeFor, type StoreRequestInput } from '@ember-data/store'; +import type { InstanceCache } from '@ember-data/store/-private/caches/instance-cache'; +import type { StableRecordIdentifier } from '@warp-drive/core-types'; +import type { Cache } from '@warp-drive/core-types/cache'; +import type { TypedRecordInstance, TypeFromInstance } from '@warp-drive/core-types/record'; +import { SkipCache } from '@warp-drive/core-types/request'; + +type SaveRecordRequestInput = StoreRequestInput & { + op: 'createRecord' | 'deleteRecord' | 'updateRecord'; + data: { + record: StableRecordIdentifier; + options: SaveRecordBuilderOptions; + }; + records: [StableRecordIdentifier]; +}; + +type SaveRecordBuilderOptions = Record; + +function _resourceIsFullDeleted(identifier: StableRecordIdentifier, cache: Cache): boolean { + return cache.isDeletionCommitted(identifier) || (cache.isNew(identifier) && cache.isDeleted(identifier)); +} + +function resourceIsFullyDeleted(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean { + const cache = instanceCache.cache; + return !cache || _resourceIsFullDeleted(identifier, cache); +} + +/** + This function builds a request config for saving the given record (e.g. creating, updating, or deleting the record). + When passed to `store.request`, this config will result in the same behavior as a legacy `store.saveRecord` request. + Additionally, it takes the same options as `store.saveRecord`. + + All `@ember-data/legacy-compat` builders exist to enable you to migrate your codebase to using the correct syntax for `store.request` while temporarily preserving legacy behaviors. + This is useful for quickly upgrading an entire app to a unified syntax while a longer incremental migration is made to shift off of adapters and serializers. + To that end, these builders are deprecated and will be removed in a future version of Ember Data. + + @method saveRecord + @deprecated + @public + @static + @for @ember-data/legacy-compat/builders + @param {object} record a record to save + @param {SaveRecordBuilderOptions} options optional, may include `adapterOptions` hash which will be passed to adapter.saveRecord + @return {SaveRecordRequestInput} request config +*/ +export function saveRecordBuilder( + record: T, + options: Record = {} +): SaveRecordRequestInput> { + const store = storeFor(record); + assert(`Unable to initiate save for a record in a disconnected state`, store); + const identifier = recordIdentifierFor(record); + + if (!identifier) { + // this commonly means we're disconnected + // but just in case we throw here to prevent bad things. + throw new Error(`Record Is Disconnected`); + } + assert( + `Cannot initiate a save request for an unloaded record: ${identifier.lid}`, + store._instanceCache.recordIsLoaded(identifier) + ); + if (resourceIsFullyDeleted(store._instanceCache, identifier)) { + throw new Error('cannot build saveRecord request for deleted record'); + } + + if (!options) { + options = {}; + } + let operation: 'createRecord' | 'deleteRecord' | 'updateRecord' = 'updateRecord'; + + const cache = store.cache; + if (cache.isNew(identifier)) { + operation = 'createRecord'; + } else if (cache.isDeleted(identifier)) { + operation = 'deleteRecord'; + } + + return { + op: operation, + data: { + options, + record: identifier, + }, + records: [identifier], + cacheOptions: { [SkipCache as symbol]: true }, + }; +} diff --git a/packages/legacy-compat/src/builders/utils.ts b/packages/legacy-compat/src/builders/utils.ts new file mode 100644 index 00000000000..9faff88442d --- /dev/null +++ b/packages/legacy-compat/src/builders/utils.ts @@ -0,0 +1,40 @@ +import { deprecate } from '@ember/debug'; +import { dasherize } from '@ember/string'; + +import { DEPRECATE_NON_STRICT_TYPES } from '@ember-data/deprecations'; +import type { ResourceIdentifierObject } from '@warp-drive/core-types/spec/raw'; + +export function isMaybeIdentifier( + maybeIdentifier: string | ResourceIdentifierObject +): maybeIdentifier is ResourceIdentifierObject { + return Boolean( + maybeIdentifier !== null && + typeof maybeIdentifier === 'object' && + (('id' in maybeIdentifier && 'type' in maybeIdentifier && maybeIdentifier.id && maybeIdentifier.type) || + maybeIdentifier.lid) + ); +} + +export function normalizeModelName(type: string): string { + if (DEPRECATE_NON_STRICT_TYPES) { + const result = dasherize(type); + + deprecate( + `The resource type '${type}' is not normalized. Update your application code to use '${result}' instead of '${type}'.`, + result === type, + { + id: 'ember-data:deprecate-non-strict-types', + until: '6.0', + for: 'ember-data', + since: { + available: '5.3', + enabled: '5.3', + }, + } + ); + + return result; + } + + return type; +} diff --git a/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts b/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts index bd3b0805289..b0ded4a57ad 100644 --- a/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts +++ b/packages/legacy-compat/src/legacy-network-handler/legacy-network-handler.ts @@ -180,6 +180,8 @@ function saveRecord(context: StoreRequestContext): Promise { upgradeStore(store); + store.cache.willCommit(identifier, context); + const saveOptions = Object.assign( { [SaveOp]: operation as 'updateRecord' | 'deleteRecord' | 'createRecord' }, options diff --git a/packages/store/src/-private.ts b/packages/store/src/-private.ts index 86afd997c40..9ebddf4afae 100644 --- a/packages/store/src/-private.ts +++ b/packages/store/src/-private.ts @@ -16,9 +16,11 @@ export { isStableIdentifier, } from './-private/caches/identifier-cache'; +export { default as constructResource } from './-private/utils/construct-resource'; + // TODO this should be a deprecated helper but we have so much usage of it // to also eliminate -export { default as coerceId } from './-private/utils/coerce-id'; +export { default as coerceId, ensureStringId } from './-private/utils/coerce-id'; export type { NativeProxy } from './-private/record-arrays/native-proxy-type-fix'; export { default as RecordArray, diff --git a/packages/store/src/-private/store-service.ts b/packages/store/src/-private/store-service.ts index 839d4f3ef17..07155cfa5da 100644 --- a/packages/store/src/-private/store-service.ts +++ b/packages/store/src/-private/store-service.ts @@ -33,7 +33,7 @@ import type { ModelSchema } from '../-types/q/ds-model'; import type { OpaqueRecordInstance } from '../-types/q/record-instance'; import type { SchemaService } from '../-types/q/schema-service'; import type { FindAllOptions, FindRecordOptions, QueryOptions } from '../-types/q/store'; -import type { LifetimesService, StoreRequestContext, StoreRequestInput } from './cache-handler'; +import type { LifetimesService, StoreRequestInput } from './cache-handler'; import { IdentifierCache } from './caches/identifier-cache'; import { InstanceCache, @@ -485,7 +485,7 @@ class Store extends EmberObject { * a resource. * * This hook can be used to select or instantiate any desired - * mechanism of presentating cache data to the ui for access + * mechanism of presenting cache data to the ui for access * mutation, and interaction. * * @method instantiateRecord (hook) @@ -1198,8 +1198,8 @@ class Store extends EmberObject { */ findRecord(resource: TypeFromInstance, id: string | number, options?: FindRecordOptions): Promise; findRecord(resource: string, id: string | number, options?: FindRecordOptions): Promise; - findRecord(resource: ResourceIdentifierObject>, id?: FindRecordOptions): Promise; - findRecord(resource: ResourceIdentifierObject, id?: FindRecordOptions): Promise; + findRecord(resource: ResourceIdentifierObject>, options?: FindRecordOptions): Promise; + findRecord(resource: ResourceIdentifierObject, options?: FindRecordOptions): Promise; findRecord( resource: string | ResourceIdentifierObject, id?: string | number | FindRecordOptions, @@ -2121,7 +2121,7 @@ class Store extends EmberObject { if (DEBUG) { assertDestroyingStore(this, 'saveRecord'); } - assert(`Unable to initate save for a record in a disconnected state`, storeFor(record)); + assert(`Unable to initiate save for a record in a disconnected state`, storeFor(record)); const identifier = recordIdentifierFor(record); const cache = this.cache; @@ -2130,7 +2130,6 @@ class Store extends EmberObject { // but just in case we reject here to prevent bad things. return Promise.reject(new Error(`Record Is Disconnected`)); } - // TODO we used to check if the record was destroyed here assert( `Cannot initiate a save request for an unloaded record: ${identifier.lid}`, this._instanceCache.recordIsLoaded(identifier) @@ -2160,9 +2159,6 @@ class Store extends EmberObject { cacheOptions: { [SkipCache as symbol]: true }, }; - // we lie here on the type because legacy doesn't have enough context - cache.willCommit(identifier, { request } as unknown as StoreRequestContext); - return this.request(request).then((document) => document.content); } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index e194e086eb2..e6711bdb98a 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -120,7 +120,7 @@ * * ### Presenting Data from the Cache * - * Now that we have a source and a cach for our data, we need to configure how + * Now that we have a source and a cache for our data, we need to configure how * the Store delivers that data back to our application. We do this via the hook * [instantiateRecord](https://api.emberjs.com/ember-data/release/classes/Store/methods/instantiateRecord%20(hook)?anchor=instantiateRecord%20(hook)), * which allows us to transform the data for a resource before handing it to the application. diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 575b55ffdd7..fb8fc049772 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -13,6 +13,7 @@ module.exports = { '@ember-data/json-api', '@ember-data/json-api/request', '@ember-data/legacy-compat', + '@ember-data/legacy-compat/builders', '@ember-data/model', '@ember-data/request', '@ember-data/request-utils', @@ -76,6 +77,11 @@ module.exports = { '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_NON_STRICT_TYPES', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_NON_UNIQUE_PAYLOADS', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE', + '(public) @ember-data/legacy-compat/builders @ember-data/legacy-compat/builders#findAll', + '(public) @ember-data/legacy-compat/builders @ember-data/legacy-compat/builders#findRecord', + '(public) @ember-data/legacy-compat/builders @ember-data/legacy-compat/builders#query', + '(public) @ember-data/legacy-compat/builders @ember-data/legacy-compat/builders#queryRecord', + '(public) @ember-data/legacy-compat/builders @ember-data/legacy-compat/builders#saveRecord', '(private) @ember-data/legacy-compat SnapshotRecordArray#_recordArray', '(private) @ember-data/legacy-compat SnapshotRecordArray#_snapshots', '(private) @ember-data/legacy-compat SnapshotRecordArray#constructor', diff --git a/tests/main/package.json b/tests/main/package.json index 0a26f0fedd6..5b44462d011 100644 --- a/tests/main/package.json +++ b/tests/main/package.json @@ -21,7 +21,7 @@ "test:try-one": "ember try:one", "holodeck:start-program": "holodeck start", "holodeck:end-program": "holodeck end", - "launch:tests": "ember test --port=0 --serve --no-launch", + "launch:tests": "ember test --test-port=0 --serve --no-launch", "start": "holodeck run launch:tests", "test": "holodeck run examine", "test:production": "holodeck run examine", diff --git a/tests/main/tests/integration/adapter/rest-adapter/-ajax-mocks.js b/tests/main/tests/integration/adapter/rest-adapter/-ajax-mocks.js index 98e13e94a8b..9f84c5ce8f6 100644 --- a/tests/main/tests/integration/adapter/rest-adapter/-ajax-mocks.js +++ b/tests/main/tests/integration/adapter/rest-adapter/-ajax-mocks.js @@ -2,7 +2,7 @@ import deepCopy from '@ember-data/unpublished-test-infra/test-support/deep-copy' /** * @description Helper function to mock the response of an adapter in order to - * Test is behaviour. + * test its behavior. * @param { adapter } RESTAdapter instance * @param { response } Response to return from the adapter * @return { ajaxCallback } Function that returns information about the last diff --git a/tests/main/tests/integration/legacy-compat/find-all-test.ts b/tests/main/tests/integration/legacy-compat/find-all-test.ts new file mode 100644 index 00000000000..ac1a417cea1 --- /dev/null +++ b/tests/main/tests/integration/legacy-compat/find-all-test.ts @@ -0,0 +1,91 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type { CompatStore } from '@ember-data/legacy-compat'; +import { findAll } from '@ember-data/legacy-compat/builders'; +import Model, { attr } from '@ember-data/model'; +import { ResourceType } from '@warp-drive/core-types/symbols'; + +type FindAllBuilderOptions = Exclude[1], undefined>; + +class Post extends Model { + [ResourceType] = 'post' as const; + @attr declare name: string; +} + +module('Integration - legacy-compat/builders/findAll', function (hooks) { + setupTest(hooks); + + test('basic payload', async function (assert) { + this.owner.register('model:post', Post); + this.owner.register( + 'adapter:application', + class Adapter { + findAll() { + assert.step('adapter-findAll'); + return Promise.resolve({ + data: [ + { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + ], + }); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const { content: results } = await store.request(findAll('post')); + + assert.strictEqual(results.length, 1, 'post was found'); + assert.strictEqual(results[0].id, '1', 'post has correct id'); + assert.strictEqual(results[0].name, 'Krystan rules, you drool', 'post has correct name'); + assert.verifySteps(['adapter-findAll'], 'adapter-findAll was called'); + }); + + test('findAll', function (assert) { + const result = findAll('post'); + assert.deepEqual( + result, + { + op: 'findAll', + data: { + type: 'post', + options: {}, + }, + cacheOptions: {}, + }, + `findAll works` + ); + }); + + test('findAll with options', function (assert) { + const options: Required = { + reload: true, + backgroundReload: false, + include: 'author,comments', + adapterOptions: {}, + }; + const result = findAll('post', options); + assert.deepEqual( + result, + { + op: 'findAll', + data: { + type: 'post', + options, + }, + cacheOptions: {}, + }, + `findAll works with options` + ); + }); +}); diff --git a/tests/main/tests/integration/legacy-compat/find-record-test.ts b/tests/main/tests/integration/legacy-compat/find-record-test.ts new file mode 100644 index 00000000000..57dc134862e --- /dev/null +++ b/tests/main/tests/integration/legacy-compat/find-record-test.ts @@ -0,0 +1,149 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type { CompatStore } from '@ember-data/legacy-compat'; +import { findRecord } from '@ember-data/legacy-compat/builders'; +import Model, { attr } from '@ember-data/model'; +import type { FindRecordOptions } from '@ember-data/store/-types/q/store'; +import { ResourceType } from '@warp-drive/core-types/symbols'; + +type FindRecordBuilderOptions = Exclude[1], undefined>; + +class Post extends Model { + [ResourceType] = 'post' as const; + @attr declare name: string; +} + +module('Integration - legacy-compat/builders/findRecord', function (hooks) { + setupTest(hooks); + + test('basic payload', async function (assert) { + this.owner.register('model:post', Post); + this.owner.register( + 'adapter:application', + class Adapter { + findRecord() { + assert.step('adapter-findRecord'); + return Promise.resolve({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const { content: post } = await store.request(findRecord('post', '1')); + + assert.strictEqual(post.id, '1', 'post has correct id'); + assert.strictEqual(post.name, 'Krystan rules, you drool', 'post has correct name'); + assert.verifySteps(['adapter-findRecord'], 'adapter-findRecord was called'); + }); + + test('findRecord by type+id', function (assert) { + const result = findRecord('post', '1'); + assert.deepEqual( + result, + { + op: 'findRecord', + data: { + record: { type: 'post', id: '1' }, + options: {}, + }, + cacheOptions: {}, + }, + `findRecord works with type+id` + ); + }); + + test('findRecord by type+id with options', function (assert) { + const options: Required = { + reload: true, + backgroundReload: false, + include: 'author,comments', + adapterOptions: {}, + }; + const result = findRecord('post', '1', options); + assert.deepEqual( + result, + { + op: 'findRecord', + data: { + record: { type: 'post', id: '1' }, + options, + }, + cacheOptions: {}, + }, + `findRecord works with type+id and options` + ); + }); + + test('findRecord by type+id with invalid options', async function (assert) { + // Type hacks to ensure we're notified if we add new FindRecordOptions that aren't valid FindRecordBuilderOptions + const invalidOptions: Omit, keyof FindRecordBuilderOptions> = { + preload: {}, + }; + await assert.expectAssertion(() => { + // @ts-expect-error TS knows the options are invalid + findRecord('post', '1', invalidOptions); + }, 'Assertion Failed: findRecord builder does not support options.preload'); + }); + + test('findRecord by identifier', function (assert) { + const result = findRecord({ type: 'post', id: '1' }); + assert.deepEqual( + result, + { + op: 'findRecord', + data: { + record: { type: 'post', id: '1' }, + options: {}, + }, + cacheOptions: {}, + }, + `findRecord works with an identifier` + ); + }); + + test('findRecord by identifier with options', function (assert) { + const options: Required = { + reload: true, + backgroundReload: false, + include: 'author,comments', + adapterOptions: {}, + }; + const result = findRecord({ type: 'post', id: '1' }, options); + assert.deepEqual( + result, + { + op: 'findRecord', + data: { + record: { type: 'post', id: '1' }, + options, + }, + cacheOptions: {}, + }, + `findRecord works with an identifier and options` + ); + }); + + test('findRecord by identifier with invalid options', async function (assert) { + // Type hacks to ensure we're notified if we add new FindRecordOptions that aren't valid FindRecordBuilderOptions + const invalidOptions: Omit, keyof FindRecordBuilderOptions> = { + preload: {}, + }; + await assert.expectAssertion(() => { + // @ts-expect-error TS knows the options are invalid + findRecord({ type: 'post', id: '1' }, invalidOptions); + }, 'Assertion Failed: findRecord builder does not support options.preload'); + }); +}); diff --git a/tests/main/tests/integration/legacy-compat/query-test.ts b/tests/main/tests/integration/legacy-compat/query-test.ts new file mode 100644 index 00000000000..07837eed052 --- /dev/null +++ b/tests/main/tests/integration/legacy-compat/query-test.ts @@ -0,0 +1,165 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type { CompatStore } from '@ember-data/legacy-compat'; +import { query, queryRecord } from '@ember-data/legacy-compat/builders'; +import Model, { attr } from '@ember-data/model'; +import { ResourceType } from '@warp-drive/core-types/symbols'; + +type QueryBuilderOptions = Exclude[2], undefined>; +type QueryRecordBuilderOptions = Exclude[2], undefined>; + +class Post extends Model { + [ResourceType] = 'post' as const; + @attr declare name: string; +} + +module('Integration - legacy-compat/builders/query', function (hooks) { + setupTest(hooks); + + module('query', function () { + test('basic payload', async function (assert) { + this.owner.register('model:post', Post); + this.owner.register( + 'adapter:application', + class Adapter { + query() { + assert.step('adapter-query'); + return Promise.resolve({ + data: [ + { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + ], + }); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const { content: results } = await store.request(query('post', { id: '1' })); + + assert.strictEqual(results.length, 1, 'post was found'); + assert.strictEqual(results[0].id, '1', 'post has correct id'); + assert.strictEqual(results[0].name, 'Krystan rules, you drool', 'post has correct name'); + assert.verifySteps(['adapter-query'], 'adapter-query was called'); + }); + + test('query', function (assert) { + const result = query('post', { id: '1' }); + assert.deepEqual( + result, + { + op: 'query', + data: { + type: 'post', + query: { id: '1' }, + options: {}, + }, + cacheOptions: {}, + }, + `query works` + ); + }); + + test('query with options', function (assert) { + const options: Required = { + whatever: true, + adapterOptions: {}, + }; + const result = query('post', { id: '1' }, options); + assert.deepEqual( + result, + { + op: 'query', + data: { + type: 'post', + query: { id: '1' }, + options, + }, + cacheOptions: {}, + }, + `query works with options` + ); + }); + }); + + module('queryRecord', function () { + test('basic payload', async function (assert) { + this.owner.register('model:post', Post); + this.owner.register( + 'adapter:application', + class Adapter { + queryRecord() { + assert.step('adapter-queryRecord'); + return Promise.resolve({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const { content: post } = await store.request(queryRecord('post', { id: '1' })); + + assert.strictEqual(post.id, '1', 'post has correct id'); + assert.strictEqual(post.name, 'Krystan rules, you drool', 'post has correct name'); + assert.verifySteps(['adapter-queryRecord'], 'adapter-queryRecord was called'); + }); + + test('queryRecord', function (assert) { + const result = queryRecord('post', { id: '1' }); + assert.deepEqual( + result, + { + op: 'queryRecord', + data: { + type: 'post', + query: { id: '1' }, + options: {}, + }, + cacheOptions: {}, + }, + `queryRecord works` + ); + }); + + test('queryRecord with options', function (assert) { + const options: Required = { + whatever: true, + adapterOptions: {}, + }; + const result = queryRecord('post', { id: '1' }, options); + assert.deepEqual( + result, + { + op: 'queryRecord', + data: { + type: 'post', + query: { id: '1' }, + options, + }, + cacheOptions: {}, + }, + `queryRecord works with options` + ); + }); + }); +}); diff --git a/tests/main/tests/integration/legacy-compat/save-record-test.ts b/tests/main/tests/integration/legacy-compat/save-record-test.ts new file mode 100644 index 00000000000..0a7c5c3f077 --- /dev/null +++ b/tests/main/tests/integration/legacy-compat/save-record-test.ts @@ -0,0 +1,295 @@ +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import type { CompatStore } from '@ember-data/legacy-compat'; +import { saveRecord } from '@ember-data/legacy-compat/builders'; +import Model, { attr } from '@ember-data/model'; +import { recordIdentifierFor } from '@ember-data/store'; +import { ResourceType } from '@warp-drive/core-types/symbols'; + +class Post extends Model { + [ResourceType] = 'post' as const; + @attr declare name: string; +} + +type SaveRecordBuilderOptions = Exclude[1], undefined>; + +module('Integration - legacy-compat/builders/saveRecord', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('model:post', Post); + }); + + module('createRecord', function () { + test('basic payload', async function (assert) { + this.owner.register( + 'adapter:application', + class Adapter { + createRecord() { + assert.step('adapter-createRecord'); + return Promise.resolve({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const newPost: Post = store.createRecord('post', { name: 'Krystan rules, you drool' }); + const { content: savedPost } = await store.request(saveRecord(newPost)); + + assert.strictEqual(savedPost.id, '1', 'post has correct id'); + assert.strictEqual(savedPost.name, 'Krystan rules, you drool', 'post has correct name'); + assert.verifySteps(['adapter-createRecord'], 'adapter-createRecord was called'); + }); + + test('saveRecord', function (assert) { + const store = this.owner.lookup('service:store') as CompatStore; + const newPost: Post = store.createRecord('post', { name: 'Krystan rules, you drool' }); + const identifier = recordIdentifierFor(newPost); + const result = saveRecord(newPost); + assert.deepEqual( + result, + { + op: 'createRecord', + data: { + record: identifier, + options: {}, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + + test('saveRecord with options', function (assert) { + const options: Required = { + whatever: true, + adapterOptions: {}, + }; + const store = this.owner.lookup('service:store') as CompatStore; + const newPost: Post = store.createRecord('post', { name: 'Krystan rules, you drool' }); + const identifier = recordIdentifierFor(newPost); + const result = saveRecord(newPost, options); + assert.deepEqual( + result, + { + op: 'createRecord', + data: { + record: identifier, + options: options, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + }); + + module('deleteRecord', function () { + test('basic payload', async function (assert) { + this.owner.register( + 'adapter:application', + class Adapter { + deleteRecord() { + assert.step('adapter-deleteRecord'); + return Promise.resolve(); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.deleteRecord(); + const { content: savedPost } = await store.request(saveRecord(existingPost)); + + assert.strictEqual(savedPost.id, '1', 'post has correct id'); + assert.strictEqual(savedPost.name, 'Krystan rules, you drool', 'post has correct name'); + assert.true(savedPost.isDeleted, 'post isDeleted'); + assert.verifySteps(['adapter-deleteRecord'], 'adapter-deleteRecord was called'); + }); + + test('saveRecord', function (assert) { + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.deleteRecord(); + const identifier = recordIdentifierFor(existingPost); + const result = saveRecord(existingPost); + assert.deepEqual( + result, + { + op: 'deleteRecord', + data: { + record: identifier, + options: {}, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + + test('saveRecord with options', function (assert) { + const options: Required = { + whatever: true, + adapterOptions: {}, + }; + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.deleteRecord(); + const identifier = recordIdentifierFor(existingPost); + const result = saveRecord(existingPost, options); + assert.deepEqual( + result, + { + op: 'deleteRecord', + data: { + record: identifier, + options: options, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + }); + + module('updateRecord', function () { + test('basic payload', async function (assert) { + this.owner.register( + 'adapter:application', + class Adapter { + updateRecord() { + assert.step('adapter-updateRecord'); + return Promise.resolve(); + } + static create() { + return new this(); + } + } + ); + + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.name = 'Chris drools, Krystan rules'; + const { content: savedPost } = await store.request(saveRecord(existingPost)); + + assert.strictEqual(savedPost.id, '1', 'post has correct id'); + assert.strictEqual(savedPost.name, 'Chris drools, Krystan rules', 'post has correct name'); + assert.false(savedPost.isDeleted, 'post is not deleted'); + assert.verifySteps(['adapter-updateRecord'], 'adapter-updateRecord was called'); + }); + + test('saveRecord', function (assert) { + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.name = 'Chris drools, Krystan rules'; + const identifier = recordIdentifierFor(existingPost); + const result = saveRecord(existingPost); + assert.deepEqual( + result, + { + op: 'updateRecord', + data: { + record: identifier, + options: {}, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + + test('saveRecord with options', function (assert) { + const options: Required = { + whatever: true, + adapterOptions: {}, + }; + const store = this.owner.lookup('service:store') as CompatStore; + const existingPost: Post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + name: 'Krystan rules, you drool', + }, + }, + }); + existingPost.name = 'Chris drools, Krystan rules'; + const identifier = recordIdentifierFor(existingPost); + const result = saveRecord(existingPost, options); + assert.deepEqual( + result, + { + op: 'updateRecord', + data: { + record: identifier, + options: options, + }, + records: [identifier], + cacheOptions: {}, + }, + `saveRecord works` + ); + }); + }); +});