Skip to content
Merged
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,15 @@ All notable changes to this project will be documented in this file. Breaking ch

## [3.9.0]
### Added
- Added a new `returnOnConditionCheckFailure` execution option for write operations. Instead of throwing when a condition expression fails, the call resolves with `{ rejected: true }`. Pass `true` to get just the rejection flag, or `"all_old"` to also receive the existing item under `data`. The `"all_old"` mode requires AWS SDK v3, or AWS SDK v2 >= `2.1408.0`.
- Added a new `returnOnConditionCheckFailure` execution option for write operations. Instead of throwing when a condition expression fails, the call resolves with `{ rejected: true }`. Pass `true` to get just the rejection flag, or `"all_old"` to also receive the existing item under `data`. The `"all_old"` mode requires AWS SDK v3, or AWS SDK v2 >= `2.1408.0`.

## [3.9.1]
### Fixed
- The `unprocessed: "raw"` execution option now works as documented. Previously the option was silently ignored, and unprocessed keys returned from batch operations were always deconstructed into composite attributes; passing `"raw"` now returns them in their raw key format.
- Set attributes flagged `hidden: true` are no longer returned when nested inside a `map` attribute. Hidden attributes of every type and depth now stay hidden — if your application was reading these values on parent map attributes despite the flag, they will no longer be present in responses.
- Set attributes defined with enum items now produce a valid, typed DynamoDB Set when generating parameters without an attached client. Previously the generated set was malformed (missing its type) and could be rejected by DynamoDB if sent as-is. Relatedly, defining a set with an unsupported member type now fails immediately with an `Invalid Set type` error instead of continuing with an invalid set.
- Defining an attribute with an invalid `watch` value now fails with a validation error explaining the accepted values (an array of attribute names or `"*"`). Previously entity construction crashed with an unhelpful `ReferenceError`.
- Transaction results are now reliable in several edge cases: `transactGet` results keep their expected order and count even when a returned item cannot be matched to one of your entities (such slots resolve to `null`); canceled transactions containing such items no longer throw; each result in a transaction response is now a distinct object, so modifying one no longer silently modifies the others; and passing the same options object to multiple `.go()` calls no longer causes loggers and listeners to fire additional times with each call.

### Changed
- Corrected documentation that had drifted from actual behavior. Most notably, the descriptions of `find` and `match` were swapped: `match` is the method that applies your provided values as query filters, while `find` only uses them to seek an index. If you chose between these methods based on the documentation, double-check you are using the one you intended.
34 changes: 19 additions & 15 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,26 @@ type TransactGetCommandInput = {
TransactItems: TransactGetItem[];
};

export type V2DocumentClient = {
get: DocumentClientMethod;
put: DocumentClientMethod;
delete: DocumentClientMethod;
update: DocumentClientMethod;
batchWrite: DocumentClientMethod;
batchGet: DocumentClientMethod;
scan: DocumentClientMethod;
transactGet: DocumentClientMethod;
transactWrite: DocumentClientMethod;
query: DocumentClientMethod;
};

export type V3DocumentClient = {
send: (command: any) => Promise<any>;
};

export type DocumentClient =
| {
get: DocumentClientMethod;
put: DocumentClientMethod;
delete: DocumentClientMethod;
update: DocumentClientMethod;
batchWrite: DocumentClientMethod;
batchGet: DocumentClientMethod;
scan: DocumentClientMethod;
transactGet: DocumentClientMethod;
transactWrite: DocumentClientMethod;
query: DocumentClientMethod;
}
| {
send: (command: any) => Promise<any>;
};
| V2DocumentClient
| V3DocumentClient;

export type AllCollectionNames<
E extends { [name: string]: Entity<any, any, any, any> },
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "electrodb",
"version": "3.9.0",
"version": "3.9.1",
"description": "A library to more easily create and interact with multiple entities and heretical relationships in dynamodb",
"main": "index.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -1960,7 +1960,7 @@ class Entity {

if (typeof option.unprocessed === "string") {
if (typeof UnprocessedTypes[option.unprocessed] === "string") {
config.unproessed = UnprocessedTypes[option.unprocessed];
config.unprocessed = UnprocessedTypes[option.unprocessed];
} else {
throw new e.ElectroError(
e.ErrorCodes.InvalidOptions,
Expand Down
5 changes: 4 additions & 1 deletion src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,9 @@ class SetAttribute extends Attribute {
? (value, getSiblings) => get(value, getSiblings())
: (attr) => attr;
return (values, getSiblings) => {
if (this.hidden) {
return;
}
if (values !== undefined) {
const data = this.fromDDBSet(values);
return getter(data, getSiblings);
Expand Down Expand Up @@ -1423,7 +1426,7 @@ class Schema {
} else {
throw new e.ElectroError(
e.ErrorCodes.InvalidAttributeWatchDefinition,
`Attribute Validation Error. The attribute '${name}' is defined to "watch" an invalid value of: '${attribute.watch}'. The watch property must either be a an array of attribute names, or the single string value of "${WatchAll}".`,
`Attribute Validation Error. The attribute '${name}' is defined to "watch" an invalid value of: '${attribute.watch}'. The watch property must either be a an array of attribute names, or the single string value of "${AttributeWildCard}".`,
);
}
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/set.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ const memberTypeToSetType = {
Binary: "Binary",
string: "String",
number: "Number",
enum: "String",
};

class DynamoDBSet {
constructor(list, type) {
this.wrapperName = "Set";
this.type = memberTypeToSetType[type];
if (this.type === undefined) {
new Error(`Invalid Set type: ${type}`);
throw new Error(`Invalid Set type: ${type}`);
}
this.values = Array.from(new Set([].concat(list)));
}
Expand Down
40 changes: 25 additions & 15 deletions src/transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ function cleanseCanceledData(
paramItem,
identifiers,
});
result.item = entities[entityAlias].formatResponse({ Item }, index, {
...config,
pager: false,
parse: undefined,
}).data;
if (entityAlias) {
result.item = entities[entityAlias].formatResponse({ Item }, index, {
...config,
pager: false,
parse: undefined,
}).data;
} else {
result.item = null;
}
} else {
result.item = null;
}
Expand Down Expand Up @@ -69,6 +73,7 @@ function cleanseTransactionData(
const paramItem = paramItems[i];
const entityAlias = matchToEntityAlias({ paramItem, identifiers, record });
if (!entityAlias) {
results.push(null);
continue;
}

Expand Down Expand Up @@ -130,14 +135,14 @@ function createTransaction(options) {
data: [],
};
}
let listeners =
options && options.listeners ? [...options.listeners] : [];
if (options && options.logger) {
if (!options.listeners) {
options.listeners = [];
}
options.listeners.push(options.logger);
listeners = [...listeners, options.logger];
}
const response = await driver.go(method, params, {
...options,
listeners,
parse: (options, data) => {
if (options.data === DataOptions.raw) {
return data;
Expand All @@ -162,12 +167,15 @@ function createTransaction(options) {
},
);
} else {
return new Array(paramItems ? paramItems.length : 0).fill({
item: null,
code: "None",
rejected: false,
message: undefined,
});
return Array.from(
{ length: paramItems ? paramItems.length : 0 },
() => ({
item: null,
code: "None",
rejected: false,
message: undefined,
}),
);
}
},
});
Expand Down Expand Up @@ -202,4 +210,6 @@ module.exports = {
createTransaction,
createWriteTransaction,
createGetTransaction,
cleanseTransactionData,
cleanseCanceledData,
};
190 changes: 190 additions & 0 deletions test/fixtures/mock-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { DocumentClient } from "aws-sdk/clients/dynamodb";
import type { V2DocumentClient } from "../../index";

export type StoredItem = Record<string, any>;

export type MockableMethod = keyof V2DocumentClient;

const MockableMethods: Record<MockableMethod, MockableMethod> = {
get: "get",
put: "put",
update: "update",
delete: "delete",
batchWrite: "batchWrite",
batchGet: "batchGet",
scan: "scan",
query: "query",
transactWrite: "transactWrite",
transactGet: "transactGet",
}

export type CanceledTransactionSimulation = {
cancellationReasons: Array<{
Code?: string;
Item?: StoredItem;
Message?: string;
}>;
};

type MethodInput = {
get: DocumentClient.GetItemInput;
put: DocumentClient.PutItemInput;
update: DocumentClient.UpdateItemInput;
delete: DocumentClient.DeleteItemInput;
batchWrite: DocumentClient.BatchWriteItemInput;
batchGet: DocumentClient.BatchGetItemInput;
scan: DocumentClient.ScanInput;
query: DocumentClient.QueryInput;
transactWrite: DocumentClient.TransactWriteItemsInput;
transactGet: DocumentClient.TransactGetItemsInput;
};

type MethodOutput = {
get: DocumentClient.GetItemOutput;
put: DocumentClient.PutItemOutput;
update: DocumentClient.UpdateItemOutput;
delete: DocumentClient.DeleteItemOutput;
batchWrite: DocumentClient.BatchWriteItemOutput;
batchGet: DocumentClient.BatchGetItemOutput;
scan: DocumentClient.ScanOutput;
query: DocumentClient.QueryOutput;
transactWrite:
| DocumentClient.TransactWriteItemsOutput
| CanceledTransactionSimulation;
transactGet:
| DocumentClient.TransactGetItemsOutput
| CanceledTransactionSimulation;
};

export type MockHandler<Method extends MockableMethod = MockableMethod> =
| MethodOutput[Method]
| ((params: MethodInput[Method]) => MethodOutput[Method]);

/** method-name-keyed map: unknown method names are compile errors */
export type MockHandlers = {
[Method in MockableMethod]?: MockHandler<Method>;
};

export type MockClientCall = {
method: MockableMethod;
params: Record<string, any>;
};

export type MockedV2DocumentClient = V2DocumentClient & {
createSet: (value: any) => Set<any>;
};

export type MockV2Client = {
client: MockedV2DocumentClient;
calls: MockClientCall[];
};

export function makeMockV2Client(handlers: MockHandlers = {}): MockV2Client {
const calls: MockClientCall[] = [];
const transactMethods = new Set<MockableMethod>([
"transactWrite",
"transactGet",
]);
const client: any = {
createSet: (value: any) => new Set([].concat(value)),
};
for (const method of Object.values(MockableMethods)) {
client[method] = (params: Record<string, any>) => {
calls.push({ method, params });
const handler = handlers[method];
const value: any =
typeof handler === "function"
? (handler as (input: Record<string, any>) => unknown)(params)
: handler;
if (transactMethods.has(method)) {
const stored: Record<string, (input: any) => void> = {};
return {
on: (event: string, callback: (input: any) => void) => {
stored[event] = callback;
},
abort: () => {},
promise: () => {
if (value && value.cancellationReasons) {
if (stored.extractError) {
stored.extractError({
httpResponse: {
body: {
toString: () =>
JSON.stringify({
CancellationReasons: value.cancellationReasons,
}),
},
},
});
}
return Promise.reject(new Error("TransactionCanceledException"));
}
return Promise.resolve(value === undefined ? {} : value);
},
};
}
return {
promise: () => Promise.resolve(value === undefined ? {} : value),
};
};
}

return { client, calls };
}

export type MakePagingQueryHandlerOptions = {
pages: number;
perPage: number;
/** produces the i-th stored item (from-DynamoDB shape, including key fields) */
makeItem: (i: number) => StoredItem;
};

export type PagingQueryResponse = {
Items: StoredItem[];
Count: number;
LastEvaluatedKey?: { pk: any; sk: any };
};

export type MakePagingQueryHandlerResponse = (
params: Record<string, any>,
) => PagingQueryResponse;

// Builds a `query`/`scan` handler that pages through `pages` responses of
// `perPage` items each, emitting a `LastEvaluatedKey` on every page but the
// last. Sort keys must be unique across items. Like DynamoDB, the handler
// resumes from the params' `ExclusiveStartKey` by locating the item whose
// pk/sk match, so it also honors cursors ElectroDB synthesizes mid-page
// (e.g. when a `count` limit lands inside a page).
export function makePagingQueryHandler(
options: MakePagingQueryHandlerOptions,
): MakePagingQueryHandlerResponse {
const { pages, perPage, makeItem } = options;
const items: StoredItem[] = [];
const indexBySortKey = new Map<string, number>();
for (let i = 0; i < pages * perPage; i++) {
const item = makeItem(i);
items.push(item);
indexBySortKey.set(`${item.pk}|${item.sk}`, i);
}
return (params: Record<string, any>) => {
let start = 0;
const exclusiveStartKey = params.ExclusiveStartKey;
if (exclusiveStartKey !== undefined) {
const index = indexBySortKey.get(
`${exclusiveStartKey.pk}|${exclusiveStartKey.sk}`,
);
if (index === undefined) {
throw new Error("Unknown ExclusiveStartKey provided to paging mock");
}
start = index + 1;
}
const Items = items.slice(start, start + perPage);
const response: PagingQueryResponse = { Items, Count: Items.length };
const lastReturned = start + Items.length - 1;
if (lastReturned < items.length - 1 && Items.length > 0) {
const lastItem = Items[Items.length - 1];
response.LastEvaluatedKey = { pk: lastItem.pk, sk: lastItem.sk };
}
return response;
};
}
Loading
Loading