diff --git a/README.md b/README.md index 915de71d..3ba884a9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A PostgreSQL client with strict types and assertions. * [Value placeholders](#value-placeholders) * [A value set](#a-value-set) * [Multiple value sets](#multiple-value-sets) + * [Named placeholders](#named-placeholders) * [Convenience methods](#convenience-methods) * [`any`](#any) * [`insert`](#insert) @@ -108,6 +109,24 @@ SELECT ($1, $2, $3), ($4, $5, $6) ``` +### Named placeholders + +A `:[a-zA-Z]` regex is used to match named placeholders. + +```js +await connection.query('SELECT :foo', { + foo: 'FOO' +}); + +``` + +Produces: + +```sql +SELECT $1 + +``` + ## Convenience methods ### `any` diff --git a/src/index.js b/src/index.js index 9b4362f2..aa8f7ef5 100644 --- a/src/index.js +++ b/src/index.js @@ -8,14 +8,14 @@ import { } from 'pg-connection-string'; import createDebug from 'debug'; import prettyHrtime from 'pretty-hrtime'; -import arrayFlatten from 'array-flatten'; import { DataIntegrityError, DuplicateEntryError, NotFoundError } from './errors'; import { - normalizeValuePlaceholders + normalizeAnonymousValuePlaceholders, + normalizeNamedValuePlaceholders } from './utilities'; import type { DatabaseConfigurationType, @@ -45,17 +45,35 @@ types.setTypeParser(20, (value) => { const debug = createDebug('mightyql'); -export const query: InternalQueryType = async (connection, sql, values = []) => { +export const query: InternalQueryType = async (connection, sql, values) => { debug('query input', sql, values); try { const start = process.hrtime(); - const normalizedQuery = normalizeValuePlaceholders(sql, values); + let result; - debug('normlized query', normalizedQuery); + if (Array.isArray(values)) { + const { + sql: normalizedSql, + values: normalizedValues + } = normalizeAnonymousValuePlaceholders(sql, values); - const result = await connection.query(normalizedQuery, arrayFlatten(values)); + debug('normlized SQL', normalizedSql); + + result = await connection.query(normalizedSql, normalizedValues); + } else if (values) { + const { + sql: normalizedSql, + values: normalizedValues + } = normalizeNamedValuePlaceholders(sql, values); + + debug('normlized SQL', normalizedSql); + + result = await connection.query(normalizedSql, normalizedValues); + } else { + result = await connection.query(sql); + } const end = process.hrtime(start); diff --git a/src/types.js b/src/types.js index 45d93967..ef35c0da 100644 --- a/src/types.js +++ b/src/types.js @@ -35,17 +35,29 @@ export type QueryResultRowType = { [key: string]: string | number }; +export type NormalizedQueryType = {| + +sql: string, + +values: $ReadOnlyArray<*> +|}; + type QueryPrimitiveValueType = string | number | null; -export type DatabaseQueryValuesType = - // eslint-disable-next-line - $ReadOnlyArray< +// eslint-disable-next-line flowtype/generic-spacing +export type AnonymouseValuePlaceholderValuesType = $ReadOnlyArray< // INSERT ... VALUES ? => INSERT ... VALUES (1, 2, 3); [[1, 2, 3]] // INSERT ... VALUES ? => INSERT ... VALUES (1), (2), (3); [[[1], [2], [3]]] $ReadOnlyArray> | QueryPrimitiveValueType - >; + >; + +export type NamedValuePlaceholderValuesType = { + +[key: string]: string | number | null +}; + +export type DatabaseQueryValuesType = + AnonymouseValuePlaceholderValuesType | + NamedValuePlaceholderValuesType; // eslint-disable-next-line flowtype/no-weak-types export type InternalQueryType = (connection: InternalDatabaseConnectionType, sql: string, values?: DatabaseQueryValuesType) => Promise; diff --git a/src/utilities/index.js b/src/utilities/index.js index b2062f3b..955598d6 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -1,3 +1,4 @@ // @flow -export {default as normalizeValuePlaceholders} from './normalizeValuePlaceholders'; +export {default as normalizeAnonymousValuePlaceholders} from './normalizeAnonymousValuePlaceholders'; +export {default as normalizeNamedValuePlaceholders} from './normalizeNamedValuePlaceholders'; diff --git a/src/utilities/normalizeValuePlaceholders.js b/src/utilities/normalizeAnonymousValuePlaceholders.js similarity index 76% rename from src/utilities/normalizeValuePlaceholders.js rename to src/utilities/normalizeAnonymousValuePlaceholders.js index 8ec4f8e4..7dd2c99d 100644 --- a/src/utilities/normalizeValuePlaceholders.js +++ b/src/utilities/normalizeAnonymousValuePlaceholders.js @@ -1,18 +1,20 @@ // @flow +import arrayFlatten from 'array-flatten'; import type { - DatabaseQueryValuesType + AnonymouseValuePlaceholderValuesType, + NormalizedQueryType } from '../types'; -const placeholdersRegex = /\?/g; +const anonymousePlaceholdersRegex = /\?/g; /** * @see https://github.com/mysqljs/sqlstring/blob/f946198800a8d7f198fcf98d8bb80620595d01ec/lib/SqlString.js#L73 */ export default ( sql: string, - values: DatabaseQueryValuesType = [] -): string => { + values: AnonymouseValuePlaceholderValuesType = [] +): NormalizedQueryType => { let chunkIndex = 0; let result = ''; let match; @@ -20,7 +22,7 @@ export default ( let placeholderIndex = 0; // eslint-disable-next-line no-cond-assign - while (match = placeholdersRegex.exec(sql)) { + while (match = anonymousePlaceholdersRegex.exec(sql)) { if (!values.hasOwnProperty(valueIndex)) { throw new Error('Value placeholder is missing a value.'); } @@ -30,7 +32,7 @@ export default ( valueIndex++; result += sql.slice(chunkIndex, match.index); - chunkIndex = placeholdersRegex.lastIndex; + chunkIndex = anonymousePlaceholdersRegex.lastIndex; // SELECT ?, [[[1,1],[1,1]]]; SELECT ($1, $2), ($3, $4) if (Array.isArray(value) && Array.isArray(value[0])) { @@ -75,12 +77,13 @@ export default ( } if (chunkIndex === 0) { - return sql; + result = sql; + } else if (chunkIndex < sql.length) { + result += sql.slice(chunkIndex); } - if (chunkIndex < sql.length) { - return result + sql.slice(chunkIndex); - } - - return result; + return { + sql: result, + values: arrayFlatten(values) + }; }; diff --git a/src/utilities/normalizeNamedValuePlaceholders.js b/src/utilities/normalizeNamedValuePlaceholders.js new file mode 100644 index 00000000..3be6a72e --- /dev/null +++ b/src/utilities/normalizeNamedValuePlaceholders.js @@ -0,0 +1,59 @@ +// @flow + +import type { + NamedValuePlaceholderValuesType, + NormalizedQueryType +} from '../types'; + +/** + * @see https://regex101.com/r/KrEe8i/1 + */ +const namedPlaceholderRegex = /[\s,(]:([a-zA-Z]+)/g; + +/** + * @see https://github.com/mysqljs/sqlstring/blob/f946198800a8d7f198fcf98d8bb80620595d01ec/lib/SqlString.js#L73 + */ +export default ( + sql: string, + values: NamedValuePlaceholderValuesType = {} +): NormalizedQueryType => { + let chunkIndex = 0; + let result = ''; + let match; + let placeholderIndex = 0; + + const normalizedValues = []; + + // eslint-disable-next-line no-cond-assign + while (match = namedPlaceholderRegex.exec(sql)) { + const matchIndex = match.index + 1; + const matchName = match[1]; + + if (!values.hasOwnProperty(matchName)) { + throw new Error('Value placeholder is missing a value.'); + } + + const value = values[matchName]; + + normalizedValues.push(value); + + result += sql.slice(chunkIndex, matchIndex); + + chunkIndex = namedPlaceholderRegex.lastIndex; + + ++placeholderIndex; + + result += '$' + placeholderIndex; + } + + if (chunkIndex === 0) { + result = sql; + } else if (chunkIndex < sql.length) { + result += sql.slice(chunkIndex); + } + + return { + sql: result, + values: normalizedValues + }; +}; diff --git a/test/utilities/normalizeAnonymousValuePlaceholders.js b/test/utilities/normalizeAnonymousValuePlaceholders.js new file mode 100644 index 00000000..0ff7fca7 --- /dev/null +++ b/test/utilities/normalizeAnonymousValuePlaceholders.js @@ -0,0 +1,90 @@ +// @flow + +import test from 'ava'; +import { + normalizeAnonymousValuePlaceholders +} from '../../src/utilities'; + +test('does not error when placeholders are absent', (t) => { + const { + sql, + values + } = normalizeAnonymousValuePlaceholders('SELECT 1'); + + t.true(sql === 'SELECT 1'); + t.deepEqual(values, []); +}); + +test('interpolates a value placeholder', (t) => { + const { + sql, + values + } = normalizeAnonymousValuePlaceholders('SELECT ?', [ + 'foo' + ]); + + t.true(sql === 'SELECT $1'); + t.deepEqual(values, [ + 'foo' + ]); +}); + +test('interpolates multiple value placeholders', (t) => { + const { + sql, + values + } = normalizeAnonymousValuePlaceholders('SELECT ?, ?', [ + 'foo', + 'bar' + ]); + + t.true(sql === 'SELECT $1, $2'); + t.deepEqual(values, [ + 'foo', + 'bar' + ]); +}); + +test('interpolates a value set', (t) => { + const { + sql, + values + } = normalizeAnonymousValuePlaceholders('SELECT ?', [ + [ + 'foo', + 'bar' + ] + ]); + + t.true(sql === 'SELECT ($1, $2)'); + t.deepEqual(values, [ + 'foo', + 'bar' + ]); +}); + +test('interpolates a list of value sets', (t) => { + const { + sql, + values + } = normalizeAnonymousValuePlaceholders('SELECT ?', [ + [ + [ + 'foo', + 'bar' + ], + [ + 'baz', + 'qux' + ] + ] + ]); + + t.true(sql === 'SELECT ($1, $2), ($3, $4)'); + t.deepEqual(values, [ + 'foo', + 'bar', + 'baz', + 'qux' + ]); +}); diff --git a/test/utilities/normalizeNamedValuePlaceholders.js b/test/utilities/normalizeNamedValuePlaceholders.js new file mode 100644 index 00000000..877c82af --- /dev/null +++ b/test/utilities/normalizeNamedValuePlaceholders.js @@ -0,0 +1,61 @@ +// @flow + +import test from 'ava'; +import { + normalizeNamedValuePlaceholders +} from '../../src/utilities'; + +test('does not error when placeholders are absent', (t) => { + const { + sql, + values + } = normalizeNamedValuePlaceholders('SELECT 1'); + + t.true(sql === 'SELECT 1'); + t.deepEqual(values, []); +}); + +test('interpolates a value placeholder', (t) => { + const { + sql, + values + } = normalizeNamedValuePlaceholders('SELECT :foo', { + foo: 'FOO' + }); + + t.true(sql === 'SELECT $1'); + t.deepEqual(values, [ + 'FOO' + ]); +}); + +test('interpolates multiple value placeholders', (t) => { + const { + sql, + values + } = normalizeNamedValuePlaceholders('SELECT :foo, :bar', { + bar: 'BAR', + foo: 'FOO' + }); + + t.true(sql === 'SELECT $1, $2'); + t.deepEqual(values, [ + 'FOO', + 'BAR' + ]); +}); + +test('interpolates multiple value placeholders (same value)', (t) => { + const { + sql, + values + } = normalizeNamedValuePlaceholders('SELECT :foo, :foo', { + foo: 'FOO' + }); + + t.true(sql === 'SELECT $1, $2'); + t.deepEqual(values, [ + 'FOO', + 'FOO' + ]); +}); diff --git a/test/utilities/normalizeValuePlaceholders.js b/test/utilities/normalizeValuePlaceholders.js deleted file mode 100644 index a47f7d9a..00000000 --- a/test/utilities/normalizeValuePlaceholders.js +++ /dev/null @@ -1,59 +0,0 @@ -// @flow - -/* eslint-disable flowtype/no-weak-types */ - -import test from 'ava'; -import { - normalizeValuePlaceholders -} from '../../src/utilities'; - -test('does not error when placeholders are absent', (t) => { - const formattedSql = normalizeValuePlaceholders('SELECT 1', []); - - t.true(formattedSql === 'SELECT 1'); -}); - -test('interpolates a value placeholder', (t) => { - const formattedSql = normalizeValuePlaceholders('SELECT ?', [ - 1 - ]); - - t.true(formattedSql === 'SELECT $1'); -}); - -test('interpolates multiple value placeholders', (t) => { - const formattedSql = normalizeValuePlaceholders('SELECT ?, ?', [ - 1, - 2 - ]); - - t.true(formattedSql === 'SELECT $1, $2'); -}); - -test('interpolates a value set', (t) => { - const formattedSql = normalizeValuePlaceholders('SELECT ?', [ - [ - 1, - 2 - ] - ]); - - t.true(formattedSql === 'SELECT ($1, $2)'); -}); - -test('interpolates a list of value sets', (t) => { - const formattedSql = normalizeValuePlaceholders('SELECT ?', [ - [ - [ - 1, - 2 - ], - [ - 3, - 4 - ] - ] - ]); - - t.true(formattedSql === 'SELECT ($1, $2), ($3, $4)'); -});