diff --git a/.github/workflows/test-appsync-utils.yml b/.github/workflows/test-appsync-utils.yml index d7139ab..8260918 100644 --- a/.github/workflows/test-appsync-utils.yml +++ b/.github/workflows/test-appsync-utils.yml @@ -11,7 +11,7 @@ on: env: LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_API_KEY }} - TEST_IMAGE_NAME: public.ecr.aws/lambda/nodejs:16 + TEST_IMAGE_NAME: public.ecr.aws/lambda/nodejs:18 jobs: unit-test: @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 18 - run: npm ci - run: npm test @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 18 - name: Pull test docker image run: docker pull $TEST_IMAGE_NAME diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 3163981..3b6fe2e 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -38,6 +38,29 @@ exports[`dynamodb resolvers something 2`] = ` ] `; +exports[`rds resolvers attributeExists nested or 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE "id" = :P0 AND (("deleted" = :P1) OR ("deleted" IS NULL))", + ], + "variableMap": { + ":P0": "123456", + ":P1": false, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers attributeExists true false 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE "created" IS NOT NULL AND "deleted" IS NULL", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers mysql insert 1`] = ` { "statements": [ @@ -362,3 +385,73 @@ exports[`rds resolvers typehints UUID 1`] = ` "value": "abc123", } `; + +exports[`rds resolvers where mixed inline and 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE "count" <= :P0 AND ("id" = :P1) AND "deleted" IS NULL", + ], + "variableMap": { + ":P0": 10, + ":P1": 123456, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers where mixed or/and 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE ("id" = :P0) AND ("id" = :P1) AND "id" = :P2", + ], + "variableMap": { + ":P0": "and eq", + ":P1": "or eq 2", + ":P2": "id eq", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers where mixed or/and multiple or 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE ("id" = :P0) AND ("id" = :P1) OR ("id" = :P2) AND "id" = :P3", + ], + "variableMap": { + ":P0": "and eq", + ":P1": "or eq 1", + ":P2": "or eq 2", + ":P3": "id eq", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers where nested ors with ands 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE "id" = :P0 AND ("id" = :P1) OR (("id" = :P2) OR ("id" = :P3)) OR ("id" = :P4)", + ], + "variableMap": { + ":P0": "id eq", + ":P1": "or 1", + ":P2": "or nested 1", + ":P3": "or nested 2", + ":P4": "final or", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers where single value in and 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE ("id" = :P0)", + ], + "variableMap": { + ":P0": 123456, + }, + "variableTypeHintMap": {}, +} +`; diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 907f79f..59d6660 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -225,7 +225,7 @@ describe("rds resolvers", () => { const context = { arguments: { - id: new Date(2023, 1, 1), + id: new Date(Date.UTC(2023, 1, 1)), }, }; @@ -503,6 +503,147 @@ describe("rds resolvers", () => { await checkResolverValid(code, responseContext, "response"); }) + test("where mixed inline and", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + count: { le: 10 }, + and: [{ id: { eq: 123456 } }], + deleted: { attributeExists: false } + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("where single value in and", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + and: [{ id: { eq: 123456 } }], + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("where mixed or/and", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + and: [{ id: { eq: "and eq" } }], + or: [{ id: { eq: "or eq 2" } }], + id: { eq: "id eq" }, + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("where mixed or/and multiple or", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + and: [{ id: { eq: "and eq" } }], + or: [ + { id: { eq: "or eq 1" } }, + { id: { eq: "or eq 2" } } + ], + id: { eq: "id eq" }, + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("where nested ors with ands", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + id: { eq: "id eq" }, + or: [ + { id: { eq: "or 1" } }, + { + or: [ + { id: { eq: "or nested 1" } }, + { id: { eq: "or nested 2" } } + ] + }, + { id: { eq: "final or" } } + ] + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("attributeExists true false", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + created: { attributeExists: true }, + deleted: { attributeExists: false } + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + + test("attributeExists nested or", async () => { + const code = ` + export function request(ctx) { + const query = rds.select({ + table: 'supplier', + where: { + id: { + eq: "123456" + }, + and: [{ + or: [ + { deleted: { eq: false } }, + { deleted: { attributeExists: false } } + ] + } + ] + } + }); + return rds.createPgStatement(query); + } + export function response(ctx) {} + `; + await checkResolverValid(code, {}, "request"); + }) + describe("mysql", () => { test("raw string", async () => { @@ -566,7 +707,7 @@ describe("rds resolvers", () => { arguments: { id: "1232", name: "hello", - started: new Date(2022, 2, 2), + started: new Date(Date.UTC(2022, 2, 2)), } }; @@ -757,7 +898,7 @@ describe("rds resolvers", () => { arguments: { id: "1232", name: "hello", - started: new Date(2022, 2, 2), + started: new Date(Date.UTC(2022, 2, 2)), } }; diff --git a/rds/index.js b/rds/index.js index e452757..1c943cc 100644 --- a/rds/index.js +++ b/rds/index.js @@ -273,26 +273,28 @@ class StatementBuilder { return name; } - buildWhereClause(where) { + buildWhereClause(where, startGrouping = "", endGrouping = "", default_operator="AND") { let blocks = []; - if (where.or) { - const parts = []; - for (const part of where.or) { - parts.push(this.buildWhereStatement(part)); - } - blocks.push(parts.join(" OR ")); - } else if (where.and) { - const parts = []; - for (const part of where.and) { - parts.push(this.buildWhereStatement(part)); + for (const key in where) { + if ( ["or", "and"].includes(key)) { + const ops = key.toUpperCase(); + if (!Array.isArray(where[key])) { + // TODO properly handle errors to return a more useful message + throw new Error(`'${key}' expects conditions to be an array`); + } + const parts = where[key].map( + part => this.buildWhereClause(part, "(", ")", ops) + ); + blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); + } else { + // implicit single clause + const block = {}; + block[key] = where[key]; + blocks.push(this.buildWhereStatement(block, startGrouping, endGrouping)); } - blocks.push(parts.join(" AND ")); - } else { - // implicit single clause - blocks.push(this.buildWhereStatement(where, "", "")); } - return blocks; + return blocks.join(` ${default_operator} `); } buildWhereStatement(defn, startGrouping = "(", endGrouping = ")") { @@ -300,7 +302,12 @@ class StatementBuilder { const condition = defn[columnName]; const conditionType = Object.keys(condition)[0]; - const value = this.newVariable(condition[conditionType]); + let value; + if (conditionType !== "attributeExists") { + value = this.newVariable(condition[conditionType]); + } else { + value = condition[conditionType]; + } switch (conditionType) { case "eq": return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} = ${value}${endGrouping}`; @@ -318,6 +325,8 @@ class StatementBuilder { return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} LIKE ${value}${endGrouping}`; case "notContains": return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} NOT LIKE ${value}${endGrouping}`; + case "attributeExists": + return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} IS ${value? "NOT " : ""}NULL${endGrouping}`; default: throw new Error(`Unhandled condition type ${conditionType}`); } diff --git a/test_in_docker.sh b/test_in_docker.sh index d670b67..8dd29d3 100755 --- a/test_in_docker.sh +++ b/test_in_docker.sh @@ -18,7 +18,7 @@ if [ -z ${TEST_IN_DOCKER_ENTRYPOINT:-} ]; then --workdir /test \ --entrypoint bash \ -e TEST_IN_DOCKER_ENTRYPOINT=1 \ - ${TEST_IMAGE_NAME:-public.ecr.aws/lambda/nodejs:16} /test_in_docker.sh + ${TEST_IMAGE_NAME:-public.ecr.aws/lambda/nodejs:18} /test_in_docker.sh else # entrypoint echo Entrypoint