Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2021-06-01 20:59:40 -05:00
parent 26d54033af
commit 9be9c44a32
138 changed files with 11544 additions and 139 deletions

View File

@@ -0,0 +1,270 @@
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect';
import {Constraint, isConstraintGroup, isConstraintItem, SpecifiedField} from "../types";
import {AbstractBuilder} from "../builder/AbstractBuilder";
/**
* An implementation of the SQLDialect specific to PostgreSQL.
*/
export class PostgreSQLDialect extends SQLDialect {
public escape(value: EscapeValue): QuerySafeValue {
if ( value instanceof QuerySafeValue ) return value
else if ( Array.isArray(value) ) {
return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
} else if ( String(value).toLowerCase() === 'true' || value === true ) {
return new QuerySafeValue(value, 'TRUE')
} else if ( String(value).toLowerCase() === 'false' || value === false ) {
return new QuerySafeValue(value, 'FALSE')
} else if ( typeof value === 'number' ) {
return new QuerySafeValue(value, `${value}`)
} else if ( value instanceof Date ) {
const pad = (val: number) => val < 10 ? `0${val}` : `${val}`
const [y, m, d, h, i, s] = [
`${value.getFullYear()}`,
`${pad(value.getMonth() + 1)}`,
`${pad(value.getDate())}`,
`${pad(value.getHours())}`,
`${pad(value.getMinutes())}`,
`${pad(value.getSeconds())}`,
]
return new QuerySafeValue(value, `${y}-${m}-${d} ${h}:${i}:${s}`)
} else if ( !isNaN(Number(value)) ) {
return new QuerySafeValue(value, String(Number(value)))
} else if ( value === null || typeof value === 'undefined' ) {
return new QuerySafeValue(value, 'NULL')
} else {
const escaped = value.replace(/'/g, '\\\'') //.replace(/"/g, '\\"').replace(/`/g, '\\`')
return new QuerySafeValue(value, `'${escaped}'`)
}
}
public renderCount(query: string): string {
return [
'SELECT COUNT(*) AS "extollo_render_count"',
'FROM (',
...query.split('\n').map(x => ` ${x}`),
') AS extollo_target_query'
].join('\n')
}
public renderRangedSelect(query: string, start: number, end: number): string {
return [
'SELECT *',
'FROM (',
...query.split('\n').map(x => ` ${x}`),
') AS extollo_target_query',
`OFFSET ${start} LIMIT ${(end - start) + 1}`
].join('\n')
}
/** Render the fields from the builder class to PostgreSQL syntax. */
protected renderFields(builder: AbstractBuilder<any>) {
return builder.appliedFields.map((field: SpecifiedField) => {
let columnString: string
if ( typeof field === 'string' ) columnString = field.split('.').map(x => `"${x}"`).join('.')
else if ( field instanceof QuerySafeValue ) columnString = field.toString()
else if ( typeof field.field === 'string' ) columnString = field.field.split('.').map(x => `"${x}"`).join('.')
else columnString = field.field.toString()
let aliasString = ''
if ( typeof field !== 'string' && !(field instanceof QuerySafeValue) ) aliasString = ` AS "${field.alias}"`
return `${columnString}${aliasString}`
})
}
public renderSelect(builder: AbstractBuilder<any>): string {
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
const queryLines = [
`SELECT${builder.appliedDistinction ? ' DISTINCT' : ''}`
]
// Add fields
// FIXME error if no fields
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
queryLines.push(fields)
// Add table source
// FIXME error if no source
const source = builder.querySource
if ( source ) {
const tableString = typeof source === 'string' ? source : source.table
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
queryLines.push('FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
}
// Add constraints
const wheres = this.renderConstraints(builder.appliedConstraints)
if ( wheres.trim() ) {
queryLines.push('WHERE')
queryLines.push(wheres)
}
// Add group by
if ( builder.appliedGroupings?.length ) {
const grouping = builder.appliedGroupings.map(group => {
return indent(group.split('.').map(x => `"${x}"`).join('.'))
}).join(',\n')
queryLines.push('GROUP BY')
queryLines.push(grouping)
}
// Add order by
if ( builder.appliedOrder?.length ) {
const ordering = builder.appliedOrder.map(x => indent(`${x.field.split('.').map(x => '"' + x + '"').join('.')} ${x.direction}`)).join(',\n')
queryLines.push('ORDER BY')
queryLines.push(ordering)
}
// Add limit/offset
const pagination = builder.appliedPagination
if ( pagination.take ) {
queryLines.push(`LIMIT ${pagination.take}${pagination.skip ? ' OFFSET ' + pagination.skip : ''}`)
} else if ( pagination.skip ) {
queryLines.push(`OFFSET ${pagination.skip}`)
}
return queryLines.join('\n')
}
// TODO support FROM, RETURNING
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
const queryLines: string[] = []
// Add table source
const source = builder.querySource
if ( source ) {
const tableString = typeof source === 'string' ? source : source.table
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
queryLines.push('UPDATE ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
}
queryLines.push(this.renderUpdateSet(data))
// Add constraints
const wheres = this.renderConstraints(builder.appliedConstraints)
if ( wheres.trim() ) {
queryLines.push('WHERE')
queryLines.push(wheres)
}
return queryLines.join('\n')
}
public renderExistential(builder: AbstractBuilder<any>): string {
const query = builder.clone()
.clearFields()
.field(raw('TRUE'))
.limit(1)
return this.renderSelect(query)
}
// FIXME: subquery support here and with select
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
const queryLines: string[] = []
if ( !Array.isArray(data) ) data = [data]
const columns = Object.keys(data[0])
// Add table source
const source = builder.querySource
if ( source ) {
const tableString = typeof source === 'string' ? source : source.table
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
queryLines.push('INSERT INTO ' + (typeof source === 'string' ? table : `${table} AS "${source.alias}"`)
+ (columns.length ? ` (${columns.join(', ')})` : ''))
}
if ( Array.isArray(data) && !data.length ) {
queryLines.push('DEFAULT VALUES')
} else {
queryLines.push('VALUES')
const valueString = data.map(row => {
const values = columns.map(x => this.escape(row[x]))
return indent(`(${values.join(', ')})`)
})
.join(',\n')
queryLines.push(valueString)
}
// Add return fields
if ( builder.appliedFields?.length ) {
queryLines.push('RETURNING')
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
queryLines.push(fields)
}
return queryLines.join('\n')
}
public renderDelete(builder: AbstractBuilder<any>): string {
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
const queryLines: string[] = []
// Add table source
const source = builder.querySource
if ( source ) {
const tableString = typeof source === 'string' ? source : source.table
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
queryLines.push('DELETE FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
}
// Add constraints
const wheres = this.renderConstraints(builder.appliedConstraints)
if ( wheres.trim() ) {
queryLines.push('WHERE')
queryLines.push(wheres)
}
// Add return fields
if ( builder.appliedFields?.length ) {
queryLines.push('RETURNING')
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
queryLines.push(fields)
}
return queryLines.join('\n')
}
public renderConstraints(constraints: Constraint[]): string {
const constraintsToSql = (constraints: Constraint[], level = 1): string => {
const indent = Array(level * 2).fill(' ').join('')
let statements = []
for ( const constraint of constraints ) {
if ( isConstraintGroup(constraint) ) {
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
} else if ( isConstraintItem(constraint) ) {
const field: string = constraint.field.split('.').map(x => `"${x}"`).join('.')
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
}
}
return statements.filter(Boolean).join('\n')
}
return constraintsToSql(constraints)
}
public renderUpdateSet(data: {[key: string]: EscapeValue}) {
const sets = []
for ( const key in data ) {
if ( !data.hasOwnProperty(key) ) continue
sets.push(` "${key}" = ${this.escape(data[key])}`)
}
return ['SET', ...sets].join('\n')
}
}

View File

@@ -0,0 +1,169 @@
import {Constraint} from "../types";
import {AbstractBuilder} from "../builder/AbstractBuilder";
import {AppClass} from "../../lifecycle/AppClass";
/**
* A value which can be escaped to be interpolated into an SQL query.
*/
export type EscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue | EscapeValue[] // FIXME | Select<any>
/**
* Object mapping string field names to EscapeValue items.
*/
export type EscapeValueObject = { [field: string]: EscapeValue }
/**
* A wrapper class whose value is save to inject directly into a query.
*/
export class QuerySafeValue {
constructor(
/** The unescaped value. */
public readonly originalValue: any,
/** The query-safe sanitized value. */
public readonly value: any,
) { }
/** Cast the value to a query-safe string. */
toString() {
return this.value
}
}
/**
* Treat the value as raw SQL that can be injected directly into a query.
* This is dangerous and should NEVER be used to wrap user input.
* @param value
*/
export function raw(value: any) {
return new QuerySafeValue(value, value)
}
/**
* Abstract class defining a particular dialect of SQL that is used to render
* query builders to strings of SQL of that dialect for execution by Connection
* instances.
*/
export abstract class SQLDialect extends AppClass {
/**
* Escape the given value and return the query-safe equivalent.
* @param value
*/
public abstract escape(value: EscapeValue): QuerySafeValue
/**
* Render the given query builder as a "SELECT ..." query string.
*
* This function should escape the values before they are included in the query string.
* @param builder
*/
public abstract renderSelect(builder: AbstractBuilder<any>): string;
/**
* Render the given query builder as an "UPDATE ..." query string, setting the
* column values from the given data object.
*
* This function should escape the values before they are included in the query string.
* @param builder
* @param data
*/
public abstract renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string;
/**
* Render the given query builder as a "DELETE ..." query string.
*
* This function should escape the values before they are included in the query string.
* @param builder
*/
public abstract renderDelete(builder: AbstractBuilder<any>): string;
/**
* Render the given query builder as a query that can be used to test if at
* least 1 row exists for the given builder.
*
* The resultant query should return at least 1 row if that condition is met,
* and should return NO rows otherwise.
*
* This function should escape the values before they are included in the query string.
*
* @example
* The PostgreSQL dialect achieves this by removing the user-specified fields,
* select-ing `TRUE`, and applying `LIMIT 1` to the query. This returns a single
* row if the constraints have results, and nothing otherwise.
*
* @param builder
*/
public abstract renderExistential(builder: AbstractBuilder<any>): string;
/**
* Render the given query as an "INSERT ..." query string, inserting rows for
* the given data object(s).
*
* This function should escape the values before they are included in the query string.
*
* @param builder
* @param data
*/
public abstract renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[]): string;
/**
* Wrap the given query string as a "SELECT ..." query that returns the number of
* rows matched by the original query string.
*
* The resultant query should return the `extollo_render_count` field with the
* number of rows that the original `query` would return.
*
* @param query
*/
public abstract renderCount(query: string): string;
/**
* Given a rendered "SELECT ..." query string, wrap it such that the query will
* only return the rows ranging from the `start` to `end` indices.
*
* @param query
* @param start
* @param end
*/
public abstract renderRangedSelect(query: string, start: number, end: number): string;
/**
* Given an array of Constraint objects, render them as WHERE-clause SQL in this dialect.
*
* This function should escape the values before they are included in the query string.
*
* @example
* ```ts
* dialect.renderConstraints([
* {
* field: 'id',
* operator: '<',
* operand: 44,
* preop: 'AND',
* },
* {
* field: 'id',
* operator: '>',
* operand: 30,
* preop: 'AND',
* },
* ]) // => 'id < 44 AND id > 30'
* ```
*
* @param constraints
*/
public abstract renderConstraints(constraints: Constraint[]): string;
/**
* Render the "SET ... [field = value ...]" portion of the update query.
*
* This function should escape the values before they are included in the query string.
*
* @example
* dialect.renderUpdateSet({field1: 'value', field2: 45})
* // => "SET field1 = 'value', field2 = 45"
*
* @param data
*/
public abstract renderUpdateSet(data: {[key: string]: EscapeValue}): string;
}