From e6891f94a948bef3b69d66fdbdb5a32c2de69d72 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Wed, 11 Jun 2025 19:44:03 -0400 Subject: [PATCH 01/10] added snapshot test --- .../__snapshots__/resolvers.test.js.snap | 13 ++++++++++ __tests__/resolvers.test.js | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 3163981..18147bb 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -38,6 +38,19 @@ exports[`dynamodb resolvers something 2`] = ` ] `; +exports[`rds resolvers attributeExists 1`] = ` +{ + "statements": [ + "SELECT * FROM "supplier" WHERE "id" = :P0 AND (("deleted" = :P1) OR ("deleted" IS NULL))", + ], + "variableMap": { + ":P0": "123456", + ":P1": false, + }, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers mysql insert 1`] = ` { "statements": [ diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 907f79f..e16ec54 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -503,6 +503,31 @@ describe("rds resolvers", () => { await checkResolverValid(code, responseContext, "response"); }) + test("attributeExists", 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 () => { From fea0567a4862a3ca9a44b3eb699791ad0d1001e9 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Wed, 11 Jun 2025 19:45:56 -0400 Subject: [PATCH 02/10] added support --- rds/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rds/index.js b/rds/index.js index e452757..755fba4 100644 --- a/rds/index.js +++ b/rds/index.js @@ -318,6 +318,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} ${value? "IS" : "NOT"} NULL${endGrouping}`; default: throw new Error(`Unhandled condition type ${conditionType}`); } From 0be620a7a0df50376bc99104fcc9b7c82e3d36fd Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 01:35:43 -0400 Subject: [PATCH 03/10] fixed and/or for nested conditions --- rds/index.js | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/rds/index.js b/rds/index.js index 755fba4..4e47dc2 100644 --- a/rds/index.js +++ b/rds/index.js @@ -273,23 +273,33 @@ class StatementBuilder { return name; } - buildWhereClause(where) { + buildWhereClause(where, startGrouping = "", endGrouping = "") { 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(); + // .and [ {cond_1}, {cond_1} ] + if ( where[key].length > 1) { + const parts = []; + for (const part of where[key]) { + parts.push(this.buildWhereClause(part, "(", ")")); + } + blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); + } else if (blocks) { + // cond_1.and[ { cond2 } ] + const lastIndex = blocks.length - 1; + blocks[lastIndex] = `${startGrouping}${blocks[lastIndex]} ${ops} ${this.buildWhereClause(where[key][0], "(",")")}${endGrouping}`; + } else { + // .and[{cond_1}] + // Is it possible to have only one condition? + throw new Error(" TODO find the error"); + } + } 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; @@ -300,7 +310,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}`; @@ -319,7 +334,7 @@ class StatementBuilder { case "notContains": return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} NOT LIKE ${value}${endGrouping}`; case "attributeExists": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} ${value? "IS" : "NOT"} NULL${endGrouping}`; + return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} ${value? "NOT" : "IS"} NULL${endGrouping}`; default: throw new Error(`Unhandled condition type ${conditionType}`); } From 291e24864007ffb9c03047eec526d1071142b40c Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 12:42:47 -0600 Subject: [PATCH 04/10] added lots of testing --- .../__snapshots__/resolvers.test.js.snap | 82 +++++++++++- __tests__/resolvers.test.js | 118 +++++++++++++++++- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 18147bb..3b6fe2e 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -38,7 +38,7 @@ exports[`dynamodb resolvers something 2`] = ` ] `; -exports[`rds resolvers attributeExists 1`] = ` +exports[`rds resolvers attributeExists nested or 1`] = ` { "statements": [ "SELECT * FROM "supplier" WHERE "id" = :P0 AND (("deleted" = :P1) OR ("deleted" IS NULL))", @@ -51,6 +51,16 @@ exports[`rds resolvers attributeExists 1`] = ` } `; +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": [ @@ -375,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 e16ec54..8cb1e05 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -503,7 +503,123 @@ describe("rds resolvers", () => { await checkResolverValid(code, responseContext, "response"); }) - test("attributeExists", async () => { + 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({ From bbec3b2fd75ceebe181563bc6cf9436f649eadc7 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 12:43:18 -0600 Subject: [PATCH 05/10] fixed and simplified logic --- rds/index.js | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/rds/index.js b/rds/index.js index 4e47dc2..76e39b0 100644 --- a/rds/index.js +++ b/rds/index.js @@ -273,27 +273,20 @@ class StatementBuilder { return name; } - buildWhereClause(where, startGrouping = "", endGrouping = "") { + buildWhereClause(where, startGrouping = "", endGrouping = "", default_operator="AND") { let blocks = []; for (const key in where) { if ( ["or", "and"].includes(key)) { const ops = key.toUpperCase(); - // .and [ {cond_1}, {cond_1} ] - if ( where[key].length > 1) { - const parts = []; - for (const part of where[key]) { - parts.push(this.buildWhereClause(part, "(", ")")); - } - blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); - } else if (blocks) { - // cond_1.and[ { cond2 } ] - const lastIndex = blocks.length - 1; - blocks[lastIndex] = `${startGrouping}${blocks[lastIndex]} ${ops} ${this.buildWhereClause(where[key][0], "(",")")}${endGrouping}`; - } else { - // .and[{cond_1}] - // Is it possible to have only one condition? - throw new Error(" TODO find the error"); + 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 = []; + for (const part of where[key]) { + parts.push(this.buildWhereClause(part, "(", ")", ops)); } + blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); } else { // implicit single clause const block = {}; @@ -302,7 +295,7 @@ class StatementBuilder { } } - return blocks; + return blocks.join(` ${default_operator} `); } buildWhereStatement(defn, startGrouping = "(", endGrouping = ")") { @@ -334,7 +327,7 @@ class StatementBuilder { case "notContains": return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} NOT LIKE ${value}${endGrouping}`; case "attributeExists": - return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} ${value? "NOT" : "IS"} NULL${endGrouping}`; + return `${startGrouping}${this.quoteChar}${columnName}${this.quoteChar} IS ${value? "NOT " : ""}NULL${endGrouping}`; default: throw new Error(`Unhandled condition type ${conditionType}`); } From e1105182527a8959d2823e77a6e49939d5e3aa0d Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 12:43:43 -0600 Subject: [PATCH 06/10] fixed date handling in tests --- __tests__/resolvers.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 8cb1e05..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)), }, }; @@ -707,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)), } }; @@ -898,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)), } }; From 6445310d392e43f51b053140d3771ef081fc0fa5 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 13:03:08 -0600 Subject: [PATCH 07/10] upgraded node version to match localstack default node version --- test_in_docker.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b4e3dee6434f54304765c06aacbab293190dbfc5 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 13:06:07 -0600 Subject: [PATCH 08/10] updated ci image --- .github/workflows/test-appsync-utils.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-appsync-utils.yml b/.github/workflows/test-appsync-utils.yml index d7139ab..5499cef 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 From 044c8628353f91128b73a9c7c5a7283daca8f2a3 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 13:07:45 -0600 Subject: [PATCH 09/10] one more --- .github/workflows/test-appsync-utils.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-appsync-utils.yml b/.github/workflows/test-appsync-utils.yml index 5499cef..8260918 100644 --- a/.github/workflows/test-appsync-utils.yml +++ b/.github/workflows/test-appsync-utils.yml @@ -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 From 17b86eb8ee91458342af5b4cf5356da5ee47ea91 Mon Sep 17 00:00:00 2001 From: Mathieu Cloutier Date: Thu, 12 Jun 2025 15:06:58 -0600 Subject: [PATCH 10/10] small code cleanup --- rds/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rds/index.js b/rds/index.js index 76e39b0..1c943cc 100644 --- a/rds/index.js +++ b/rds/index.js @@ -282,10 +282,9 @@ class StatementBuilder { // TODO properly handle errors to return a more useful message throw new Error(`'${key}' expects conditions to be an array`); } - const parts = []; - for (const part of where[key]) { - parts.push(this.buildWhereClause(part, "(", ")", ops)); - } + const parts = where[key].map( + part => this.buildWhereClause(part, "(", ")", ops) + ); blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); } else { // implicit single clause