Skip to content

Commit

Permalink
Merge pull request #12 from mindler-olli/support-contains-function
Browse files Browse the repository at this point in the history
Support contains function
  • Loading branch information
woltsu authored Mar 18, 2024
2 parents 63a388b + 8c677d5 commit f60af20
Show file tree
Hide file tree
Showing 11 changed files with 136 additions and 22 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tsynamo

Type-safe DynamoDB query builder! Inspired by [Kysely](https://github.com/kysely-org/kysely/tree/master).
Type-friendly DynamoDB query builder! Inspired by [Kysely](https://github.com/kysely-org/kysely/tree/master).

Usable with AWS SDK v3 `DynamoDBDocumentClient`.

Expand Down
5 changes: 5 additions & 0 deletions src/nodes/containsFunctionExpression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type ContainsFunctionExpression = {
readonly kind: "ContainsFunctionExpression";
readonly key: string;
readonly value: unknown;
};
4 changes: 3 additions & 1 deletion src/nodes/filterExpressionJoinTypeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AttributeExistsFunctionExpression } from "./attributeExistsFunctionExpr
import { AttributeNotExistsFunctionExpression } from "./attributeNotExistsFunctionExpression";
import { BeginsWithFunctionExpression } from "./beginsWithFunctionExpression";
import { BetweenConditionExpression } from "./betweenConditionExpression";
import { ContainsFunctionExpression } from "./containsFunctionExpression";
import { FilterExpressionComparatorExpressions } from "./filterExpressionComparatorExpression";
import { FilterExpressionNode } from "./filterExpressionNode";
import { FilterExpressionNotExpression } from "./filterExpressionNotExpression";
Expand All @@ -17,6 +18,7 @@ export type FilterExpressionJoinTypeNode = {
| AttributeExistsFunctionExpression
| AttributeNotExistsFunctionExpression
| BetweenConditionExpression
| BeginsWithFunctionExpression;
| BeginsWithFunctionExpression
| ContainsFunctionExpression;
readonly joinType: JoinType;
};
5 changes: 3 additions & 2 deletions src/nodes/operands.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export type BetweenExpression = "BETWEEN";

// TODO: Support "contains", "size", and "attribute_type" functions
// TODO: Support "size" and "attribute_type" functions
export type FunctionExpression =
| "attribute_exists"
| "attribute_not_exists"
| "begins_with";
| "begins_with"
| "contains";

export type NotExpression = "NOT";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ exports[`QueryQueryBuilder > handles a FilterExpression that takes attributes fr
]
`;

exports[`QueryQueryBuilder > handles a FilterExpression that uses CONTAINS function 1`] = `
[
{
"dataTimestamp": 996,
"someBoolean": false,
"somethingElse": -9,
"tags": [
"testTag",
],
"userId": "313",
},
]
`;

exports[`QueryQueryBuilder > handles a FilterExpression that uses attribute_exists and attribute_not_exists 1`] = `
[
{
Expand Down
13 changes: 6 additions & 7 deletions src/queryBuilders/getItemQueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { GetNode } from "../nodes/getNode";
import { QueryCompiler } from "../queryCompiler";
import {
DeepPartial,
ExecuteOutput,
ObjectFullPaths,
PickPk,
PickSkRequired,
SelectAttributes,
StripKeys,
} from "../typeHelpers";
import { preventAwait } from "../util/preventAwait";
import { QueryCompiler } from "../queryCompiler";

export interface GetQueryBuilderInterface<DDB, Table extends keyof DDB, O> {
keys<Keys extends PickPk<DDB[Table]> & PickSkRequired<DDB[Table]>>(
Expand All @@ -22,7 +21,7 @@ export interface GetQueryBuilderInterface<DDB, Table extends keyof DDB, O> {
attributes: A
): GetQueryBuilderInterface<DDB, Table, SelectAttributes<DDB[Table], A>>;

execute(): Promise<StripKeys<DeepPartial<O>> | undefined>;
execute(): Promise<ExecuteOutput<O> | undefined>;
}

export class GetQueryBuilder<DDB, Table extends keyof DDB, O extends DDB[Table]>
Expand Down Expand Up @@ -77,10 +76,10 @@ export class GetQueryBuilder<DDB, Table extends keyof DDB, O extends DDB[Table]>
}) as any;
}

execute = async (): Promise<StripKeys<DeepPartial<O>> | undefined> => {
execute = async (): Promise<ExecuteOutput<O> | undefined> => {
const command = this.#props.queryCompiler.compile(this.#props.node);
const item = await this.#props.ddbClient.send(command);
return (item.Item as StripKeys<DeepPartial<O>>) ?? undefined;
return (item.Item as ExecuteOutput<O>) ?? undefined;
};
}

Expand Down
17 changes: 14 additions & 3 deletions src/queryBuilders/queryQueryBuilder.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,22 +136,22 @@ describe("QueryQueryBuilder", () => {
});

it("handles a FilterExpression that uses attribute_exists and attribute_not_exists", async () => {
let data = await tsynamoClient
const data = await tsynamoClient
.query("myTable")
.keyCondition("userId", "=", "123")
.filterExpression("nested.nestedString", "attribute_exists")
.execute();

expect(data).toMatchSnapshot();

data = await tsynamoClient
const data2 = await tsynamoClient
.query("myTable")
.keyCondition("userId", "=", "123")
.filterExpression("nested.nestedString", "attribute_not_exists")
.attributes(["userId", "nested"])
.execute();

expect(data).toMatchSnapshot();
expect(data2).toMatchSnapshot();
});

it("handles a FilterExpression that uses begins_with", async () => {
Expand All @@ -171,6 +171,7 @@ describe("QueryQueryBuilder", () => {

expect(data).toMatchSnapshot();
});

it("handles a FilterExpression that takes attributes from cats array", async () => {
let data = await tsynamoClient
.query("myOtherTable")
Expand All @@ -181,4 +182,14 @@ describe("QueryQueryBuilder", () => {

expect(data).toMatchSnapshot();
});

it("handles a FilterExpression that uses CONTAINS function", async () => {
const data = await tsynamoClient
.query("myTable")
.keyCondition("userId", "=", "313")
.filterExpression("tags", "contains", "testTag")
.execute();

expect(data).toMatchSnapshot();
});
});
77 changes: 70 additions & 7 deletions src/queryBuilders/queryQueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { AttributeExistsFunctionExpression } from "../nodes/attributeExistsFunctionExpression";
import { AttributeNotExistsFunctionExpression } from "../nodes/attributeNotExistsFunctionExpression";
import {
FilterExpressionJoinTypeNode,
JoinType,
} from "../nodes/filterExpressionJoinTypeNode";
import { FilterExpressionNode } from "../nodes/filterExpressionNode";
import {
BetweenExpression,
FilterConditionComparators,
Expand All @@ -16,7 +15,7 @@ import {
import { QueryNode } from "../nodes/queryNode";
import { QueryCompiler } from "../queryCompiler";
import {
DeepPartial,
ExecuteOutput,
GetFromPath,
ObjectFullPaths,
ObjectKeyPaths,
Expand All @@ -29,7 +28,7 @@ import {
import { preventAwait } from "../util/preventAwait";

export interface QueryQueryBuilderInterface<DDB, Table extends keyof DDB, O> {
execute(): Promise<StripKeys<DeepPartial<O>>[] | undefined>;
execute(): Promise<ExecuteOutput<O>[] | undefined>;

/**
* keyCondition methods
Expand Down Expand Up @@ -77,13 +76,23 @@ export interface QueryQueryBuilderInterface<DDB, Table extends keyof DDB, O> {
>
): QueryQueryBuilderInterface<DDB, Table, O>;

// begins_with function expression
// BEGINS_WITH function expression
filterExpression<Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>>(
key: Key,
expr: Extract<FunctionExpression, "begins_with">,
substr: string
): QueryQueryBuilderInterface<DDB, Table, O>;

// CONTAINS function expression
filterExpression<
Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>,
Property extends GetFromPath<DDB[Table], Key> & unknown[]
>(
key: Key,
expr: Extract<FunctionExpression, "contains">,
value: StripKeys<Property>[number]
): QueryQueryBuilderInterface<DDB, Table, O>;

// BETWEEN expression
filterExpression<Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>>(
key: Key,
Expand Down Expand Up @@ -134,6 +143,16 @@ export interface QueryQueryBuilderInterface<DDB, Table extends keyof DDB, O> {
substr: string
): QueryQueryBuilderInterface<DDB, Table, O>;

// CONTAINS function expression
orFilterExpression<
Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>,
Property extends GetFromPath<DDB[Table], Key> & unknown[]
>(
key: Key,
expr: Extract<FunctionExpression, "contains">,
value: StripKeys<Property>[number]
): QueryQueryBuilderInterface<DDB, Table, O>;

// BETWEEN expression
orFilterExpression<Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>>(
key: Key,
Expand Down Expand Up @@ -202,6 +221,15 @@ export interface QueryQueryBuilderInterfaceWithOnlyFilterOperations<
substr: string
): QueryQueryBuilderInterfaceWithOnlyFilterOperations<DDB, Table, O>;

filterExpression<
Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>,
Property extends GetFromPath<DDB[Table], Key> & unknown[]
>(
key: Key,
expr: Extract<FunctionExpression, "contains">,
value: StripKeys<Property>[number]
): QueryQueryBuilderInterfaceWithOnlyFilterOperations<DDB, Table, O>;

filterExpression<Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>>(
key: Key,
expr: BetweenExpression,
Expand Down Expand Up @@ -245,6 +273,15 @@ export interface QueryQueryBuilderInterfaceWithOnlyFilterOperations<
substr: string
): QueryQueryBuilderInterfaceWithOnlyFilterOperations<DDB, Table, O>;

orFilterExpression<
Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>,
Property extends GetFromPath<DDB[Table], Key> & unknown[]
>(
key: Key,
expr: Extract<FunctionExpression, "contains">,
value: StripKeys<Property>[number]
): QueryQueryBuilderInterfaceWithOnlyFilterOperations<DDB, Table, O>;

orFilterExpression<Key extends ObjectKeyPaths<PickNonKeys<DDB[Table]>>>(
key: Key,
expr: BetweenExpression,
Expand Down Expand Up @@ -287,6 +324,11 @@ type FilterExprArgs<
>
]
| [key: Key, func: Extract<FunctionExpression, "begins_with">, substr: string]
| [
key: Key,
expr: Extract<FunctionExpression, "contains">,
value: StripKeys<GetFromPath<DDB[Table], Key>>
]
| [
key: Key,
expr: BetweenExpression,
Expand Down Expand Up @@ -421,6 +463,27 @@ export class QueryQueryBuilder<
},
},
});
} else if (args[1] === "contains") {
const [key, expr, value] = args;

return new QueryQueryBuilder<DDB, Table, O>({
...this.#props,
node: {
...this.#props.node,
filterExpression: {
...this.#props.node.filterExpression,
expressions: this.#props.node.filterExpression.expressions.concat({
kind: "FilterExpressionJoinTypeNode",
expr: {
kind: "ContainsFunctionExpression",
key,
value,
},
joinType,
}),
},
},
});
} else if (
args[1] === "attribute_exists" ||
args[1] === "attribute_not_exists"
Expand Down Expand Up @@ -631,10 +694,10 @@ export class QueryQueryBuilder<
}) as any;
}

execute = async (): Promise<StripKeys<DeepPartial<O>>[] | undefined> => {
execute = async (): Promise<ExecuteOutput<O>[] | undefined> => {
const command = this.#props.queryCompiler.compile(this.#props.node);
const result = await this.#props.ddbClient.send(command);
return (result.Items as StripKeys<DeepPartial<O>>[]) ?? undefined;
return (result.Items as ExecuteOutput<O>[]) ?? undefined;
};
}

Expand Down
8 changes: 7 additions & 1 deletion src/queryCompiler/queryCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export class QueryCompiler {
ConsistentRead: consistentReadNode?.enabled,
ProjectionExpression: ProjectionExpression,
ExpressionAttributeNames:
attributeNames.size > 0
attributeNames.size > 0 || ExpressionAttributeNames
? {
...Object.fromEntries(attributeNames),
...ExpressionAttributeNames,
Expand Down Expand Up @@ -228,6 +228,12 @@ export class QueryCompiler {
filterExpressionAttributeValues.set(attributeValue, expr.substr);
break;
}

case "ContainsFunctionExpression": {
res += `contains(${attributeName}, ${attributeValue})`;
filterExpressionAttributeValues.set(attributeValue, expr.value);
break;
}
}

return res;
Expand Down
5 changes: 5 additions & 0 deletions src/typeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ export type DeepPartial<T> = {
? DeepPartial<T[P]>
: T[P];
};

export type ExecuteOutput<O> = StripKeys<
PickPk<O> & PickSkRequired<O> & DeepPartial<O>
>;

type IntersectionToSingleObject<T> = T extends infer U
? { [K in keyof U]: U[K] }
: never;
Expand Down
8 changes: 8 additions & 0 deletions test/testFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface DDB {
nestedNestedBoolean: boolean;
};
};
tags: string[];
};
myOtherTable: {
userId: PartitionKey<string>;
Expand Down Expand Up @@ -216,4 +217,11 @@ export const TEST_DATA = [
},
},
},
{
userId: "313",
dataTimestamp: 996,
somethingElse: -9,
someBoolean: false,
tags: ["testTag"],
},
] as const;

0 comments on commit f60af20

Please sign in to comment.