From 0828c951b756c88ccaf68433530f5a4c823bfedb Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 30 Jun 2025 21:45:49 -0700 Subject: [PATCH] Add defaultable query options - Add a `defaultQueryOptions` property to entity configuration - Use these defaults when building execution options --- index.d.ts | 1 + src/entity.js | 401 ++++++++++++++++++----------------- test/offline.options.spec.js | 302 ++++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 197 deletions(-) diff --git a/index.d.ts b/index.d.ts index 7967145a..a6a70be9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5007,6 +5007,7 @@ export type EntityConfiguration = { entity?: string; version?: string; }; + defaultQueryOptions?: QueryOptions; }; export class Entity< diff --git a/src/entity.js b/src/entity.js index 216ad860..9c8707c3 100644 --- a/src/entity.js +++ b/src/entity.js @@ -62,6 +62,7 @@ class Entity { /** start beta/v1 condition **/ this.config.table = config.table || model.table; /** end beta/v1 condition **/ + this.defaultQueryOptions = config.defaultQueryOptions || {}; this._filterBuilder = new FilterFactory( this.model.schema.attributes, FilterOperations, @@ -1649,249 +1650,255 @@ class Entity { _includeOnResponseItem: {}, }; - return provided.filter(Boolean).reduce((config, option) => { - if (typeof option.order === "string") { - switch (option.order.toLowerCase()) { - case "asc": - config.params[ResultOrderParam] = ResultOrderOption.asc; - break; - case "desc": - config.params[ResultOrderParam] = ResultOrderOption.desc; - break; - default: + return [this.defaultQueryOptions, ...provided] + .filter(Boolean) + .reduce((config, option) => { + if (typeof option.order === "string") { + switch (option.order.toLowerCase()) { + case "asc": + config.params[ResultOrderParam] = ResultOrderOption.asc; + break; + case "desc": + config.params[ResultOrderParam] = ResultOrderOption.desc; + break; + default: + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Invalid value for query option "order" provided. Valid options include 'asc' and 'desc, received: "${option.order}"`, + ); + } + } + + if (typeof option.compare === "string") { + const type = ComparisonTypes[option.compare.toLowerCase()]; + if (type) { + config.compare = type; + if (type === ComparisonTypes.v2 && option.complete === undefined) { + config.complete = true; + } + } else { throw new e.ElectroError( e.ErrorCodes.InvalidOptions, - `Invalid value for query option "order" provided. Valid options include 'asc' and 'desc, received: "${option.order}"`, + `Invalid value for query option "compare" provided. Valid options include ${u.commaSeparatedString( + Object.keys(ComparisonTypes), + )}, received: "${option.compare}"`, ); + } } - } - if (typeof option.compare === "string") { - const type = ComparisonTypes[option.compare.toLowerCase()]; - if (type) { - config.compare = type; - if (type === ComparisonTypes.v2 && option.complete === undefined) { - config.complete = true; + if (typeof option.response === "string" && option.response.length) { + const format = ReturnValues[option.response]; + if (format === undefined) { + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Invalid value for query option "format" provided: "${ + option.format + }". Allowed values include ${u.commaSeparatedString( + Object.keys(ReturnValues), + )}.`, + ); + } else if (format !== ReturnValues.default) { + config.response = format; + if (context.operation === MethodTypes.transactWrite) { + config.params.ReturnValuesOnConditionCheckFailure = + FormatToReturnValues[format]; + } else { + config.params.ReturnValues = FormatToReturnValues[format]; + } } - } else { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Invalid value for query option "compare" provided. Valid options include ${u.commaSeparatedString( - Object.keys(ComparisonTypes), - )}, received: "${option.compare}"`, - ); } - } - if (typeof option.response === "string" && option.response.length) { - const format = ReturnValues[option.response]; - if (format === undefined) { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Invalid value for query option "format" provided: "${ - option.format - }". Allowed values include ${u.commaSeparatedString( - Object.keys(ReturnValues), - )}.`, + if (option.formatCursor) { + const isValid = ["serialize", "deserialize"].every( + (method) => + method in option.formatCursor && + validations.isFunction(option.formatCursor[method]), ); - } else if (format !== ReturnValues.default) { - config.response = format; - if (context.operation === MethodTypes.transactWrite) { - config.params.ReturnValuesOnConditionCheckFailure = - FormatToReturnValues[format]; + if (isValid) { + config.formatCursor = option.formatCursor; } else { - config.params.ReturnValues = FormatToReturnValues[format]; + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Invalid value for query option "formatCursor" provided. Formatter interface must have serialize and deserialize functions`, + ); } } - } - if (option.formatCursor) { - const isValid = ["serialize", "deserialize"].every( - (method) => - method in option.formatCursor && - validations.isFunction(option.formatCursor[method]), - ); - if (isValid) { - config.formatCursor = option.formatCursor; - } else { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Invalid value for query option "formatCursor" provided. Formatter interface must have serialize and deserialize functions`, - ); + if (option.terminalOperation in TerminalOperation) { + config.terminalOperation = TerminalOperation[option.terminalOperation]; } - } - if (option.terminalOperation in TerminalOperation) { - config.terminalOperation = TerminalOperation[option.terminalOperation]; - } - - if (Array.isArray(option.attributes)) { - config.attributes = config.attributes.concat(option.attributes); - } - - if (option.preserveBatchOrder === true) { - config.preserveBatchOrder = true; - } + if (Array.isArray(option.attributes)) { + config.attributes = config.attributes.concat(option.attributes); + } - if (option.pages !== undefined) { - config.pages = option.pages; - } + if (option.preserveBatchOrder === true) { + config.preserveBatchOrder = true; + } - if (option._isCollectionQuery === true) { - config._isCollectionQuery = true; - } + if (option.pages !== undefined) { + config.pages = option.pages; + } - if (option.includeKeys === true) { - config.includeKeys = true; - } + if (option._isCollectionQuery === true) { + config._isCollectionQuery = true; + } - if (option.originalErr === true) { - config.originalErr = true; - } + if (option.includeKeys === true) { + config.includeKeys = true; + } - if (option.raw === true) { - config.raw = true; - } + if (option.originalErr === true) { + config.originalErr = true; + } - if (option._isPagination) { - config._isPagination = true; - } + if (option.raw === true) { + config.raw = true; + } - if (option.lastEvaluatedKeyRaw === true) { - config.lastEvaluatedKeyRaw = true; - config.pager = Pager.raw; - config.unprocessed = UnprocessedTypes.raw; - } + if (option._isPagination) { + config._isPagination = true; + } - if (option.cursor) { - config.cursor = option.cursor; - } + if (option.lastEvaluatedKeyRaw === true) { + config.lastEvaluatedKeyRaw = true; + config.pager = Pager.raw; + config.unprocessed = UnprocessedTypes.raw; + } - if (option.data) { - if (!DataOptions[option.data]) { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Query option 'data' must be one of ${u.commaSeparatedString( - Object.keys(DataOptions), - )}.`, - ); + if (option.cursor) { + config.cursor = option.cursor; } - config.data = option.data; - switch (option.data) { - case DataOptions.raw: - config.raw = true; - break; - case DataOptions.includeKeys: - config.includeKeys = true; - break; + + if (option.data) { + if (!DataOptions[option.data]) { + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Query option 'data' must be one of ${u.commaSeparatedString( + Object.keys(DataOptions), + )}.`, + ); + } + config.data = option.data; + switch (option.data) { + case DataOptions.raw: + config.raw = true; + break; + case DataOptions.includeKeys: + config.includeKeys = true; + break; + } } - } - if (option.count !== undefined) { - if (typeof option.count !== "number" || option.count < 1) { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Query option 'count' must be of type 'number' and greater than zero.`, - ); + if (option.count !== undefined) { + if (typeof option.count !== "number" || option.count < 1) { + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Query option 'count' must be of type 'number' and greater than zero.`, + ); + } + config.count = option.count; } - config.count = option.count; - } - if (option.consistent === true) { - config.consistent = true; - config.params.ConsistentRead = true; - } + if (option.consistent !== undefined) { + config.consistent = option.consistent; + if (option.consistent === true) { + config.params.ConsistentRead = true; + } else if (option.consistent === false) { + delete config.params.ConsistentRead; + } + } - if (option.limit !== undefined) { - config.limit = option.limit; - config.params.Limit = option.limit; - } + if (option.limit !== undefined) { + config.limit = option.limit; + config.params.Limit = option.limit; + } - if (validations.isStringHasLength(option.table)) { - config.params.TableName = option.table; - config.table = option.table; - } + if (validations.isStringHasLength(option.table)) { + config.params.TableName = option.table; + config.table = option.table; + } - if (option.concurrent !== undefined) { - config.concurrent = option.concurrent; - } + if (option.concurrent !== undefined) { + config.concurrent = option.concurrent; + } - if (validations.isFunction(option.parse)) { - config.parse = option.parse; - } + if (validations.isFunction(option.parse)) { + config.parse = option.parse; + } - if (typeof option.pager === "string") { - if (typeof Pager[option.pager] === "string") { - config.pager = option.pager; - } else { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Invalid value for option "pager" provided: "${ - option.pager - }". Allowed values include ${u.commaSeparatedString( - Object.keys(Pager), - )}.`, - ); + if (typeof option.pager === "string") { + if (typeof Pager[option.pager] === "string") { + config.pager = option.pager; + } else { + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Invalid value for option "pager" provided: "${ + option.pager + }". Allowed values include ${u.commaSeparatedString( + Object.keys(Pager), + )}.`, + ); + } } - } - if (typeof option.unprocessed === "string") { - if (typeof UnprocessedTypes[option.unprocessed] === "string") { - config.unproessed = UnprocessedTypes[option.unprocessed]; - } else { - throw new e.ElectroError( - e.ErrorCodes.InvalidOptions, - `Invalid value for option "unprocessed" provided: "${ - option.unprocessed - }". Allowed values include ${u.commaSeparatedString( - Object.keys(UnprocessedTypes), - )}.`, - ); + if (typeof option.unprocessed === "string") { + if (typeof UnprocessedTypes[option.unprocessed] === "string") { + config.unproessed = UnprocessedTypes[option.unprocessed]; + } else { + throw new e.ElectroError( + e.ErrorCodes.InvalidOptions, + `Invalid value for option "unprocessed" provided: "${ + option.unprocessed + }". Allowed values include ${u.commaSeparatedString( + Object.keys(UnprocessedTypes), + )}.`, + ); + } } - } - if (option.ignoreOwnership) { - config.ignoreOwnership = option.ignoreOwnership; - config._providedIgnoreOwnership = option.ignoreOwnership; - } + if (option.ignoreOwnership) { + config.ignoreOwnership = option.ignoreOwnership; + config._providedIgnoreOwnership = option.ignoreOwnership; + } - if (option.listeners) { - if (Array.isArray(option.listeners)) { - config.listeners = config.listeners.concat(option.listeners); + if (option.listeners) { + if (Array.isArray(option.listeners)) { + config.listeners = config.listeners.concat(option.listeners); + } } - } - if (option.logger) { - if (validations.isFunction(option.logger)) { - config.listeners.push(option.logger); - } else { - throw new e.ElectroError( - e.ErrorCodes.InvalidLoggerProvided, - `Loggers must be of type function`, - ); + if (option.logger) { + if (validations.isFunction(option.logger)) { + config.listeners.push(option.logger); + } else { + throw new e.ElectroError( + e.ErrorCodes.InvalidLoggerProvided, + `Loggers must be of type function`, + ); + } } - } - if (option.hydrate) { - config.hydrate = true; - config.ignoreOwnership = true; - } + if (option.hydrate) { + config.hydrate = true; + config.ignoreOwnership = true; + } - if (validations.isFunction(option.hydrator)) { - config.hydrator = option.hydrator; - } + if (validations.isFunction(option.hydrator)) { + config.hydrator = option.hydrator; + } - if (option._includeOnResponseItem) { - config._includeOnResponseItem = { - ...config._includeOnResponseItem, - ...option._includeOnResponseItem, - }; - } + if (option._includeOnResponseItem) { + config._includeOnResponseItem = { + ...config._includeOnResponseItem, + ...option._includeOnResponseItem, + }; + } - config.page = Object.assign({}, config.page, option.page); - config.params = Object.assign({}, config.params, option.params); - return config; - }, config); + config.page = Object.assign({}, config.page, option.page); + config.params = Object.assign({}, config.params, option.params); + return config; + }, config); } _applyParameterOptions({ params = {}, options = {} } = {}) { diff --git a/test/offline.options.spec.js b/test/offline.options.spec.js index 2bb8f5f6..809b9a71 100644 --- a/test/offline.options.spec.js +++ b/test/offline.options.spec.js @@ -727,4 +727,306 @@ describe("Query Options", () => { // } // }).timeout(10000); }); + describe("defaults", () => { + it("can be set at the entity level", () => { + const schema = { + model: { + entity: "test", + service: "tests", + version: "1", + }, + attributes: { + id: { type: "string" }, + }, + indexes: { + primary: { + pk: { field: "pk", facets: ["id"] }, + }, + }, + }; + + const defaultQueryOptions = { ignoreOwnership: true }; + const entity = new Entity(schema, { defaultQueryOptions }); + + expect(entity.defaultQueryOptions).to.deep.equal(defaultQueryOptions); + }); + + it("can be applied to all queries", () => { + const schema = { + model: { + entity: "defaultQueryOptions", + service: "tests", + version: "1", + }, + attributes: { + id: { + type: "string", + }, + name: { + type: "string", + }, + }, + indexes: { + primary: { + pk: { + field: "pk", + facets: ["id"], + }, + }, + }, + }; + + const defaultQueryOptions = { + ignoreOwnership: true, + consistent: true, + limit: 100, + }; + + const entity = new Entity(schema, { + table: "test-table", + defaultQueryOptions, + }); + + // Test get operation + const getParams = entity.get({ id: "123" }).params(); + expect(getParams.ConsistentRead).to.equal(true); + + // Test scan operation + const scanParams = entity.scan.params(); + expect(scanParams.ConsistentRead).to.equal(true); + expect(scanParams.Limit).to.equal(100); + + // Test query operation + const queryParams = entity.query.primary({ id: "123" }).params(); + expect(queryParams.ConsistentRead).to.equal(true); + expect(queryParams.Limit).to.equal(100); + }); + + it("can be overridden", () => { + const schema = { + model: { + entity: "overrideQueryOptions", + service: "tests", + version: "1", + }, + attributes: { + id: { + type: "string", + }, + name: { + type: "string", + }, + }, + indexes: { + primary: { + pk: { + field: "pk", + facets: ["id"], + }, + }, + }, + }; + + const defaultQueryOptions = { + ignoreOwnership: true, + consistent: true, + limit: 100, + }; + + const entity = new Entity(schema, { + table: "test-table", + defaultQueryOptions, + }); + + // Override default options + const params = entity.scan.params({ + consistent: false, + limit: 50, + }); + + expect(params.ConsistentRead).to.be.undefined; + expect(params.Limit).to.equal(50); + }); + + it("allow ignoreOwnership to be applied to all queries", async () => { + const schema = { + model: { + entity: "ignoreOwnershipDefault", + service: "tests", + version: "1", + }, + attributes: { + id: { + type: "string", + }, + name: { + type: "string", + }, + }, + indexes: { + primary: { + pk: { + field: "pk", + facets: ["id"], + }, + }, + }, + }; + + const defaultQueryOptions = { + ignoreOwnership: true, + }; + + const entity = new Entity(schema, { + table: "test-table", + defaultQueryOptions, + }); + + // First get the actual pk value that would be generated + const params = entity.get({ id: "123" }).params(); + const expectedPk = params.Key.pk; + + // Create a mock client to capture the request + const mockClient = { + get: (params) => ({ + promise: () => Promise.resolve({ + Item: { + pk: expectedPk, + id: "123", + name: "Test", + // No entity identifiers - this would normally cause item to be filtered out + }, + }), + }), + put: () => ({ promise: () => Promise.resolve() }), + delete: () => ({ promise: () => Promise.resolve() }), + update: () => ({ promise: () => Promise.resolve() }), + batchWrite: () => ({ promise: () => Promise.resolve() }), + batchGet: () => ({ promise: () => Promise.resolve() }), + scan: () => ({ promise: () => Promise.resolve() }), + query: () => ({ promise: () => Promise.resolve() }), + transactWrite: () => ({ promise: () => Promise.resolve() }), + transactGet: () => ({ promise: () => Promise.resolve() }), + createSet: () => ({}), + }; + + const entityWithMockClient = new Entity(schema, { + table: "test-table", + client: mockClient, + defaultQueryOptions, + }); + + // Execute query and verify ignoreOwnership is applied + const result = await entityWithMockClient + .get({ id: "123" }) + .go(); + + // If ignoreOwnership is working, we should get the item back + expect(result).to.not.be.null; + expect(result.data).to.exist; + expect(result.data.id).to.equal("123"); + expect(result.data.name).to.equal("Test"); + }); + + it("can be empty", () => { + const schema = { + model: { + entity: "emptyDefaultOptions", + service: "tests", + version: "1", + }, + attributes: { + id: { + type: "string", + }, + }, + indexes: { + primary: { + pk: { + field: "pk", + facets: ["id"], + }, + }, + }, + }; + + const entity = new Entity(schema, { + table: "test-table", + defaultQueryOptions: {}, + }); + + const params = entity.get({ id: "123" }).params(); + expect(params.ConsistentRead).to.be.undefined; + }); + + it("work with all query types", () => { + const schema = { + model: { + entity: "allQueryTypes", + service: "tests", + version: "1", + }, + attributes: { + id: { + type: "string", + }, + sk: { + type: "string", + }, + gsi1pk: { + type: "string", + }, + }, + indexes: { + primary: { + pk: { + field: "pk", + facets: ["id"], + }, + sk: { + field: "sk", + facets: ["sk"], + }, + }, + gsi1: { + index: "gsi1", + pk: { + field: "gsi1pk", + facets: ["gsi1pk"], + }, + }, + }, + }; + + const defaultQueryOptions = { + limit: 50, + order: "desc", + }; + + const entity = new Entity(schema, { + table: "test-table", + defaultQueryOptions, + }); + + // Test query on primary index + const primaryParams = entity.query.primary({ id: "123" }).params(); + expect(primaryParams.Limit).to.equal(50); + expect(primaryParams.ScanIndexForward).to.equal(false); + + // Test query on GSI + const gsiParams = entity.query.gsi1({ gsi1pk: "test" }).params(); + expect(gsiParams.Limit).to.equal(50); + expect(gsiParams.ScanIndexForward).to.equal(false); + + // Test find operation + const findParams = entity.find({ id: "123" }).params(); + expect(findParams.Limit).to.equal(50); + expect(findParams.ScanIndexForward).to.equal(false); + + // Test match operation + const matchParams = entity.match({ id: "123" }).params(); + expect(matchParams.Limit).to.equal(50); + expect(matchParams.ScanIndexForward).to.equal(false); + }); + }); });