From 12aa9e96d9724ae62a760c8753799fa29e2bdcd5 Mon Sep 17 00:00:00 2001 From: Gajus Kuizinas Date: Sun, 29 Jul 2018 14:39:32 +0100 Subject: [PATCH] feat: add sql.identifier utility (fixes #9) --- .README/VALUE_PLACEHOLDERS.md | 16 ++++++++++ README.md | 17 ++++++++++ package.json | 30 +++++++++--------- src/index.js | 54 +++++++++++++++++++++++++++---- src/types.js | 8 ++++- test/slonik/sql.js | 60 +++++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 test/slonik/sql.js diff --git a/.README/VALUE_PLACEHOLDERS.md b/.README/VALUE_PLACEHOLDERS.md index 96269527..0ed8f0bf 100644 --- a/.README/VALUE_PLACEHOLDERS.md +++ b/.README/VALUE_PLACEHOLDERS.md @@ -108,6 +108,22 @@ connection.query('INSERT INTO reservation_ticket (reservation_id, ticket_id) VAL ``` +#### Creating dynamic delimited identifiers + +[Delimited identifiers](https://www.postgresql.org/docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS) are created by enclosing an arbitrary sequence of characters in double-quotes ("). To create create a delimited identifier, create an `sql` tag function placeholder value using `sql.identifier`, e.g. + +```js +sql`SELECT ${'foo'} FROM ${sql.identifier(['bar', 'baz'])}`; + +// { +// sql: 'SELECT ? FROM "bar"."baz"', +// values: [ +// 'foo' +// ] +// } + +``` + #### Guarding against accidental unescaped input When using tagged template literals, it is easy to forget to add the `sql` tag, i.e. diff --git a/README.md b/README.md index dd7a8070..3b705671 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,23 @@ connection.query('INSERT INTO reservation_ticket (reservation_id, ticket_id) VAL ``` + +#### Creating dynamic delimited identifiers + +[Delimited identifiers](https://www.postgresql.org/docs/current/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS) are created by enclosing an arbitrary sequence of characters in double-quotes ("). To create create a delimited identifier, create an `sql` tag function placeholder value using `sql.identifier`, e.g. + +```js +sql`SELECT ${'foo'} FROM ${sql.identifier(['bar', 'baz'])}`; + +// { +// sql: 'SELECT ? FROM "bar"."baz"', +// values: [ +// 'foo' +// ] +// } + +``` + #### Guarding against accidental unescaped input diff --git a/package.json b/package.json index 35530a7e..553dd814 100644 --- a/package.json +++ b/package.json @@ -17,30 +17,30 @@ "pg": "^7.4.3", "pg-connection-string": "^2.0.0", "pretty-hrtime": "^1.0.3", - "roarr": "^2.3.0", + "roarr": "^2.5.0", "serialize-error": "^2.1.0", "stack-trace": "0.0.10", "ulid": "^2.3.0" }, "description": "A PostgreSQL client with strict types, detail logging and assertions.", "devDependencies": { - "@babel/cli": "^7.0.0-beta.51", - "@babel/core": "^7.0.0-beta.51", - "@babel/plugin-transform-flow-strip-types": "^7.0.0-beta.51", - "@babel/preset-env": "^7.0.0-beta.51", - "@babel/register": "^7.0.0-beta.51", + "@babel/cli": "^7.0.0-beta.55", + "@babel/core": "^7.0.0-beta.55", + "@babel/plugin-transform-flow-strip-types": "^7.0.0-beta.55", + "@babel/preset-env": "^7.0.0-beta.55", + "@babel/register": "^7.0.0-beta.55", "ava": "^1.0.0-beta.6", - "babel-plugin-istanbul": "^5.0.0", + "babel-plugin-istanbul": "^5.0.1", "coveralls": "^3.0.2", - "eslint": "^5.0.1", - "eslint-config-canonical": "^10.3.2", - "flow-bin": "^0.75.0", - "flow-copy-source": "^2.0.1", + "eslint": "^5.2.0", + "eslint-config-canonical": "^11.0.0", + "flow-bin": "^0.77.0", + "flow-copy-source": "^2.0.2", "gitdown": "^2.5.2", - "husky": "^1.0.0-rc.9", - "nyc": "^13.0.0", - "semantic-release": "^15.6.1", - "sinon": "^6.0.1" + "husky": "^1.0.0-rc.13", + "nyc": "^13.0.1", + "semantic-release": "^15.8.1", + "sinon": "^6.1.4" }, "engines": { "node": ">=8.0" diff --git a/src/index.js b/src/index.js index ad7cbc8e..553c95ac 100644 --- a/src/index.js +++ b/src/index.js @@ -25,6 +25,7 @@ import { UniqueIntegrityConstraintViolationError } from './errors'; import { + escapeIdentifier, mapTaggedTemplateLiteralInvocation, normalizeAnonymousValuePlaceholders, normalizeNamedValuePlaceholders, @@ -46,6 +47,7 @@ import type { InternalQueryOneFirstFunctionType, InternalQueryOneFunctionType, InternalTransactionFunctionType, + QueryIdentifierType, TaggledTemplateLiteralInvocationType } from './types'; import Logger from './Logger'; @@ -81,6 +83,50 @@ const log = Logger.child({ const ulid = ulidFactory(detectPrng(true)); +const sql = (parts: $ReadOnlyArray, ...values: $ReadOnlyArray): TaggledTemplateLiteralInvocationType => { + let raw = ''; + + const bindings = []; + + let index = 0; + + for (const part of parts) { + const value = values[index++]; + + raw += part; + + if (index >= parts.length) { + // eslint-disable-next-line no-continue + continue; + } + + if (value && Array.isArray(value.names) && value.type === 'IDENTIFIER') { + raw += value.names.map(escapeIdentifier).join('.'); + + // eslint-disable-next-line no-continue + continue; + } else { + raw += '?'; + + bindings.push(value); + } + } + + return { + sql: raw, + values: bindings + }; +}; + +sql.identifier = (names: $ReadOnlyArray): QueryIdentifierType => { + // @todo Replace `type` with a symbol once Flow adds symbol support + // @see https://github.com/facebook/flow/issues/810 + return { + names, + type: 'IDENTIFIER' + }; +}; + export type { DatabaseConnectionType, DatabasePoolConnectionType, @@ -96,6 +142,7 @@ export { NotFoundError, NotNullIntegrityConstraintViolationError, SlonikError, + sql, UniqueIntegrityConstraintViolationError }; @@ -444,13 +491,6 @@ export const transaction: InternalTransactionFunctionType = async (connection, h } }; -export const sql = (parts: $ReadOnlyArray, ...values: $ReadOnlyArray): TaggledTemplateLiteralInvocationType => { - return { - sql: parts.join('?'), - values - }; -}; - export const createConnection = async ( connectionConfiguration: DatabaseConfigurationType, clientConfiguration: ClientConfigurationType = defaultClientConfiguration diff --git a/src/types.js b/src/types.js index f0e53b14..4c3fc7ff 100644 --- a/src/types.js +++ b/src/types.js @@ -88,6 +88,11 @@ export type NormalizedQueryType = {| +values: $ReadOnlyArray<*> |}; +export type QueryIdentifierType = {| + names: $ReadOnlyArray, + type: 'IDENTIFIER' +|}; + type QueryPrimitiveValueType = string | number | null; export type AnonymouseValuePlaceholderValueType = @@ -95,7 +100,8 @@ export type AnonymouseValuePlaceholderValueType = // INSERT ... VALUES ? => INSERT ... VALUES (1, 2, 3); [[1, 2, 3]] // INSERT ... VALUES ? => INSERT ... VALUES (1), (2), (3); [[[1], [2], [3]]] $ReadOnlyArray> | - QueryPrimitiveValueType; + QueryPrimitiveValueType | + QueryIdentifierType; export type NamedValuePlaceholderValuesType = { +[key: string]: string | number | null diff --git a/test/slonik/sql.js b/test/slonik/sql.js new file mode 100644 index 00000000..f12f381a --- /dev/null +++ b/test/slonik/sql.js @@ -0,0 +1,60 @@ +// @flow + +import test from 'ava'; +import { + sql +} from '../../src'; + +test('creates an object describing a query', (t) => { + const query = sql`SELECT 1`; + + t.deepEqual(query, { + sql: 'SELECT 1', + values: [] + }); +}); + +test('creates an object describing query value bindings', (t) => { + const query = sql`SELECT ${'foo'}`; + + t.deepEqual(query, { + sql: 'SELECT ?', + values: [ + 'foo' + ] + }); +}); + +test('creates an object describing query value bindings (multiple)', (t) => { + const query = sql`SELECT ${'foo'} FROM ${'bar'}`; + + t.deepEqual(query, { + sql: 'SELECT ? FROM ?', + values: [ + 'foo', + 'bar' + ] + }); +}); + +test('creates an object describing a query with inlined identifiers', (t) => { + const query = sql`SELECT ${'foo'} FROM ${sql.identifier(['bar'])}`; + + t.deepEqual(query, { + sql: 'SELECT ? FROM "bar"', + values: [ + 'foo' + ] + }); +}); + +test('creates an object describing a query with inlined identifiers (specifier)', (t) => { + const query = sql`SELECT ${'foo'} FROM ${sql.identifier(['bar', 'baz'])}`; + + t.deepEqual(query, { + sql: 'SELECT ? FROM "bar"."baz"', + values: [ + 'foo' + ] + }); +});