Skip to content

Commit

Permalink
fix: let node-postgres handle arbitrary values
Browse files Browse the repository at this point in the history
  • Loading branch information
aleclarson committed Sep 7, 2022
1 parent 49cb91c commit d9d1fbd
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 114 deletions.
2 changes: 1 addition & 1 deletion src/database/database.ts
Expand Up @@ -12,7 +12,7 @@ import { PrimaryKey, RowInsertion, RowUpdate, TableRef } from './table'
export type ClientResult = { rows: any[]; rowCount?: number }
export type Client = {
query: {
(query: string): Promise<ClientResult>
(query: string, values: any[]): Promise<ClientResult>
<T>(cursor: QueryStreamCursor): QueryStream<T>
}
end: () => Promise<void>
Expand Down
17 changes: 9 additions & 8 deletions src/database/query.ts
Expand Up @@ -19,16 +19,17 @@ export abstract class Query<
return this.context.nodes[this.position].props as any
}

constructor(parent?: Query | Database | null) {
constructor(parent: Query | Database) {
if (parent instanceof Query) {
// Assume our node will be added next.
this.position = parent.context.nodes.length
this.context = parent.context
} else {
this.position = 0
this.context = {
db: parent || null,
db: parent,
nodes: [],
values: [],
}
}
}
Expand Down Expand Up @@ -89,12 +90,10 @@ export abstract class Query<
// interface will not be awaitable, thus avoiding incomplete queries.
Object.defineProperty(Query.prototype, 'then', {
value: function then(this: Query, onfulfilled?: any, onrejected?: any) {
const { db } = this.context
if (!db) {
throw Error('Query not associated with a database')
}
const { db, values } = this.context
const query = this.render()
return db.client
.query(this.render())
.query(query, values)
.then(
this.resolve ||
(result =>
Expand All @@ -106,8 +105,10 @@ Object.defineProperty(Query.prototype, 'then', {

export namespace Query {
export interface Context {
db: Database | null
db: Database
nodes: Node<Query>[]
/** Not populated until the query is rendered. */
values: any[]
single?: boolean
select?: SelectProps
inArray?: boolean
Expand Down
3 changes: 1 addition & 2 deletions src/database/query/put.ts
Expand Up @@ -35,12 +35,11 @@ export class Put<T extends TableRef = any> extends Query<Props<T>, 'put'> {
{ tuple: [table[kPrimaryKey]] },
'DO UPDATE SET',
{
join: Object.keys(row).map(k => [
list: Object.keys(row).map(k => [
{ id: k },
'=',
{ id: ['excluded', k] },
]),
with: ', ',
}
)
}
Expand Down
7 changes: 4 additions & 3 deletions src/database/query/select.ts
Expand Up @@ -29,16 +29,17 @@ export class Select<From extends Selectable[] = any> //
return this
}

stream(config?: QueryStreamConfig | null) {
const db = this.context.db!
stream(config?: QueryStreamConfig) {
const { db, values } = this.context

const QueryStream = db[kDatabaseQueryStream]
if (!QueryStream)
throw Error(
'pg-query-stream not installed or the generated client is outdated'
)

const query = this.render()
const cursor = new QueryStream(query, undefined, config || undefined)
const cursor = new QueryStream(query, values, config)
return db.client.query<SelectResult<From>>(cursor)
}
}
Expand Down
121 changes: 65 additions & 56 deletions src/database/token.ts
@@ -1,38 +1,41 @@
import { Exclusive } from '@alloc/types'
import type { Database } from './database'
import { Query } from './query'
import { kDatabaseReserved } from './symbols'

/** Format like %I */
export type Identifier = { id: any }
/** Coerce into a string, buffer, or null */
type Value = { value: any }

/** Format like %L */
export type Literal = { literal: any }
/** Format with `%I` like sprintf */
type Identifier = { id: any }

/** Format like %s */
export type Stringify = { string: any }
/** Format with `%L` like sprintf */
type Literal = { literal: any }

/** Coerce into a number, throw if `NaN` */
export type Numeric = { number: any }
type Numeric = { number: any }

/** Join token list with a character */
export type StringJoin = { join: TokenArray; with: string }
/** Join tokens with an empty string */
type Concat = { concat: TokenArray }

/** Join token list with commas and wrap with parentheses */
export type Tuple = { tuple: TokenArray }
/** A comma-separated list */
type List = { list: TokenArray }

export type Call = { callee: string; args?: TokenArray }
/** A comma-separated list with parentheses around it */
type Tuple = { tuple: TokenArray }

export type SubQuery = { query: Query }
type Call = { callee: string; args?: TokenArray }

type SubQuery = { query: Query }

export type Token =
| string
| Exclusive<
| Value
| Identifier
| Literal
| Stringify
| Numeric
| StringJoin
| Concat
| List
| Tuple
| Call
| SubQuery
Expand All @@ -44,37 +47,6 @@ export type TokenProducer<Props extends object | null = any> = (
ctx: Query.Context
) => TokenArray

export function renderToken(token: Token, ctx: Query.Context): string {
return typeof token == 'string'
? token
: 'id' in token
? Array.isArray(token.id)
? token.id.map(mapToIdent, ctx.db).join('.')
: toIdentifier(token.id, ctx.db!)
: 'literal' in token
? toLiteral(token.literal)
: 'string' in token
? toString(token.string)
: 'number' in token
? toNumber(token.number)
: token.join
? token.join
.map(t =>
Array.isArray(t)
? renderTokens(t, ctx).join(' ')
: renderToken(t, ctx)
)
.join(token.with)
: token.callee
? token.callee +
(token.args ? `(${renderTokens(token.args, ctx).join(', ')})` : ``)
: `(${
token.query
? token.query.render()
: renderTokens(token.tuple!, ctx).join(', ')
})`
}

export function renderTokens(
tokens: TokenArray,
ctx: Query.Context,
Expand All @@ -93,8 +65,47 @@ export function renderTokens(
return sql
}

function mapToIdent(this: Database, val: any) {
return toIdentifier(val, this)
function renderToken(token: Token, ctx: Query.Context): string {
return typeof token == 'string'
? token
: 'value' in token
? '$' + ctx.values.push(token.value)
: 'id' in token
? Array.isArray(token.id)
? token.id.map(mapStringToIdentifier, ctx).join('.')
: toIdentifier(token.id, ctx)
: 'callee' in token
? toIdentifier(token.callee, ctx) +
(token.args ? `(${renderTokens(token.args, ctx).join(', ')})` : ``)
: 'literal' in token
? toLiteral(token.literal)
: 'number' in token
? toNumber(token.number)
: token.query
? `(${token.query.render()})`
: renderList(token, ctx)
}

function renderList(
token: Exclusive<List | Concat | Tuple>,
ctx: Query.Context
): string {
const tokens = (token.list || token.concat || token.tuple).map(
mapTokensToSql,
ctx
)
const sql = tokens.join(token.concat ? '' : ', ')
return token.tuple ? `(${sql})` : sql
}

function mapTokensToSql(this: Query.Context, arg: Token | TokenArray) {
return Array.isArray(arg)
? renderTokens(arg, this).join(' ')
: renderToken(arg, this)
}

function mapStringToIdentifier(this: Query.Context, arg: any) {
return toIdentifier(arg, this)
}

// https://github.com/segmentio/pg-escape/blob/780350b461f4f2ab50ca8b5aafcbb57433835f6b/index.js
Expand All @@ -115,17 +126,15 @@ function toLiteral(val: any): string {
)
}

function toIdentifier(val: any, db: Database): string {
const capitalOrSpace = /[A-Z\s]/

function toIdentifier(val: any, { db }: Query.Context): string {
val = String(val).replace(/"/g, '""')
return /[A-Z\s]/.test(val) || db[kDatabaseReserved].includes(val)
? '"' + val + '"'
return capitalOrSpace.test(val) || db[kDatabaseReserved].includes(val)
? `"${val}"`
: val
}

function toString(val: any) {
return val == null ? '' : String(val)
}

function toNumber(val: any) {
const type = typeof val
if (type !== 'number' || isNaN(val) || !isFinite(val)) {
Expand Down
64 changes: 20 additions & 44 deletions src/database/tokenize.ts
Expand Up @@ -18,41 +18,26 @@ import {
/**
* Safely coerce a user-defined value to a SQL token.
*/
export function tokenize(val: any, ctx: Query.Context): Token | TokenArray {
if (val == null || typeof val !== 'object') {
if (typeof val == 'number') {
return String(val)
export function tokenize(value: any, ctx: Query.Context): Token | TokenArray {
if (value == null || typeof value !== 'object') {
if (typeof value == 'number') {
return String(value)
}
return { literal: val }
return { literal: value }
}
if (Array.isArray(val)) {
return tokenizeArray(val, ctx)
}
if (isExpression(val)) {
return tokenizeExpression(val, ctx)
}
if (val instanceof Query) {
// Expressions inherit a query context, but subqueries don't.
const isExpr = val instanceof Expression
const tokens = val.tokenize(isExpr ? ctx : undefined)
return isExpr ? tokens : ['(', tokens, ')']
}
if (val instanceof CheckBuilder) {
return tokenize(val['left'], ctx)
if (!Array.isArray(value) && !Buffer.isBuffer(value)) {
if (isExpression(value)) {
return tokenizeExpression(value, ctx)
}
if (value instanceof Query) {
return { query: value }
}
if (value instanceof CheckBuilder) {
return tokenize(value['left'], ctx)
}
}
throw Error('Value could not be tokenized: ' + val)
}

export function tokenizeArray(val: any[], ctx: Query.Context) {
const { inArray } = ctx
ctx.inArray = true
const tokens: TokenArray = [
inArray ? '[' : 'ARRAY[',
val.map(elem => tokenize(elem, ctx)),
']',
]
ctx.inArray = inArray
return tokens
// Let node-postgres handle the serialization.
return { value }
}

export function tokenizeExpression(expr: Expression, ctx: Query.Context) {
Expand All @@ -66,7 +51,7 @@ export function tokenizeSelectedColumns(
const args = selection[kSelectionArgs]
if (Array.isArray(args)) {
return {
join: args.map(arg => {
list: args.map(arg => {
if (typeof arg == 'string') {
return { id: arg }
}
Expand All @@ -75,7 +60,6 @@ export function tokenizeSelectedColumns(
}
return tokenizeAliasMapping(arg, ctx)
}),
with: ', ',
}
}
if (isExpression(args)) {
Expand Down Expand Up @@ -104,14 +88,7 @@ export function tokenizeCheck(check: Check, ctx: Query.Context) {
// This allows for overriding of operator precedence.
if (isBoolExpression(left)) {
const expr = tokenizeExpression(left, ctx)
tokens.push(
isCallExpression(left)
? expr
: {
join: ['(', expr, ')'],
with: '',
}
)
tokens.push(isCallExpression(left) ? expr : { concat: ['(', expr, ')'] })
}
// Some checks have a check as their left side. (eg AND, OR)
else if (left instanceof Check) {
Expand Down Expand Up @@ -147,7 +124,7 @@ export function tokenizeSelected(
return selections.every(isTableRef)
? '*'
: {
join: selections.map(selection => {
list: selections.map(selection => {
if (isTableRef(selection)) {
return {
id: [selection[kTableName], '*'],
Expand All @@ -158,7 +135,6 @@ export function tokenizeSelected(
}
return tokenizeExpression(selection, ctx)
}),
with: ', ',
}
}

Expand Down

0 comments on commit d9d1fbd

Please sign in to comment.