diff --git a/README.md b/README.md index 147359c..1cae8cb 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- 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 - v0.1.1: security updates - v0.1.0: first pinned version of the library diff --git a/__tests__/__snapshots__/resolvers.test.js.snap b/__tests__/__snapshots__/resolvers.test.js.snap index 583227e..cc90cf8 100644 --- a/__tests__/__snapshots__/resolvers.test.js.snap +++ b/__tests__/__snapshots__/resolvers.test.js.snap @@ -73,6 +73,351 @@ exports[`rds resolvers attributeExists true false 1`] = ` } `; +exports[`rds resolvers empty, null and repeated clause inputs attributeExists alongside another condition 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "id" = :P0 AND "id" IS NOT NULL", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs contains wraps the value in wildcards 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" LIKE :P0", + ], + "variableMap": { + ":P0": "%test%", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty column list 1`] = ` +{ + "statements": [ + "SELECT FROM "domain"."color"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty column list in mysql 1`] = ` +{ + "statements": [ + "SELECT FROM \`domain\`.\`color\`", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty column list with limit 1`] = ` +{ + "statements": [ + "SELECT FROM "domain"."color" LIMIT :P0", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty condition object alongside a real condition 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" = :P0 AND", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty condition object for a column 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty nested or group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty object in an and array 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE ("id" = :P0) AND", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty objects in an or array 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE OR", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty orderBy array 1`] = ` +{ + "statements": [ + "SELECT * FROM "domain"."color"", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty orderBy array in mysql 1`] = ` +{ + "statements": [ + "SELECT * FROM \`domain\`.\`color\`", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty orderBy array with limit 1`] = ` +{ + "statements": [ + "SELECT * FROM "domain"."color" LIMIT :P0", + ], + "variableMap": { + ":P0": 3, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty returning array in insert 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (:P0) RETURNING", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty returning array in remove 1`] = ` +{ + "statements": [ + "DELETE FROM "persons" WHERE "id" = :P0 RETURNING", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs empty values object in update 1`] = ` +{ + "statements": [ + "UPDATE "persons" SET WHERE "id" = :P0", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs false and zero values are bound 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("active", "score") VALUES (:P0, :P1)", + ], + "variableMap": { + ":P0": false, + ":P1": 0, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs lowercase orderBy dir is uppercased 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" ORDER BY "name" DESC", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs multiple conditions on one column 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "id" >= :P0 AND "id" <= :P1 AND "id" != :P2", + ], + "variableMap": { + ":P0": 1, + ":P1": 9, + ":P2": 5, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs multiple conditions on one column inside a group 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE ("id" = :P0 AND "id" > :P1) AND ("name" = :P2)", + ], + "variableMap": { + ":P0": 1, + ":P1": 0, + ":P2": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs notContains does not wrap the value 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" WHERE "name" NOT LIKE :P0", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs null alongside a type hint 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("id", "country") VALUES (:P0, NULL)", + ], + "variableMap": { + ":P0": "0e0d0c0b-0a09-0807-0605-040302010000", + }, + "variableTypeHintMap": { + ":P0": "UUID", + }, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs null mixed with bound values in insert 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name", "country") VALUES (:P0, NULL)", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs null orderBy dir defaults to ascending 1`] = ` +{ + "statements": [ + "SELECT * FROM "persons" ORDER BY "name" ASC", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs null value in insert values 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (NULL)", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs null value in update values 1`] = ` +{ + "statements": [ + "UPDATE "persons" SET "country" = NULL WHERE "id" = :P0", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs returning column array in insert 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (:P0) RETURNING "id", "name"", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs returning star in insert 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (:P0) RETURNING *", + ], + "variableMap": { + ":P0": "test", + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs returning star in remove 1`] = ` +{ + "statements": [ + "DELETE FROM "persons" WHERE "id" = :P0 RETURNING *", + ], + "variableMap": { + ":P0": 1, + }, + "variableTypeHintMap": {}, +} +`; + +exports[`rds resolvers empty, null and repeated clause inputs undefined value in insert values 1`] = ` +{ + "statements": [ + "INSERT INTO "persons" ("name") VALUES (NULL)", + ], + "variableMap": {}, + "variableTypeHintMap": {}, +} +`; + exports[`rds resolvers mysql insert 1`] = ` { "statements": [ diff --git a/__tests__/resolvers.test.js b/__tests__/resolvers.test.js index 60d97a3..aad3d07 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'; @@ -1216,6 +1217,181 @@ describe("rds resolvers", () => { await checkResolverValid(code, {}, "request"); }); }); + + describe("empty, null and repeated clause inputs", () => { + // AWS assembles a statement from clause fragments and drops the ones that render to nothing. + // Some of the inputs below make AWS itself emit invalid SQL (a dangling SELECT, SET, RETURNING + // or AND); the snapshots reproduce that byte-for-byte, so a query that breaks in AWS breaks + // identically here rather than silently working. + 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) {} + `; + + // an empty sort list drops the ORDER BY keyword along with its body + test("empty orderBy array", async () => { + await checkResolverValid(pg(`rds.select({ table: "domain.color", orderBy: [] })`), {}, "request"); + }); + + test("empty orderBy array with limit", async () => { + await checkResolverValid(pg(`rds.select({ table: "domain.color", orderBy: [], limit: 3 })`), {}, "request"); + }); + + test("empty orderBy array in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: "domain.color", orderBy: [] })`), {}, "request"); + }); + + // an empty column list leaves the column fragment out entirely: `SELECT FROM ...` + test("empty column list", async () => { + await checkResolverValid(pg(`rds.select({ table: "domain.color", columns: [] })`), {}, "request"); + }); + + test("empty column list with limit", async () => { + await checkResolverValid(pg(`rds.select({ table: "domain.color", columns: [], limit: 3 })`), {}, "request"); + }); + + test("empty column list in mysql", async () => { + await checkResolverValid(mysql(`rds.select({ table: "domain.color", columns: [] })`), {}, "request"); + }); + + test("empty nested or group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ or: [] }] } })`), {}, "request"); + }); + + test("empty condition object for a column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: {} } })`), {}, "request"); + }); + + test("empty condition object alongside a real condition", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { eq: "test" }, id: {} } })`), {}, "request"); + }); + + test("empty object in an and array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ id: { eq: 1 } }, {}] } })`), {}, "request"); + }); + + test("empty objects in an or array", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { or: [{}, {}] } })`), {}, "request"); + }); + + test("empty returning array in insert", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test" }, returning: [] })`), {}, "request"); + }); + + test("empty returning array in remove", async () => { + await checkResolverValid(pg(`rds.remove({ table: "persons", where: { id: { eq: 1 } }, returning: [] })`), {}, "request"); + }); + + test("empty values object in update", async () => { + await checkResolverValid(pg(`rds.update({ table: "persons", values: {}, where: { id: { eq: 1 } } })`), {}, "request"); + }); + + // a nullish value is inlined as a NULL literal rather than bound to a variable + test("null value in insert values", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: null } })`), {}, "request"); + }); + + test("null mixed with bound values in insert", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test", country: null } })`), {}, "request"); + }); + + test("undefined value in insert values", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: undefined } })`), {}, "request"); + }); + + test("null value in update values", async () => { + await checkResolverValid(pg(`rds.update({ table: "persons", values: { country: null }, where: { id: { eq: 1 } } })`), {}, "request"); + }); + + test("null alongside a type hint", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { id: rds.typeHint.UUID("0e0d0c0b-0a09-0807-0605-040302010000"), country: null } })`), {}, "request"); + }); + + // `false` and `0` are ordinary bound values, not nulls + test("false and zero values are bound", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { active: false, score: 0 } })`), {}, "request"); + }); + + // every condition on a column is rendered, not just the first one + test("multiple conditions on one column", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { ge: 1, le: 9, ne: 5 } } })`), {}, "request"); + }); + + test("multiple conditions on one column inside a group", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { and: [{ id: { eq: 1, gt: 0 } }, { name: { eq: "test" } }] } })`), {}, "request"); + }); + + test("attributeExists alongside another condition", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { id: { eq: 1, attributeExists: true } } })`), {}, "request"); + }); + + // returning accepts a column array (quoted) or the bare string `*` + test("returning column array in insert", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test" }, returning: ["id", "name"] })`), {}, "request"); + }); + + test("returning star in insert", async () => { + await checkResolverValid(pg(`rds.insert({ table: "persons", values: { name: "test" }, returning: "*" })`), {}, "request"); + }); + + test("returning star in remove", async () => { + await checkResolverValid(pg(`rds.remove({ table: "persons", where: { id: { eq: 1 } }, returning: "*" })`), {}, "request"); + }); + + // orderBy dir is normalised to upper case; an absent or null dir means ascending + test("lowercase orderBy dir is uppercased", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "name", dir: "desc" }] })`), {}, "request"); + }); + + test("null orderBy dir defaults to ascending", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", orderBy: [{ column: "name", dir: null }] })`), {}, "request"); + }); + + // `contains` is a substring match, so the bound value carries wildcards; `notContains` does + // not wrap its value - AWS does not either + test("contains wraps the value in wildcards", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { contains: "test" } } })`), {}, "request"); + }); + + test("notContains does not wrap the value", async () => { + await checkResolverValid(pg(`rds.select({ table: "persons", where: { name: { notContains: "test" } } })`), {}, "request"); + }); + + // These inputs are rejected outright. AWS reports the same messages prefixed with a source + // position we cannot reproduce locally, so they are asserted directly rather than snapshotted. + 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."); + }); + + 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 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 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."); + }); + }); }); describe("error handling", () => { diff --git a/package.json b/package.json index 40683b2..a803b68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.3", + "version": "0.1.4", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js", diff --git a/rds/index.js b/rds/index.js index a8f5898..5485af8 100644 --- a/rds/index.js +++ b/rds/index.js @@ -93,8 +93,9 @@ export function remove(s) { } class StatementBuilder { - constructor({ quoteChar }) { + constructor({ quoteChar, supportsReturning = true }) { this.quoteChar = quoteChar; + this.supportsReturning = supportsReturning; this.result = { statements: [], variableMap: {}, @@ -146,119 +147,94 @@ class StatementBuilder { this.result.statements.push(stmt); } + /** + * Assemble a statement from its clause fragments the way AWS does: a fragment that renders to + * nothing contributes nothing at all, and the finished statement is right-trimmed. That is what + * turns an empty column list into `SELECT FROM "t"` rather than `SELECT FROM "t"`. AWS emits + * these dangling keywords too - the SQL is invalid on both sides - so they are reproduced + * byte-for-byte instead of being silently repaired into valid-but-different SQL. + */ + joinClauses(parts) { + return parts.filter(part => part !== "" && part != null).join(' ').trimEnd(); + } + renderStructuredStatement(type, properties) { switch (type) { case "SELECT": { const { table, columns, where, orderBy, limit, offset } = properties; - const tableName = this.getTableName(table); - let query; + const parts = ["SELECT"]; if (columns) { - const columnNames = columns.map(name => this.quoteIdentifier(name)).join(', '); - query = `SELECT ${columnNames} FROM ${tableName}`; + parts.push(columns.map(name => this.quoteIdentifier(name)).join(', ')); } else { - query = `SELECT * FROM ${tableName}`; - } - - if (where) { - const parts = this.buildWhereClause(where); - if (parts) { - query = `${query} WHERE ${parts}`; - } + parts.push('*'); } + parts.push(`FROM ${this.getTableName(table)}`); + parts.push(...this.buildWhereParts(where)); - if (orderBy) { - let orderByParts = []; - for (let { column, dir } of orderBy) { - dir = dir || "ASC"; - orderByParts.push(`${this.quoteIdentifier(column)} ${dir}`); - } - - query = `${query} ORDER BY ${orderByParts.join(', ')}`; - - }; + // 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(', ')); + } // limit/offset are optional and may be passed as null; 0 is a valid value if (limit != null) { - const limitValue = this.newVariable(limit); - query = `${query} LIMIT ${limitValue}`; + parts.push(`LIMIT ${this.newVariable(limit)}`); } if (offset != null) { - const offsetValue = this.newVariable(offset); - query = `${query} OFFSET ${offsetValue}`; + parts.push(`OFFSET ${this.newVariable(offset)}`); } - this.result.statements.push(query); + this.result.statements.push(this.joinClauses(parts)); break; } case "REMOVE": { const { table, where, returning, } = properties; - const tableName = this.getTableName(table); + const parts = [`DELETE FROM ${this.getTableName(table)}`]; - let query = `DELETE FROM ${tableName}`; - - if (where) { - const parts = this.buildWhereClause(where); - if (parts) { - query = `${query} WHERE ${parts}`; - } - } + parts.push(...this.buildWhereParts(where)); if (returning) { - const columnNames = returning.map(name => this.quoteIdentifier(name)).join(', '); - query = `${query} RETURNING ${columnNames}`; + parts.push('RETURNING', this.renderReturning(returning)); } - this.result.statements.push(query); + this.result.statements.push(this.joinClauses(parts)); break; } case "INSERT": { const { table, values, returning } = properties; - const tableName = this.getTableName(table); - - let query = `INSERT INTO ${tableName}`; + const parts = [`INSERT INTO ${this.getTableName(table)}`]; let columnTextItems = []; let valuesTextItems = []; for (const [columnName, value] of Object.entries(values)) { columnTextItems.push(this.quoteIdentifier(columnName)); - const placeholder = this.newVariable(value); - valuesTextItems.push(placeholder); + valuesTextItems.push(this.renderValue(value)); } - query = `${query} (${columnTextItems.join(', ')}) VALUES (${valuesTextItems.join(', ')})`; + parts.push(`(${columnTextItems.join(', ')}) VALUES (${valuesTextItems.join(', ')})`); if (returning) { - query = `${query} RETURNING ${returning}`; + parts.push('RETURNING', this.renderReturning(returning)); } - this.result.statements.push(query); + this.result.statements.push(this.joinClauses(parts)); break; } case "UPDATE": { const { table, values, where } = properties; - const tableName = this.getTableName(table); - - let query = `UPDATE ${tableName} SET`; + const parts = [`UPDATE ${this.getTableName(table)}`, 'SET']; let columnDefinitionItems = []; for (const [columnName, value] of Object.entries(values)) { - const placeholder = this.newVariable(value); - columnDefinitionItems.push(`${this.quoteIdentifier(columnName)} = ${placeholder}`); - + columnDefinitionItems.push(`${this.quoteIdentifier(columnName)} = ${this.renderValue(value)}`); } - query = `${query} ${columnDefinitionItems.join(', ')}`; + parts.push(columnDefinitionItems.join(', ')); - if (where) { - const parts = this.buildWhereClause(where); - if (parts) { - query = `${query} WHERE ${parts}`; - } - } - - this.result.statements.push(query); + parts.push(...this.buildWhereParts(where)); + this.result.statements.push(this.joinClauses(parts)); break; } default: @@ -266,6 +242,58 @@ class StatementBuilder { } } + buildWhereParts(where) { + if (!where) { + return []; + } + + // 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 }) { + // 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(); + if ((direction !== 'ASC') && (direction !== 'DESC')) { + throw new Error(`orderBy dir can have either ASC or DESC found ${dir}.`); + } + + return `${this.quoteIdentifier(column)} ${direction}`; + } + + renderReturning(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."); + } + + // AWS accepts either the bare string `*` or an array of column names + if (returning === '*') { + return returning; + } + + if (!Array.isArray(returning)) { + throw new Error('Expected column to be * or an array.'); + } + + return returning.map(name => this.quoteIdentifier(name)).join(', '); + } + + renderValue(value) { + // AWS inlines a NULL literal for a nullish value instead of binding a variable to it. Note + // that `false` and `0` are perfectly valid bound values, hence the nullish and not falsy test. + if (value == null) { + return 'NULL'; + } + + return this.newVariable(value); + } + newVariable(value, addTypeHint = true) { const name = `:P${this.variableIndex}`; if (value.type) { @@ -292,7 +320,12 @@ class StatementBuilder { const parts = where[key].map( part => this.buildWhereClause(part, "(", ")", ops) ); - blocks.push(`${startGrouping}${parts.join(` ${ops} `)}${endGrouping}`); + const group = parts.join(` ${ops} `); + // an `and`/`or` holding no conditions contributes nothing at all: emitting the grouping on + // its own would produce `WHERE ()` + if (group !== "") { + blocks.push(`${startGrouping}${group}${endGrouping}`); + } } else { // implicit single clause const block = {}; @@ -308,32 +341,52 @@ class StatementBuilder { const columnName = Object.keys(defn)[0]; const condition = defn[columnName]; - const conditionType = Object.keys(condition)[0]; + const conditionTypes = Object.keys(condition); + // a column carrying no condition contributes nothing, e.g. an optional filter that the + // resolver left empty + if (conditionTypes.length === 0) { + return ""; + } + + // several conditions on the same column are ANDed together + const statements = conditionTypes.map( + conditionType => this.buildCondition(columnName, condition[conditionType], conditionType) + ); + + return `${startGrouping}${statements.join(" AND ")}${endGrouping}`; + } + + buildCondition(columnName, rawValue, conditionType) { let value; - if (conditionType !== "attributeExists") { - value = this.newVariable(condition[conditionType]); + if (conditionType === "attributeExists") { + value = rawValue; + } else if (conditionType === "contains") { + // AWS binds the value wrapped in wildcards, so `contains` is a real substring match rather + // than an equality test. `notContains` is deliberately left unwrapped - AWS does not wrap it + // either. + value = this.newVariable(`%${rawValue}%`); } else { - value = condition[conditionType]; + value = this.newVariable(rawValue); } switch (conditionType) { case "eq": - return `${startGrouping}${this.quoteIdentifier(columnName)} = ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} = ${value}`; case "ne": - return `${startGrouping}${this.quoteIdentifier(columnName)} != ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} != ${value}`; case "gt": - return `${startGrouping}${this.quoteIdentifier(columnName)} > ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} > ${value}`; case "lt": - return `${startGrouping}${this.quoteIdentifier(columnName)} < ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} < ${value}`; case "ge": - return `${startGrouping}${this.quoteIdentifier(columnName)} >= ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} >= ${value}`; case "le": - return `${startGrouping}${this.quoteIdentifier(columnName)} <= ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} <= ${value}`; case "contains": - return `${startGrouping}${this.quoteIdentifier(columnName)} LIKE ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} LIKE ${value}`; case "notContains": - return `${startGrouping}${this.quoteIdentifier(columnName)} NOT LIKE ${value}${endGrouping}`; + return `${this.quoteIdentifier(columnName)} NOT LIKE ${value}`; case "attributeExists": - return `${startGrouping}${this.quoteIdentifier(columnName)} IS ${value? "NOT " : ""}NULL${endGrouping}`; + return `${this.quoteIdentifier(columnName)} IS ${value? "NOT " : ""}NULL`; default: throw new Error(`Unhandled condition type ${conditionType}`); } @@ -367,6 +420,7 @@ export function createPgStatement(...statements) { export function createMySQLStatement(...statements) { let builder = new StatementBuilder({ quoteChar: '`', + supportsReturning: false, }); return builder.render(statements); }