From 67b1042503526274126f37e23eb94c13e35d1e41 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Thu, 20 Aug 2026 20:58:10 +0200 Subject: [PATCH 1/3] fix(rds): AWS validation errors, table aliases and template where clauses Co-Authored-By: Claude Opus 5 --- README.md | 1 + .../__snapshots__/resolvers.test.js.snap | 653 ++++++++++++++++++ __tests__/helpers.js | 7 +- __tests__/resolvers.test.js | 457 ++++++++++-- errors.js | 18 + index.js | 11 +- package.json | 2 +- rds/index.js | 321 +++++++-- 8 files changed, 1315 insertions(+), 155 deletions(-) create mode 100644 errors.js diff --git a/README.md b/README.md index 1cae8cb..498fd05 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- v0.1.5: add `rds` `beginsWith`, `between` and `size` conditions, table aliases and `sql` template `where` clauses; invalid input now raises AWS's validation error instead of a raw `TypeError`, and the wildcard conditions require a string - v0.1.4: fix `rds` query builders with empty `orderBy`, `where` and `values` inputs, `contains` wildcards and multiple conditions per column; `orderBy` `dir` is now restricted to `ASC`/`DESC` - v0.1.3: fix `rds` query builders with star columns, empty `where` objects and nullable `limit`/`offset` - v0.1.2: fix `rds` query builders with schema qualified identifiers diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 243b696..f5f6447 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -249,6 +249,60 @@ exports[`rds resolvers additional conditions and the from alias beginsWith, betw } `; +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects a nested between with the wrong number of values 1`] = ` +{ + "message": "name.size condition expects an array with 2 values but received an array with length 1.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects a non-string for beginsWith 1`] = ` +{ + "message": "name.beginsWith expects a string value to be passed.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects a non-string for contains 1`] = ` +{ + "message": "name.contains expects a string value to be passed.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects a non-string for notContains 1`] = ` +{ + "message": "name.notContains expects a string value to be passed.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects a size that is not an object 1`] = ` +{ + "message": "Expected name.size to be an Object.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects an unsupported size operator 1`] = ` +{ + "message": "name.size has invalid size operator.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects between with a value that is not an array 1`] = ` +{ + "message": "age.between condition expects an array with length of 2.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects between with the wrong number of values 1`] = ` +{ + "message": "age.between condition expects an array with 2 values but received an array with length 1.", +} +`; + +exports[`rds resolvers additional conditions and the from alias beginsWith, between and size rejects null for beginsWith 1`] = ` +{ + "message": "Value for name.beginsWith can't be null.", +} +`; + exports[`rds resolvers additional conditions and the from alias beginsWith, between and size size and beginsWith in a group 1`] = ` { "statements": [ @@ -413,6 +467,42 @@ exports[`rds resolvers additional conditions and the from alias from as a table } `; +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects a null table 1`] = ` +{ + "message": "'table' or 'from' key is required.", +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects a remove given only from 1`] = ` +{ + "message": "'table' or 'from' key is required.", +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects a select with neither table nor from 1`] = ` +{ + "message": "'table' or 'from' key is required.", +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects an insert given only from 1`] = ` +{ + "message": "'table' or 'from' key is required.", +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects an update given only from 1`] = ` +{ + "message": "'table' or 'from' key is required.", +} +`; + +exports[`rds resolvers additional conditions and the from alias from as a table alias rejects from and table together in select 1`] = ` +{ + "message": "'from' and 'table' keys cannot be used together", +} +`; + exports[`rds resolvers additional conditions and the from alias from as a table alias select with a qualified from 1`] = ` { "statements": [ @@ -778,6 +868,30 @@ exports[`rds resolvers empty, null and repeated clause inputs null value in upda } `; +exports[`rds resolvers empty, null and repeated clause inputs rejects a returning value that is neither a column array nor a star 1`] = ` +{ + "message": "Expected column to be * or an array.", +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs rejects an empty orderBy dir 1`] = ` +{ + "message": "orderBy dir can have either ASC or DESC found .", +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs rejects an orderBy dir that is neither ascending nor descending 1`] = ` +{ + "message": "orderBy dir can have either ASC or DESC found ; DROP TABLE persons.", +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs rejects returning in mysql 1`] = ` +{ + "message": "returning is not supported in MySQL.", +} +`; + exports[`rds resolvers empty, null and repeated clause inputs returning column array in insert 1`] = ` { "statements": [ @@ -824,6 +938,545 @@ exports[`rds resolvers empty, null and repeated clause inputs undefined value in } `; +exports[`rds resolvers input validation column lists a malformed returning in mysql reports the column list 1`] = ` +{ + "message": "Expected column to be * or an array.", +} +`; + +exports[`rds resolvers input validation column lists a null column list selects everything 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation column lists a star column list selects everything 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation column lists a star inside the column array is left unquoted 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation column lists rejects a column list given as a plain string 1`] = ` +{ + "message": "Expected column to be * or an array.", +} +`; + +exports[`rds resolvers input validation column lists rejects a non-string column name 1`] = ` +{ + "message": "Invalid type in column array.", +} +`; + +exports[`rds resolvers input validation column lists rejects a null column name 1`] = ` +{ + "message": "Invalid type in column array.", +} +`; + +exports[`rds resolvers input validation column lists rejects a null column name in returning 1`] = ` +{ + "message": "Invalid type in column array.", +} +`; + +exports[`rds resolvers input validation column lists rejects a star returning in mysql 1`] = ` +{ + "message": "returning is not supported in MySQL.", +} +`; + +exports[`rds resolvers input validation condition values a null size comparison renders a null literal 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE LENGTH ("name") = NULL", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation condition values attributeExists true renders is not null 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" IS NOT NULL", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation condition values rejects a non-boolean attributeExists value 1`] = ` +{ + "message": "name.attributeExists expects a boolean value to be passed.", +} +`; + +exports[`rds resolvers input validation condition values rejects a null attributeExists value 1`] = ` +{ + "message": "Value for name.attributeExists can't be null.", +} +`; + +exports[`rds resolvers input validation condition values rejects a null equality value 1`] = ` +{ + "message": "Value for id.eq can't be null.", +} +`; + +exports[`rds resolvers input validation condition values rejects a null greater-than value 1`] = ` +{ + "message": "Value for id.gt can't be null.", +} +`; + +exports[`rds resolvers input validation condition values rejects an undefined equality value 1`] = ` +{ + "message": "Value for id.eq can't be null.", +} +`; + +exports[`rds resolvers input validation condition values rejects zero for attributeExists 1`] = ` +{ + "message": "name.attributeExists expects a boolean value to be passed.", +} +`; + +exports[`rds resolvers input validation insert and update values rejects an insert with no values 1`] = ` +{ + "message": "values are expected to be passed to insert", +} +`; + +exports[`rds resolvers input validation insert and update values rejects an update with no values 1`] = ` +{ + "message": "values are expected to be passed to update", +} +`; + +exports[`rds resolvers input validation insert and update values rejects insert values given as an array 1`] = ` +{ + "message": "Expected values to an Object.", +} +`; + +exports[`rds resolvers input validation insert and update values rejects null insert values 1`] = ` +{ + "message": "values are expected to be passed to insert", +} +`; + +exports[`rds resolvers input validation order by, limit and offset a numeric orderBy dir sorts ascending 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" ORDER BY "name" ASC", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation order by, limit and offset a numeric string limit binds a number 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" LIMIT :P0", + ], + "variableMap": { + ":P0": 5, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation order by, limit and offset a numeric string offset binds a number 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" OFFSET :P0", + ], + "variableMap": { + ":P0": 5, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation order by, limit and offset an object orderBy dir sorts ascending 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" ORDER BY "name" ASC", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects a limit that is not numeric 1`] = ` +{ + "message": "limit expects a number.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects a non-string orderBy column 1`] = ` +{ + "message": "orderBy item expected to have property column.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects a null orderBy item 1`] = ` +{ + "message": "orderBy item expected to be an object.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects an empty string limit 1`] = ` +{ + "message": "limit expects a number.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects an object offset 1`] = ` +{ + "message": "offset expects a number.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects an orderBy item with no column 1`] = ` +{ + "message": "orderBy item expected to have property column.", +} +`; + +exports[`rds resolvers input validation order by, limit and offset rejects an orderBy that is not an array 1`] = ` +{ + "message": "orderBy expects an array.", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a call with no statement 1`] = ` +{ + "message": "An argument is expected to be passed to createPgStatement", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a call with no statement in mysql 1`] = ` +{ + "message": "An argument is expected to be passed to createMySQLStatement", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a malformed sql tagged template 1`] = ` +{ + "message": "java.lang.IllegalArgumentException: Unexpected argument passed to sql tagged template", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a null payload 1`] = ` +{ + "message": "Expected payload to be an Object.", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a number as a statement 1`] = ` +{ + "message": "Unsupported type is passed as argument to createPgStatement", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a payload given as an array 1`] = ` +{ + "message": "Expected payload to be an Object.", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a payload that is not an object 1`] = ` +{ + "message": "Expected payload to be an Object.", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a remove called with no payload 1`] = ` +{ + "message": "An argument is expected to be passed to remove", +} +`; + +exports[`rds resolvers input validation statement arguments rejects a select called with no payload 1`] = ` +{ + "message": "An argument is expected to be passed to select", +} +`; + +exports[`rds resolvers input validation statement arguments rejects an object that is not a statement 1`] = ` +{ + "message": "Unsupported type is passed as argument to createPgStatement", +} +`; + +exports[`rds resolvers input validation statement arguments rejects an unknown statement type 1`] = ` +{ + "message": "Unsupported type is passed as argument to createPgStatement", +} +`; + +exports[`rds resolvers input validation table names and aliases delete with a table alias 1`] = ` +{ + "statements": [ + "DELETE FROM "p" as "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases from accepts an alias as well 1`] = ` +{ + "statements": [ + "SELECT * FROM "p" as "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases insert with a table alias 1`] = ` +{ + "statements": [ + "INSERT INTO "p" as "persons" ("name") VALUES (:P0)", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases rejects a from that is neither a string nor an object 1`] = ` +{ + "message": "'from' value is expected to be string or object.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects a non-string alias value 1`] = ` +{ + "message": "Table alias value is expected to be a string.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects a non-string table in insert 1`] = ` +{ + "message": "table name is expected to be a string or alias.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects a table given as an array 1`] = ` +{ + "message": "table name is expected to be a string or alias.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects a table that is neither a string nor an alias 1`] = ` +{ + "message": "table name is expected to be a string or alias.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects an alias with more than one entry 1`] = ` +{ + "message": "table alias is allowed only one key-value pair.", +} +`; + +exports[`rds resolvers input validation table names and aliases rejects an empty alias 1`] = ` +{ + "message": "java.util.NoSuchElementException", +} +`; + +exports[`rds resolvers input validation table names and aliases select with a qualified table alias 1`] = ` +{ + "statements": [ + "SELECT * FROM "p" as "public"."persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases select with a table alias 1`] = ` +{ + "statements": [ + "SELECT * FROM "p" as "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases select with a table alias in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`p\` as \`persons\`", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation table names and aliases update with a table alias 1`] = ` +{ + "statements": [ + "UPDATE "p" as "persons" SET "name" = :P0", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation where clauses rejects a bare value as a condition 1`] = ` +{ + "message": "Expected condition to be an Object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a null condition for a column 1`] = ` +{ + "message": "Expected condition to be an Object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a null entry in an and group 1`] = ` +{ + "message": "Expected and to be an Object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a sql template inside an and group 1`] = ` +{ + "message": "Expected and to be an Object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a string entry in an or group 1`] = ` +{ + "message": "Expected or to be an Object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a where given as a plain string 1`] = ` +{ + "message": "WHERE values are expected to be SQL templates or a condition object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects a where given as an array 1`] = ` +{ + "message": "WHERE values are expected to be SQL templates or a condition object.", +} +`; + +exports[`rds resolvers input validation where clauses rejects an and that is not an array 1`] = ` +{ + "message": "and expects conditions to be an array", +} +`; + +exports[`rds resolvers input validation where clauses rejects an or that is not an array 1`] = ` +{ + "message": "or expects conditions to be an array", +} +`; + +exports[`rds resolvers input validation where clauses rejects an unsupported condition 1`] = ` +{ + "message": "Unsupported condition name.bogus.", +} +`; + +exports[`rds resolvers input validation where clauses rejects the in operator 1`] = ` +{ + "message": "Unsupported condition id.in.", +} +`; + +exports[`rds resolvers input validation where clauses where as a sql template 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE id = :P0", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation where clauses where as a sql template in a delete 1`] = ` +{ + "statements": [ + "DELETE FROM "persons" WHERE id = :P0", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation where clauses where as a sql template in an update 1`] = ` +{ + "statements": [ + "UPDATE "persons" SET "name" = :P0 WHERE id = :P1", + ], + "variableMap": { + ":P0": "test", + ":P1": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation where clauses where as a sql template in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`persons\` WHERE id = :P0", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers input validation where clauses where as a sql template with nothing interpolated 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE id = 1", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers mysql insert 1`] = ` { "statements": [ diff --git a/__tests__/helpers.js b/__tests__/helpers.js index eda13b2..bf96a02 100644 --- a/__tests__/helpers.js +++ b/__tests__/helpers.js @@ -34,7 +34,12 @@ const runResolverFunctionOnAWS = async (code, context, functionName) => { const result = await client.send(command); if (result.error) { - return result.error; + // An error raised inside the utils reaches us as an uncaught exception, so AWS prefixes the + // message with the source position of the call that threw. A local run cannot reproduce the + // position, and the message is the part worth comparing, so drop the prefix and normalise to + // the same shape the local branch produces. A `util.error` message arrives without a prefix + // and is left alone. + return { message: result.error.message.replace(/^code\.js:\d+:\d+: /, "") }; } try { return JSON.parse(result.evaluationResult); diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 6ab0b1f..b39d690 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -5,7 +5,6 @@ Within the request it can be resolved to both, e.g. `ctx.arguments` and `ctx.arg import { checkResolverValid } from "./helpers"; import { util } from ".."; -import * as rds from "../rds"; import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -1366,33 +1365,23 @@ describe("rds resolvers", () => { await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { notContains: "test" } } })`), {}, "request"); }); - // These inputs are rejected outright. Note that these assertions never reach - // `checkResolverValid`, so they are NOT compared against AWS under either TEST_TARGET - they - // pin string literals that were checked against `EvaluateCode` by hand when written. They can - // become recorded snapshots once the harness captures a thrown error and strips the - // `code.js::` prefix AWS puts in front of its message. - test("rejects an orderBy dir that is neither ascending nor descending", () => { - expect(() => - rds.createPgStatement(rds.select({ table: "persons", orderBy: [{ column: "id", dir: "; DROP TABLE persons" }] })) - ).toThrow("orderBy dir can have either ASC or DESC found ; DROP TABLE persons."); + // These inputs are rejected outright. The message is snapshotted like any other result: the + // harness strips the source position AWS prefixes a thrown message with, so the recording holds + // against `EvaluateCode` and against the local module alike. + test("rejects an orderBy dir that is neither ascending nor descending", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "id", dir: "; DROP TABLE persons" }] })`), {}, "request"); }); - test("rejects an empty orderBy dir", () => { - expect(() => - rds.createPgStatement(rds.select({ table: "persons", orderBy: [{ column: "id", dir: "" }] })) - ).toThrow("orderBy dir can have either ASC or DESC found ."); + test("rejects an empty orderBy dir", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "id", dir: "" }] })`), {}, "request"); }); - test("rejects returning in mysql", () => { - expect(() => - rds.createMySQLStatement(rds.remove({ table: "persons", where: { id: { eq: 1 } }, returning: ["id"] })) - ).toThrow("returning is not supported in MySQL."); + test("rejects returning in mysql", async () => { + await checkResolverValid(mysql(`rds.remove({ table: "persons", where: { id: { eq: 1 } }, returning: ["id"] })`), {}, "request"); }); - test("rejects a returning value that is neither a column array nor a star", () => { - expect(() => - rds.createPgStatement(rds.insert({ table: "persons", values: { name: "test" }, returning: "id" })) - ).toThrow("Expected column to be * or an array."); + test("rejects a returning value that is neither a column array nor a star", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test" }, returning: "id" })`), {}, "request"); }); }); @@ -1530,52 +1519,41 @@ describe("rds resolvers", () => { await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { gt: 2 } } }, orderBy: [{ column: "name", dir: "desc" }], limit: 5 })`), {}, "request"); }); - // The wildcard conditions all require a string. As above, these assertions never reach - // `checkResolverValid`, so they are NOT compared against AWS under either TEST_TARGET - they - // pin string literals checked against `EvaluateCode` by hand when written. - test("rejects a non-string for beginsWith", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { beginsWith: 5 } } }))) - .toThrow("name.beginsWith expects a string value to be passed."); + // The wildcard conditions all require a string + test("rejects a non-string for beginsWith", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { beginsWith: 5 } } })`), {}, "request"); }); - test("rejects null for beginsWith", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { beginsWith: null } } }))) - .toThrow("Value for name.beginsWith can't be null."); + test("rejects null for beginsWith", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { beginsWith: null } } })`), {}, "request"); }); - test("rejects a non-string for contains", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { contains: 5 } } }))) - .toThrow("name.contains expects a string value to be passed."); + test("rejects a non-string for contains", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { contains: 5 } } })`), {}, "request"); }); - test("rejects a non-string for notContains", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { notContains: 5 } } }))) - .toThrow("name.notContains expects a string value to be passed."); + test("rejects a non-string for notContains", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { notContains: 5 } } })`), {}, "request"); }); - test("rejects between with the wrong number of values", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { age: { between: [1] } } }))) - .toThrow("age.between condition expects an array with 2 values but received an array with length 1."); + test("rejects between with the wrong number of values", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { age: { between: [1] } } })`), {}, "request"); }); - test("rejects between with a value that is not an array", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { age: { between: 5 } } }))) - .toThrow("age.between condition expects an array with length of 2."); + test("rejects between with a value that is not an array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { age: { between: 5 } } })`), {}, "request"); }); - test("rejects a nested between with the wrong number of values", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: { between: [1] } } } }))) - .toThrow("name.size condition expects an array with 2 values but received an array with length 1."); + test("rejects a nested between with the wrong number of values", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { between: [1] } } } })`), {}, "request"); }); - test("rejects an unsupported size operator", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: { contains: "x" } } } }))) - .toThrow("name.size has invalid size operator."); + test("rejects an unsupported size operator", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { contains: "x" } } } })`), {}, "request"); }); - test("rejects a size that is not an object", () => { - expect(() => rds.createPgStatement(rds.select({ table: "persons", where: { name: { size: 3 } } }))) - .toThrow("Expected name.size to be an Object."); + test("rejects a size that is not an object", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: 3 } } })`), {}, "request"); }); }); @@ -1601,35 +1579,374 @@ describe("rds resolvers", () => { await checkResolverValid(pg(`rds.insert({ from: "ignored", table: "persons", values: { name: "test" } })`), {}, "request"); }); - test("rejects from and table together in select", () => { - expect(() => rds.createPgStatement(rds.select({ from: "a", table: "b" }))) - .toThrow("'from' and 'table' keys cannot be used together"); + test("rejects from and table together in select", async () => { + await checkResolverValid(pg(`rds.select({ from: "a", table: "b" })`), {}, "request"); }); - test("rejects a select with neither table nor from", () => { - expect(() => rds.createPgStatement(rds.select({ columns: ["id"] }))) - .toThrow("'table' or 'from' key is required."); + test("rejects a select with neither table nor from", async () => { + await checkResolverValid(pg(`rds.select({ columns: ["id"] })`), {}, "request"); }); - test("rejects a null table", () => { - expect(() => rds.createPgStatement(rds.select({ table: null }))) - .toThrow("'table' or 'from' key is required."); + test("rejects a null table", async () => { + await checkResolverValid(pg(`rds.select({ table: null })`), {}, "request"); }); // insert/update/remove do not accept the alias at all - test("rejects an insert given only from", () => { - expect(() => rds.createPgStatement(rds.insert({ from: "persons", values: { name: "test" } }))) - .toThrow("'table' or 'from' key is required."); + test("rejects an insert given only from", async () => { + await checkResolverValid(pg(`rds.insert({ from: "persons", values: { name: "test" } })`), {}, "request"); }); - test("rejects an update given only from", () => { - expect(() => rds.createPgStatement(rds.update({ from: "persons", values: { name: "test" }, where: { id: { eq: 1 } } }))) - .toThrow("'table' or 'from' key is required."); + test("rejects an update given only from", async () => { + await checkResolverValid(pg(`rds.update({ from: "persons", values: { name: "test" }, where: { id: { eq: 1 } } })`), {}, "request"); }); - test("rejects a remove given only from", () => { - expect(() => rds.createPgStatement(rds.remove({ from: "persons", where: { id: { eq: 1 } } }))) - .toThrow("'table' or 'from' key is required."); + test("rejects a remove given only from", async () => { + await checkResolverValid(pg(`rds.remove({ from: "persons", where: { id: { eq: 1 } } })`), {}, "request"); + }); + }); + }); + + describe("input validation", () => { + const pg = (expr) => ` + export function request(ctx) { + return rds.createPgStatement(${expr}); + } + export function response(ctx) {} + `; + + const mysql = (expr) => ` + export function request(ctx) { + return rds.createMySQLStatement(${expr}); + } + export function response(ctx) {} + `; + + describe("statement arguments", () => { + // an empty expression renders as `createPgStatement()`, with no statement at all + test("rejects a call with no statement", async () => { + await checkResolverValid(pg(``), {}, "request"); + }); + + // the message names the entry point, so both dialects are worth pinning + test("rejects a call with no statement in mysql", async () => { + await checkResolverValid(mysql(``), {}, "request"); + }); + + test("rejects a number as a statement", async () => { + await checkResolverValid(pg(`123`), {}, "request"); + }); + + test("rejects an object that is not a statement", async () => { + await checkResolverValid(pg(`{}`), {}, "request"); + }); + + test("rejects an unknown statement type", async () => { + await checkResolverValid(pg(`{ type: "BOGUS", properties: {} }`), {}, "request"); + }); + + // `sql` is meant to be used as a tag; called by hand it can be given a mismatched arity + test("rejects a malformed sql tagged template", async () => { + await checkResolverValid(pg(`rds.sql(["a"], 1, 2)`), {}, "request"); + }); + + // the statement constructors validate their payload when they are called, so the message + // names the constructor rather than the statement builder + test("rejects a select called with no payload", async () => { + await checkResolverValid(pg(`rds.select()`), {}, "request"); + }); + + test("rejects a remove called with no payload", async () => { + await checkResolverValid(pg(`rds.remove()`), {}, "request"); + }); + + test("rejects a null payload", async () => { + await checkResolverValid(pg(`rds.select(null)`), {}, "request"); + }); + + test("rejects a payload that is not an object", async () => { + await checkResolverValid(pg(`rds.select("persons")`), {}, "request"); + }); + + test("rejects a payload given as an array", async () => { + await checkResolverValid(pg(`rds.select([])`), {}, "request"); + }); + }); + + describe("table names and aliases", () => { + // an alias is a single-entry object, and AWS reads the value as the table and the key as the + // alias - so `{ persons: "p" }` means `"p" as "persons"`, not the other way round + test("select with a table alias", async () => { + await checkResolverValid(pg(`rds.select({ table: { persons: "p" } })`), {}, "request"); + }); + + test("select with a table alias in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: { persons: "p" } })`), {}, "request"); + }); + + test("select with a qualified table alias", async () => { + await checkResolverValid(pg(`rds.select({ table: { "public.persons": "p" } })`), {}, "request"); + }); + + test("from accepts an alias as well", async () => { + await checkResolverValid(pg(`rds.select({ from: { persons: "p" } })`), {}, "request"); + }); + + // unlike `from`, the alias is understood by every statement type + test("insert with a table alias", async () => { + await checkResolverValid(pg(`rds.insert({ table: { persons: "p" }, values: { name: "test" } })`), {}, "request"); + }); + + test("update with a table alias", async () => { + await checkResolverValid(pg(`rds.update({ table: { persons: "p" }, values: { name: "test" } })`), {}, "request"); + }); + + test("delete with a table alias", async () => { + await checkResolverValid(pg(`rds.remove({ table: { persons: "p" } })`), {}, "request"); + }); + + test("rejects an alias with more than one entry", async () => { + await checkResolverValid(pg(`rds.select({ table: { a: "x", b: "y" } })`), {}, "request"); + }); + + // AWS leaks the exception from its own implementation here; the snapshot records it as-is + test("rejects an empty alias", async () => { + await checkResolverValid(pg(`rds.select({ table: {} })`), {}, "request"); + }); + + test("rejects a non-string alias value", async () => { + await checkResolverValid(pg(`rds.select({ table: { persons: 1 } })`), {}, "request"); + }); + + test("rejects a table that is neither a string nor an alias", async () => { + await checkResolverValid(pg(`rds.select({ table: 1 })`), {}, "request"); + }); + + test("rejects a table given as an array", async () => { + await checkResolverValid(pg(`rds.select({ table: ["persons"] })`), {}, "request"); + }); + + // `from` reports its own message rather than the one for `table` + test("rejects a from that is neither a string nor an object", async () => { + await checkResolverValid(pg(`rds.select({ from: 1 })`), {}, "request"); + }); + + test("rejects a non-string table in insert", async () => { + await checkResolverValid(pg(`rds.insert({ table: 1, values: { name: "test" } })`), {}, "request"); + }); + }); + + describe("column lists", () => { + test("a star column list selects everything", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: "*" })`), {}, "request"); + }); + + test("a star inside the column array is left unquoted", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: ["*"] })`), {}, "request"); + }); + + test("a null column list selects everything", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: null })`), {}, "request"); + }); + + test("rejects a column list given as a plain string", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: "name" })`), {}, "request"); + }); + + test("rejects a null column name", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: ["name", null] })`), {}, "request"); + }); + + test("rejects a non-string column name", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", columns: [1] })`), {}, "request"); + }); + + // `returning` is validated by the same rules as `columns` + test("rejects a null column name in returning", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test" }, returning: [null] })`), {}, "request"); + }); + + // the shape of the list is checked before the dialect, so mysql reports the malformed list + // rather than its lack of RETURNING support + test("a malformed returning in mysql reports the column list", async () => { + await checkResolverValid(mysql(`rds.insert({ table: "persons", values: { name: "test" }, returning: "id" })`), {}, "request"); + }); + + test("rejects a star returning in mysql", async () => { + await checkResolverValid(mysql(`rds.insert({ table: "persons", values: { name: "test" }, returning: "*" })`), {}, "request"); + }); + }); + + describe("where clauses", () => { + // a whole where clause may be a sql tagged template instead of a condition object + test("where as a sql template", async () => { + await checkResolverValid(pg('rds.select({ table: "persons", where: rds.sql`id = ${1}` })'), {}, "request"); + }); + + test("where as a sql template with nothing interpolated", async () => { + await checkResolverValid(pg('rds.select({ table: "persons", where: rds.sql`id = 1` })'), {}, "request"); + }); + + test("where as a sql template in mysql", async () => { + await checkResolverValid(mysql('rds.select({ table: "persons", where: rds.sql`id = ${1}` })'), {}, "request"); + }); + + test("where as a sql template in a delete", async () => { + await checkResolverValid(pg('rds.remove({ table: "persons", where: rds.sql`id = ${1}` })'), {}, "request"); + }); + + // the template shares the statement's variable numbering, so the update value binds first + test("where as a sql template in an update", async () => { + await checkResolverValid(pg('rds.update({ table: "persons", values: { name: "test" }, where: rds.sql`id = ${1}` })'), {}, "request"); + }); + + // only a condition object is accepted inside and/or, not a template + test("rejects a sql template inside an and group", async () => { + await checkResolverValid(pg('rds.select({ table: "persons", where: { and: [rds.sql`id = ${1}`] } })'), {}, "request"); + }); + + test("rejects a where given as a plain string", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: "id = 1" })`), {}, "request"); + }); + + test("rejects a where given as an array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: [] })`), {}, "request"); + }); + + test("rejects a null condition for a column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: null } })`), {}, "request"); + }); + + test("rejects a bare value as a condition", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: "test" } })`), {}, "request"); + }); + + test("rejects an and that is not an array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: "nope" } })`), {}, "request"); + }); + + test("rejects an or that is not an array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { or: "nope" } })`), {}, "request"); + }); + + test("rejects a null entry in an and group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [null] } })`), {}, "request"); + }); + + test("rejects a string entry in an or group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { or: ["id = 1"] } })`), {}, "request"); + }); + + test("rejects an unsupported condition", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { bogus: 1 } } })`), {}, "request"); + }); + + // the dynamodb operators are not shared with rds, `in` included + test("rejects the in operator", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { in: [1, 2] } } })`), {}, "request"); + }); + }); + + describe("condition values", () => { + test("rejects a null equality value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { eq: null } } })`), {}, "request"); + }); + + test("rejects an undefined equality value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { eq: undefined } } })`), {}, "request"); + }); + + test("rejects a null greater-than value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { gt: null } } })`), {}, "request"); + }); + + // a comparison under `size` keeps rendering a NULL literal - the rejection is only for a + // direct comparison + test("a null size comparison renders a null literal", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { size: { eq: null } } } })`), {}, "request"); + }); + + test("attributeExists true renders is not null", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { attributeExists: true } } })`), {}, "request"); + }); + + test("rejects a null attributeExists value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { attributeExists: null } } })`), {}, "request"); + }); + + // a truthy value is not enough, it has to be a boolean + test("rejects a non-boolean attributeExists value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { attributeExists: "yes" } } })`), {}, "request"); + }); + + test("rejects zero for attributeExists", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { attributeExists: 0 } } })`), {}, "request"); + }); + }); + + describe("order by, limit and offset", () => { + test("rejects an orderBy that is not an array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: { column: "name" } })`), {}, "request"); + }); + + test("rejects a null orderBy item", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [null] })`), {}, "request"); + }); + + test("rejects an orderBy item with no column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{}] })`), {}, "request"); + }); + + test("rejects a non-string orderBy column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: 1 }] })`), {}, "request"); + }); + + // only a string dir is validated: any other value means ascending, the same as omitting it + test("a numeric orderBy dir sorts ascending", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "name", dir: 1 }] })`), {}, "request"); + }); + + test("an object orderBy dir sorts ascending", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "name", dir: {} }] })`), {}, "request"); + }); + + // a numeric string is coerced, so the bound variable is a number either way + test("a numeric string limit binds a number", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", limit: "5" })`), {}, "request"); + }); + + test("a numeric string offset binds a number", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", offset: "5" })`), {}, "request"); + }); + + test("rejects a limit that is not numeric", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", limit: "abc" })`), {}, "request"); + }); + + test("rejects an empty string limit", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", limit: "" })`), {}, "request"); + }); + + test("rejects an object offset", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", offset: {} })`), {}, "request"); + }); + }); + + describe("insert and update values", () => { + test("rejects an insert with no values", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons" })`), {}, "request"); + }); + + // the message names the statement type + test("rejects an update with no values", async () => { + await checkResolverValid(pg(`rds.update({ table: "persons", where: { id: { eq: 1 } } })`), {}, "request"); + }); + + test("rejects null insert values", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: null })`), {}, "request"); + }); + + test("rejects insert values given as an array", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: [1] })`), {}, "request"); }); }); }); diff --git a/errors.js b/errors.js new file mode 100644 index 0000000..acdd3c1 --- /dev/null +++ b/errors.js @@ -0,0 +1,18 @@ +/** + * Raised for input the AppSync JS utils reject. AppSync surfaces this as a GraphQL error rather + * than an internal failure, so the message reaches the caller instead of an opaque + * `JSExecutionError`. + * + * Lives in its own module because both `index.js` and `rds/index.js` throw it, and `index.js` + * already imports from `rds/index.js` - declaring the class in either one would make the pair + * circular. + */ +export class AppSyncUserError extends Error { + constructor(message, errorType, data, errorInfo) { + super(message); + this.name = "AppSyncUserError"; + this.errorType = errorType; + this.data = data; + this.errorInfo = errorInfo; + } +} diff --git a/index.js b/index.js index 82fdb6e..70d1a5d 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { toJsonObject } from './rds/index.js' - -class AppSyncUserError extends Error { - constructor(message, errorType, data, errorInfo) { - super(message); - this.name = "AppSyncUserError"; - this.errorType = errorType; - this.data = data; - this.errorInfo = errorInfo; - } -} +import { AppSyncUserError } from './errors.js' export const dynamodbUtils = { toDynamoDB: function(value) { diff --git a/package.json b/package.json index a803b68..56c4a73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.4", + "version": "0.1.5", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js", diff --git a/rds/index.js b/rds/index.js index 5d44687..cef4427 100644 --- a/rds/index.js +++ b/rds/index.js @@ -1,3 +1,9 @@ +import { AppSyncUserError } from '../errors.js'; + +// AWS leaks the exception its own implementation raises for a malformed tagged template. It is +// reproduced verbatim, like the rest of these messages. +const TEMPLATE_ARITY_ERROR = "java.lang.IllegalArgumentException: Unexpected argument passed to sql tagged template"; + /** * Extract the value from the field given a row from sqlStatementResults. * @@ -68,28 +74,52 @@ export function toJsonObject(inputStr) { } export function sql(strings, ...keys) { + // AWS validates the arity when the template is built, not when it is rendered if (strings.length !== (keys.length + 1)) { - throw new Exception(`unhandled format for sql tagged template: ${{ strings, keys }}`); + throw new AppSyncUserError(TEMPLATE_ARITY_ERROR); } return { strings, keys }; } +// `sql` returns `{ strings, keys }`, and both keys are present even for a template that +// interpolates nothing +function isSqlTemplate(value) { + return (value != null) && (value.strings !== undefined) && (value.keys !== undefined); +} + + +/** + * Every statement constructor validates its payload the same way, and does so when it is called + * rather than when the statement is rendered - so the message names the constructor, not the + * `create*Statement` it was going to be passed to. + */ +function statementPayload(properties, verb) { + if (properties === undefined) { + throw new AppSyncUserError(`An argument is expected to be passed to ${verb}`); + } + + if ((properties === null) || (typeof properties !== 'object') || Array.isArray(properties)) { + throw new AppSyncUserError("Expected payload to be an Object."); + } + + return properties; +} export function select(s) { - return { type: "SELECT", properties: s }; + return { type: "SELECT", properties: statementPayload(s, 'select') }; } export function insert(s) { - return { type: "INSERT", properties: s }; + return { type: "INSERT", properties: statementPayload(s, 'insert') }; } export function update(s) { - return { type: "UPDATE", properties: s }; + return { type: "UPDATE", properties: statementPayload(s, 'update') }; } export function remove(s) { - return { type: "REMOVE", properties: s }; + return { type: "REMOVE", properties: statementPayload(s, 'remove') }; } // SQL operators for the simple comparison conditions, shared by the direct form (`{ eq: 1 }`) and @@ -104,8 +134,10 @@ const COMPARISON_OPERATORS = { }; class StatementBuilder { - constructor({ quoteChar, supportsReturning = true }) { + constructor({ quoteChar, functionName, supportsReturning = true }) { this.quoteChar = quoteChar; + // named in the messages AWS raises for a statement it cannot make sense of + this.functionName = functionName; this.supportsReturning = supportsReturning; this.result = { statements: [], @@ -117,45 +149,54 @@ class StatementBuilder { } render(statements) { + if (statements.length === 0) { + throw new AppSyncUserError(`An argument is expected to be passed to ${this.functionName}`); + } + for (const stmt of statements) { - // handle raw sql strings - if (stmt.strings !== undefined) { - const { strings, keys } = stmt; - this.renderTaggedTemplateStatement(strings, keys); + if (typeof stmt === "string") { + // a raw SQL string passes straight through + this.renderRawTemplateStatement(stmt); + } else if (isSqlTemplate(stmt)) { + this.renderTaggedTemplateStatement(stmt.strings, stmt.keys); + } else if ((stmt != null) && (stmt.type !== undefined) && (stmt.properties !== undefined)) { + this.renderStructuredStatement(stmt.type, stmt.properties); } else { - const { type, properties } = stmt; - if ((type === undefined) && (properties === undefined)) { - // we have a raw string - this.renderRawTemplateStatement(stmt); - } else { - this.renderStructuredStatement(type, properties); - } + this.unsupportedStatement(); } } return this.result; } + unsupportedStatement() { + throw new AppSyncUserError(`Unsupported type is passed as argument to ${this.functionName}`); + } + renderRawTemplateStatement(query) { this.result.statements.push(query); } renderTaggedTemplateStatement(strings, keys) { - let stmt = strings[0]; + this.result.statements.push(this.renderTemplate(strings, keys)); + } + /** + * Interpolate a `sql` tagged template, binding every interpolated value to a variable. A whole + * statement can be a template, and so can a `where` clause, so this returns the fragment rather + * than pushing it. + */ + renderTemplate(strings, keys) { if (strings.length !== (keys.length + 1)) { - throw new Error(`Invalid raw string statement: ${{ strings, keys }}`); + throw new AppSyncUserError(TEMPLATE_ARITY_ERROR); } + let stmt = strings[0]; for (let i = 0; i < keys.length; i++) { - const nextString = strings[i + 1]; - const nextKey = keys[i]; - - const newVar = this.newVariable(nextKey); - stmt = `${stmt}${newVar}${nextString}`; + stmt = `${stmt}${this.newVariable(keys[i])}${strings[i + 1]}`; } - this.result.statements.push(stmt); + return stmt; } /** @@ -175,27 +216,31 @@ class StatementBuilder { const { columns, where, orderBy, limit, offset } = properties; const parts = ["SELECT"]; - if (columns) { - parts.push(columns.map(name => this.quoteIdentifier(name)).join(', ')); - } else { - parts.push('*'); - } + // an absent column list means every column. Anything else is validated, so `columns: []` + // still leaves a dangling `SELECT`, like AWS + parts.push(columns == null ? '*' : this.renderColumnList(columns)); parts.push(`FROM ${this.resolveTableName(properties, true)}`); parts.push(...this.buildWhereParts(where)); - // an empty sort list drops the whole clause, ORDER BY keyword included, like AWS - if (orderBy && orderBy.length > 0) { - parts.push('ORDER BY', orderBy.map(item => this.renderOrderByItem(item)).join(', ')); + if (orderBy != null) { + if (!Array.isArray(orderBy)) { + throw new AppSyncUserError("orderBy expects an array."); + } + + // an empty sort list drops the whole clause, ORDER BY keyword included, like AWS + if (orderBy.length > 0) { + parts.push('ORDER BY', orderBy.map(item => this.renderOrderByItem(item)).join(', ')); + } } // limit/offset are optional and may be passed as null; 0 is a valid value if (limit != null) { - parts.push(`LIMIT ${this.newVariable(limit)}`); + parts.push(`LIMIT ${this.renderRowCount(limit, 'limit')}`); } if (offset != null) { - parts.push(`OFFSET ${this.newVariable(offset)}`); + parts.push(`OFFSET ${this.renderRowCount(offset, 'offset')}`); } this.result.statements.push(this.joinClauses(parts)); @@ -215,7 +260,8 @@ class StatementBuilder { break; } case "INSERT": { - const { values, returning } = properties; + const { returning } = properties; + const values = this.resolveValues(properties, 'insert'); const parts = [`INSERT INTO ${this.resolveTableName(properties)}`]; let columnTextItems = []; @@ -234,7 +280,8 @@ class StatementBuilder { break; } case "UPDATE": { - const { values, where } = properties; + const { where } = properties; + const values = this.resolveValues(properties, 'update'); const parts = [`UPDATE ${this.resolveTableName(properties)}`, 'SET']; let columnDefinitionItems = []; @@ -249,50 +296,115 @@ class StatementBuilder { break; } default: - throw new Error(`TODO: "${type}" query unsupported`); + this.unsupportedStatement(); } } buildWhereParts(where) { - if (!where) { + if (where == null) { return []; } + // the whole clause may be a `sql` tagged template instead of a condition object, sharing the + // statement's variable numbering + if (isSqlTemplate(where)) { + const rendered = this.renderTemplate(where.strings, where.keys); + return rendered ? ['WHERE', rendered] : []; + } + + if ((typeof where !== 'object') || Array.isArray(where)) { + throw new AppSyncUserError("WHERE values are expected to be SQL templates or a condition object."); + } + // a `where` that renders to nothing - `{}`, `{ and: [] }`, or a column with no condition - // drops the WHERE keyword along with its body const clause = this.buildWhereClause(where); return clause ? ['WHERE', clause] : []; } - renderOrderByItem({ column, dir }) { + renderOrderByItem(item) { + if ((item == null) || (typeof item !== 'object') || Array.isArray(item)) { + throw new AppSyncUserError("orderBy item expected to be an object."); + } + + const { column, dir } = item; + if (typeof column !== 'string') { + throw new AppSyncUserError("orderBy item expected to have property column."); + } + // AWS uppercases `dir` and accepts only ASC or DESC. Interpolating it raw would let a caller - // inject arbitrary SQL through the sort direction. An absent or null `dir` means ASC, but an - // empty string is rejected, matching AWS. - const direction = dir == null ? 'ASC' : String(dir).toUpperCase(); + // inject arbitrary SQL through the sort direction. Only a string is checked at all: any other + // value, null included, means ASC, while an empty string is rejected - matching AWS. + const direction = typeof dir !== 'string' ? 'ASC' : dir.toUpperCase(); if ((direction !== 'ASC') && (direction !== 'DESC')) { - throw new Error(`orderBy dir can have either ASC or DESC found ${dir}.`); + throw new AppSyncUserError(`orderBy dir can have either ASC or DESC found ${dir}.`); } return `${this.quoteIdentifier(column)} ${direction}`; } renderReturning(returning) { + // the column list is validated before the dialect is considered: a malformed `returning` on + // MySQL is reported as malformed, not as unsupported + const columns = this.renderColumnList(returning); + // MySQL has no RETURNING clause and AWS refuses the key outright rather than emitting SQL the // engine would reject if (!this.supportsReturning) { - throw new Error("returning is not supported in MySQL."); + throw new AppSyncUserError("returning is not supported in MySQL."); } + return columns; + } + + /** + * Validate and quote a list of column names. AWS applies the same rules, and raises the same + * messages, for a select's `columns` and an insert or delete's `returning`. + */ + renderColumnList(columns) { // AWS accepts either the bare string `*` or an array of column names - if (returning === '*') { - return returning; + if (columns === '*') { + return columns; + } + + if (!Array.isArray(columns)) { + throw new AppSyncUserError("Expected column to be * or an array."); } - if (!Array.isArray(returning)) { - throw new Error('Expected column to be * or an array.'); + return columns.map(name => { + if (typeof name !== 'string') { + throw new AppSyncUserError("Invalid type in column array."); + } + + return this.quoteIdentifier(name); + }).join(', '); + } + + resolveValues(properties, verb) { + const { values } = properties; + if (values == null) { + throw new AppSyncUserError(`values are expected to be passed to ${verb}`); + } + + if ((typeof values !== 'object') || Array.isArray(values)) { + // AWS's wording, grammar included + throw new AppSyncUserError("Expected values to an Object."); } - return returning.map(name => this.quoteIdentifier(name)).join(', '); + return values; + } + + /** + * LIMIT and OFFSET are bound as numbers, and AWS coerces a numeric string on the way in. An + * empty string is the one value coercion would quietly turn into 0, and AWS rejects it. + */ + renderRowCount(value, keyword) { + const count = value === '' ? NaN : Number(value); + if (Number.isNaN(count)) { + throw new AppSyncUserError(`${keyword} expects a number.`); + } + + return this.newVariable(count); } renderValue(value) { @@ -325,12 +437,17 @@ class StatementBuilder { 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`); + throw new AppSyncUserError(`${key} expects conditions to be an array`); } - const parts = where[key].map( - part => this.buildWhereClause(part, "(", ")", ops) - ); + const parts = where[key].map(part => { + // only a condition object is accepted here - unlike a top-level `where`, a nested `sql` + // template is refused + if ((part == null) || (typeof part !== 'object') || Array.isArray(part) || isSqlTemplate(part)) { + throw new AppSyncUserError(`Expected ${key} to be an Object.`); + } + + return this.buildWhereClause(part, "(", ")", ops); + }); const group = parts.join(` ${ops} `); // an `and`/`or` holding no conditions contributes nothing at all: emitting the grouping on // its own would produce `WHERE ()` @@ -352,6 +469,10 @@ class StatementBuilder { const columnName = Object.keys(defn)[0]; const condition = defn[columnName]; + if ((condition == null) || (typeof condition !== 'object') || Array.isArray(condition)) { + throw new AppSyncUserError("Expected condition to be an Object."); + } + // several conditions on the same column are ANDed together const statements = Object.keys(condition).map( conditionType => this.buildCondition(columnName, condition[conditionType], conditionType) @@ -374,7 +495,8 @@ class StatementBuilder { case "between": return this.buildBetweenCondition(column, rawValue, path); case "attributeExists": - return `${column} IS ${rawValue? "NOT " : ""}NULL`; + // AWS wants a real boolean here rather than anything truthy + return `${column} IS ${this.requireBoolean(rawValue, path) ? "NOT " : ""}NULL`; case "contains": // the wildcards make `contains` a substring match rather than an equality test return `${column} LIKE ${this.newVariable(`%${this.requireString(rawValue, path)}%`)}`; @@ -386,10 +508,12 @@ class StatementBuilder { default: { const operator = COMPARISON_OPERATORS[conditionType]; if (!operator) { - throw new Error(`Unhandled condition type ${conditionType}`); + throw new AppSyncUserError(`Unsupported condition ${path}.`); } - return `${column} ${operator} ${this.newVariable(rawValue)}`; + // unlike the same comparison under `size`, a direct comparison against a nullish value is + // rejected rather than rendered as `= NULL` + return `${column} ${operator} ${this.newVariable(this.requireNonNull(rawValue, path))}`; } } } @@ -398,11 +522,11 @@ class StatementBuilder { // AWS uses two distinct messages here: one for a value that is not an array at all, another // for an array of the wrong length if (!Array.isArray(rawValue)) { - throw new Error(`${path} condition expects an array with length of 2.`); + throw new AppSyncUserError(`${path} condition expects an array with length of 2.`); } if (rawValue.length !== 2) { - throw new Error(`${path} condition expects an array with 2 values but received an array with length ${rawValue.length}.`); + throw new AppSyncUserError(`${path} condition expects an array with 2 values but received an array with length ${rawValue.length}.`); } // mapping in order keeps the bound variables numbered low-then-high @@ -412,7 +536,7 @@ class StatementBuilder { buildSizeCondition(column, rawValue, path) { if ((typeof rawValue !== 'object') || (rawValue === null) || Array.isArray(rawValue)) { - throw new Error(`Expected ${path} to be an Object.`); + throw new AppSyncUserError(`Expected ${path} to be an Object.`); } // the comparison runs against the column's length, with the target repeated for each operator. @@ -426,7 +550,7 @@ class StatementBuilder { const comparison = COMPARISON_OPERATORS[operator]; if (!comparison) { - throw new Error(`${path} has invalid size operator.`); + throw new AppSyncUserError(`${path} has invalid size operator.`); } // unlike a direct comparison, a nullish value here is inlined rather than rejected @@ -436,14 +560,26 @@ class StatementBuilder { return statements.join(" AND "); } + requireNonNull(value, path) { + if (value == null) { + throw new AppSyncUserError(`Value for ${path} can't be null.`); + } + + return value; + } + requireString(value, path) { // AWS rejects a non-string for the wildcard conditions instead of coercing it - if (value == null) { - throw new Error(`Value for ${path} can't be null.`); + if (typeof this.requireNonNull(value, path) !== 'string') { + throw new AppSyncUserError(`${path} expects a string value to be passed.`); } - if (typeof value !== 'string') { - throw new Error(`${path} expects a string value to be passed.`); + return value; + } + + requireBoolean(value, path) { + if (typeof this.requireNonNull(value, path) !== 'boolean') { + throw new AppSyncUserError(`${path} expects a boolean value to be passed.`); } return value; @@ -455,19 +591,56 @@ class StatementBuilder { // `from` is an alias for `table`, but only in select(): insert/update/remove ignore the key // entirely, and only select() rejects the two being passed together if (allowFrom && (table != null) && (from != null)) { - throw new Error("'from' and 'table' keys cannot be used together"); + throw new AppSyncUserError("'from' and 'table' keys cannot be used together"); } - const name = allowFrom ? (table ?? from) : table; - if (name == null) { - throw new Error("'table' or 'from' key is required."); + if (allowFrom && (table == null) && (from != null)) { + // `from` carries its own type message, and rejects the array `table` would too + if ((typeof from !== 'string') && ((typeof from !== 'object') || Array.isArray(from))) { + throw new AppSyncUserError("'from' value is expected to be string or object."); + } + + return this.renderTableName(from); + } + + if (table == null) { + throw new AppSyncUserError("'table' or 'from' key is required."); } - return this.getTableName(name); + return this.renderTableName(table); } - getTableName(rawName) { - return this.quoteIdentifier(rawName); + /** + * A table is named either by a string or by a single-entry alias object, which AWS renders with + * the *value* as the table name and the *key* as the alias: `{ persons: "p" }` becomes + * `"p" as "persons"`. + */ + renderTableName(name) { + if (typeof name === 'string') { + return this.quoteIdentifier(name); + } + + if ((typeof name !== 'object') || Array.isArray(name)) { + throw new AppSyncUserError("table name is expected to be a string or alias."); + } + + const entries = Object.entries(name); + if (entries.length > 1) { + throw new AppSyncUserError("table alias is allowed only one key-value pair."); + } + + if (entries.length === 0) { + // an exception AWS's own implementation leaks for an empty alias object, reproduced verbatim + // so error handling behaves the same here as it does against AWS + throw new AppSyncUserError("java.util.NoSuchElementException"); + } + + const [alias, actual] = entries[0]; + if (typeof actual !== 'string') { + throw new AppSyncUserError("Table alias value is expected to be a string."); + } + + return `${this.quoteIdentifier(actual)} as ${this.quoteIdentifier(alias)}`; } quoteIdentifier(rawName) { @@ -487,6 +660,7 @@ class StatementBuilder { export function createPgStatement(...statements) { let builder = new StatementBuilder({ quoteChar: '"', + functionName: 'createPgStatement', }); return builder.render(statements); } @@ -494,6 +668,7 @@ export function createPgStatement(...statements) { export function createMySQLStatement(...statements) { let builder = new StatementBuilder({ quoteChar: '`', + functionName: 'createMySQLStatement', supportsReturning: false, }); return builder.render(statements); From 26e4ea56acbc7addedd8b1d958debe16eab9b086 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Thu, 20 Aug 2026 22:21:04 +0200 Subject: [PATCH 2/3] test: pass a valid select payload in the docker smoke test Co-Authored-By: Claude Opus 5 --- 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 e9d6507..4b3776e 100755 --- a/test_in_docker.sh +++ b/test_in_docker.sh @@ -35,7 +35,7 @@ else echo "import { get } from '@aws-appsync/utils/dynamodb';" >> main.mjs echo "console.log({ get: get({ key: 10 }) });" >> main.mjs echo "import { select } from '@aws-appsync/utils/rds';" >> main.mjs - echo "console.log({ value: select(10) });" >> main.mjs + echo "console.log({ value: select({ table: 'persons' }) });" >> main.mjs echo "Checking package:" node --experimental-specifier-resolution=node main.mjs From 6cd4acad2f1b545c3011e6b7d890090c69d27077 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Thu, 20 Aug 2026 22:58:17 +0200 Subject: [PATCH 3/3] fix(rds): attribute validation errors to the Code errorType Co-Authored-By: Claude Opus 5 --- __tests__/resolvers.test.js | 41 +++++++++++++++++++++ errors.js | 14 ++++++++ rds/index.js | 72 ++++++++++++++++++------------------- 3 files changed, 91 insertions(+), 36 deletions(-) diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index b39d690..82b784d 100644 --- a/__tests__/resolvers.test.js +++ b/__tests__/resolvers.test.js @@ -5,6 +5,7 @@ Within the request it can be resolved to both, e.g. `ctx.arguments` and `ctx.arg import { checkResolverValid } from "./helpers"; import { util } from ".."; +import * as rds from "../rds"; import * as fs from 'fs/promises'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -1971,4 +1972,44 @@ describe("error handling", () => { `; await checkResolverValid(code, {}, "request"); }) +}) + +// LocalStack has to tell a rejection raised by the library from an error the resolver author raised +// deliberately, and the two are otherwise identical: both are an `AppSyncUserError` carrying only a +// message. AppSync attributes the former to the errorType `Code`. +// +// This pair is the one thing here that is NOT compared against AWS, and cannot be: `EvaluateCode` +// reports an error as a bare message and never mentions errorType, so there is nothing to record. +// It pins our own contract with the runtime rather than a claim about AWS. +describe("error types", () => { + const thrownBy = (fn) => { + try { + fn(); + } catch (e) { + return e; + } + + throw new Error("expected the call to throw"); + }; + + test("a rejected input is attributed to the resolver code", () => { + const error = thrownBy(() => rds.createPgStatement(rds.select({}))); + + expect(error.name).toBe("AppSyncUserError"); + expect(error.errorType).toBe("Code"); + expect(error.message).toBe("'table' or 'from' key is required."); + }); + + test("a deliberate util.error carries no errorType", () => { + const error = thrownBy(() => util.error("foo")); + + expect(error.name).toBe("AppSyncUserError"); + expect(error.errorType).toBeUndefined(); + }); + + test("util.error keeps an errorType it was given", () => { + const error = thrownBy(() => util.error("foo", "MyErrorType")); + + expect(error.errorType).toBe("MyErrorType"); + }); }) \ No newline at end of file diff --git a/errors.js b/errors.js index acdd3c1..67effd1 100644 --- a/errors.js +++ b/errors.js @@ -16,3 +16,17 @@ export class AppSyncUserError extends Error { this.errorInfo = errorInfo; } } + + +/** + * Build the error for input the utils reject. AppSync attributes a fault in the resolver code + * itself - which is what invalid input to a util is - to the errorType `Code`, as distinct from the + * null errorType a deliberate `util.error` carries. The two are otherwise indistinguishable, so + * without this a caller cannot tell a rejection raised by the library from an error the resolver + * author raised on purpose. + * + * `EvaluateCode` reports only the message, so this is not observable in the recorded snapshots. + */ +export function codeError(message) { + return new AppSyncUserError(message, "Code"); +} diff --git a/rds/index.js b/rds/index.js index cef4427..7c03887 100644 --- a/rds/index.js +++ b/rds/index.js @@ -1,4 +1,4 @@ -import { AppSyncUserError } from '../errors.js'; +import { codeError } from '../errors.js'; // AWS leaks the exception its own implementation raises for a malformed tagged template. It is // reproduced verbatim, like the rest of these messages. @@ -76,7 +76,7 @@ export function toJsonObject(inputStr) { export function sql(strings, ...keys) { // AWS validates the arity when the template is built, not when it is rendered if (strings.length !== (keys.length + 1)) { - throw new AppSyncUserError(TEMPLATE_ARITY_ERROR); + throw codeError(TEMPLATE_ARITY_ERROR); } return { strings, keys }; @@ -96,11 +96,11 @@ function isSqlTemplate(value) { */ function statementPayload(properties, verb) { if (properties === undefined) { - throw new AppSyncUserError(`An argument is expected to be passed to ${verb}`); + throw codeError(`An argument is expected to be passed to ${verb}`); } if ((properties === null) || (typeof properties !== 'object') || Array.isArray(properties)) { - throw new AppSyncUserError("Expected payload to be an Object."); + throw codeError("Expected payload to be an Object."); } return properties; @@ -150,7 +150,7 @@ class StatementBuilder { render(statements) { if (statements.length === 0) { - throw new AppSyncUserError(`An argument is expected to be passed to ${this.functionName}`); + throw codeError(`An argument is expected to be passed to ${this.functionName}`); } for (const stmt of statements) { @@ -170,7 +170,7 @@ class StatementBuilder { } unsupportedStatement() { - throw new AppSyncUserError(`Unsupported type is passed as argument to ${this.functionName}`); + throw codeError(`Unsupported type is passed as argument to ${this.functionName}`); } renderRawTemplateStatement(query) { @@ -188,7 +188,7 @@ class StatementBuilder { */ renderTemplate(strings, keys) { if (strings.length !== (keys.length + 1)) { - throw new AppSyncUserError(TEMPLATE_ARITY_ERROR); + throw codeError(TEMPLATE_ARITY_ERROR); } let stmt = strings[0]; @@ -225,7 +225,7 @@ class StatementBuilder { if (orderBy != null) { if (!Array.isArray(orderBy)) { - throw new AppSyncUserError("orderBy expects an array."); + throw codeError("orderBy expects an array."); } // an empty sort list drops the whole clause, ORDER BY keyword included, like AWS @@ -313,7 +313,7 @@ class StatementBuilder { } if ((typeof where !== 'object') || Array.isArray(where)) { - throw new AppSyncUserError("WHERE values are expected to be SQL templates or a condition object."); + throw codeError("WHERE values are expected to be SQL templates or a condition object."); } // a `where` that renders to nothing - `{}`, `{ and: [] }`, or a column with no condition - @@ -324,12 +324,12 @@ class StatementBuilder { renderOrderByItem(item) { if ((item == null) || (typeof item !== 'object') || Array.isArray(item)) { - throw new AppSyncUserError("orderBy item expected to be an object."); + throw codeError("orderBy item expected to be an object."); } const { column, dir } = item; if (typeof column !== 'string') { - throw new AppSyncUserError("orderBy item expected to have property column."); + throw codeError("orderBy item expected to have property column."); } // AWS uppercases `dir` and accepts only ASC or DESC. Interpolating it raw would let a caller @@ -337,7 +337,7 @@ class StatementBuilder { // value, null included, means ASC, while an empty string is rejected - matching AWS. const direction = typeof dir !== 'string' ? 'ASC' : dir.toUpperCase(); if ((direction !== 'ASC') && (direction !== 'DESC')) { - throw new AppSyncUserError(`orderBy dir can have either ASC or DESC found ${dir}.`); + throw codeError(`orderBy dir can have either ASC or DESC found ${dir}.`); } return `${this.quoteIdentifier(column)} ${direction}`; @@ -351,7 +351,7 @@ class StatementBuilder { // MySQL has no RETURNING clause and AWS refuses the key outright rather than emitting SQL the // engine would reject if (!this.supportsReturning) { - throw new AppSyncUserError("returning is not supported in MySQL."); + throw codeError("returning is not supported in MySQL."); } return columns; @@ -368,12 +368,12 @@ class StatementBuilder { } if (!Array.isArray(columns)) { - throw new AppSyncUserError("Expected column to be * or an array."); + throw codeError("Expected column to be * or an array."); } return columns.map(name => { if (typeof name !== 'string') { - throw new AppSyncUserError("Invalid type in column array."); + throw codeError("Invalid type in column array."); } return this.quoteIdentifier(name); @@ -383,12 +383,12 @@ class StatementBuilder { resolveValues(properties, verb) { const { values } = properties; if (values == null) { - throw new AppSyncUserError(`values are expected to be passed to ${verb}`); + throw codeError(`values are expected to be passed to ${verb}`); } if ((typeof values !== 'object') || Array.isArray(values)) { // AWS's wording, grammar included - throw new AppSyncUserError("Expected values to an Object."); + throw codeError("Expected values to an Object."); } return values; @@ -401,7 +401,7 @@ class StatementBuilder { renderRowCount(value, keyword) { const count = value === '' ? NaN : Number(value); if (Number.isNaN(count)) { - throw new AppSyncUserError(`${keyword} expects a number.`); + throw codeError(`${keyword} expects a number.`); } return this.newVariable(count); @@ -437,13 +437,13 @@ class StatementBuilder { if ( ["or", "and"].includes(key)) { const ops = key.toUpperCase(); if (!Array.isArray(where[key])) { - throw new AppSyncUserError(`${key} expects conditions to be an array`); + throw codeError(`${key} expects conditions to be an array`); } const parts = where[key].map(part => { // only a condition object is accepted here - unlike a top-level `where`, a nested `sql` // template is refused if ((part == null) || (typeof part !== 'object') || Array.isArray(part) || isSqlTemplate(part)) { - throw new AppSyncUserError(`Expected ${key} to be an Object.`); + throw codeError(`Expected ${key} to be an Object.`); } return this.buildWhereClause(part, "(", ")", ops); @@ -470,7 +470,7 @@ class StatementBuilder { const condition = defn[columnName]; if ((condition == null) || (typeof condition !== 'object') || Array.isArray(condition)) { - throw new AppSyncUserError("Expected condition to be an Object."); + throw codeError("Expected condition to be an Object."); } // several conditions on the same column are ANDed together @@ -508,7 +508,7 @@ class StatementBuilder { default: { const operator = COMPARISON_OPERATORS[conditionType]; if (!operator) { - throw new AppSyncUserError(`Unsupported condition ${path}.`); + throw codeError(`Unsupported condition ${path}.`); } // unlike the same comparison under `size`, a direct comparison against a nullish value is @@ -522,11 +522,11 @@ class StatementBuilder { // AWS uses two distinct messages here: one for a value that is not an array at all, another // for an array of the wrong length if (!Array.isArray(rawValue)) { - throw new AppSyncUserError(`${path} condition expects an array with length of 2.`); + throw codeError(`${path} condition expects an array with length of 2.`); } if (rawValue.length !== 2) { - throw new AppSyncUserError(`${path} condition expects an array with 2 values but received an array with length ${rawValue.length}.`); + throw codeError(`${path} condition expects an array with 2 values but received an array with length ${rawValue.length}.`); } // mapping in order keeps the bound variables numbered low-then-high @@ -536,7 +536,7 @@ class StatementBuilder { buildSizeCondition(column, rawValue, path) { if ((typeof rawValue !== 'object') || (rawValue === null) || Array.isArray(rawValue)) { - throw new AppSyncUserError(`Expected ${path} to be an Object.`); + throw codeError(`Expected ${path} to be an Object.`); } // the comparison runs against the column's length, with the target repeated for each operator. @@ -550,7 +550,7 @@ class StatementBuilder { const comparison = COMPARISON_OPERATORS[operator]; if (!comparison) { - throw new AppSyncUserError(`${path} has invalid size operator.`); + throw codeError(`${path} has invalid size operator.`); } // unlike a direct comparison, a nullish value here is inlined rather than rejected @@ -562,7 +562,7 @@ class StatementBuilder { requireNonNull(value, path) { if (value == null) { - throw new AppSyncUserError(`Value for ${path} can't be null.`); + throw codeError(`Value for ${path} can't be null.`); } return value; @@ -571,7 +571,7 @@ class StatementBuilder { requireString(value, path) { // AWS rejects a non-string for the wildcard conditions instead of coercing it if (typeof this.requireNonNull(value, path) !== 'string') { - throw new AppSyncUserError(`${path} expects a string value to be passed.`); + throw codeError(`${path} expects a string value to be passed.`); } return value; @@ -579,7 +579,7 @@ class StatementBuilder { requireBoolean(value, path) { if (typeof this.requireNonNull(value, path) !== 'boolean') { - throw new AppSyncUserError(`${path} expects a boolean value to be passed.`); + throw codeError(`${path} expects a boolean value to be passed.`); } return value; @@ -591,20 +591,20 @@ class StatementBuilder { // `from` is an alias for `table`, but only in select(): insert/update/remove ignore the key // entirely, and only select() rejects the two being passed together if (allowFrom && (table != null) && (from != null)) { - throw new AppSyncUserError("'from' and 'table' keys cannot be used together"); + throw codeError("'from' and 'table' keys cannot be used together"); } if (allowFrom && (table == null) && (from != null)) { // `from` carries its own type message, and rejects the array `table` would too if ((typeof from !== 'string') && ((typeof from !== 'object') || Array.isArray(from))) { - throw new AppSyncUserError("'from' value is expected to be string or object."); + throw codeError("'from' value is expected to be string or object."); } return this.renderTableName(from); } if (table == null) { - throw new AppSyncUserError("'table' or 'from' key is required."); + throw codeError("'table' or 'from' key is required."); } return this.renderTableName(table); @@ -621,23 +621,23 @@ class StatementBuilder { } if ((typeof name !== 'object') || Array.isArray(name)) { - throw new AppSyncUserError("table name is expected to be a string or alias."); + throw codeError("table name is expected to be a string or alias."); } const entries = Object.entries(name); if (entries.length > 1) { - throw new AppSyncUserError("table alias is allowed only one key-value pair."); + throw codeError("table alias is allowed only one key-value pair."); } if (entries.length === 0) { // an exception AWS's own implementation leaks for an empty alias object, reproduced verbatim // so error handling behaves the same here as it does against AWS - throw new AppSyncUserError("java.util.NoSuchElementException"); + throw codeError("java.util.NoSuchElementException"); } const [alias, actual] = entries[0]; if (typeof actual !== 'string') { - throw new AppSyncUserError("Table alias value is expected to be a string."); + throw codeError("Table alias value is expected to be a string."); } return `${this.quoteIdentifier(actual)} as ${this.quoteIdentifier(alias)}`;