Experimental SQLite support

This commit is contained in:
2022-09-30 11:42:13 -05:00
parent c0595f3ef9
commit 52762bd4a1
16 changed files with 1401 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
import {DatabaseService, FieldType, Migration, raw, Schema} from '../orm'
import {DatabaseService, FieldType, Migration, Schema} from '../orm'
import {Inject} from '../di'
export default class CreateOAuth2TokensTableMigration extends Migration {
@@ -6,8 +6,8 @@ export default class CreateOAuth2TokensTableMigration extends Migration {
protected readonly db!: DatabaseService
async up(): Promise<void> {
const schema: Schema = this.db.get().schema()
const table = await schema.table('oauth2_tokens')
const db = this.db.get()
const table = await db.schema().table('oauth2_tokens')
table.primaryKey('oauth2_token_id').required()
@@ -21,7 +21,7 @@ export default class CreateOAuth2TokensTableMigration extends Migration {
table.column('issued')
.type(FieldType.timestamp)
.default(raw('NOW()'))
.default(db.dialect().currentTimestamp())
.required()
table.column('expires')
@@ -32,7 +32,7 @@ export default class CreateOAuth2TokensTableMigration extends Migration {
.type(FieldType.varchar)
.nullable()
await schema.commit(table)
await db.schema().commit(table)
}
async down(): Promise<void> {

View File

@@ -144,7 +144,7 @@ export abstract class AbstractBuilder<T> extends AppClass {
* @param table
* @param alias
*/
from(table: string, alias?: string): this {
from(table: string|QuerySafeValue, alias?: string): this {
if ( alias ) {
this.source = { table,
alias }

View File

@@ -1,11 +1,12 @@
import {Awaitable, ErrorWithContext} from '../../util'
import {QueryResult} from '../types'
import {Awaitable, Collection, ErrorWithContext} from '../../util'
import {QueryResult, QueryRow} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Inject, Injectable} from '../../di'
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
import {Schema} from '../schema/Schema'
import {Bus} from '../../support/bus'
import {ModelField} from '../model/Field'
/**
* Error thrown when a connection is used before it is ready.
@@ -75,6 +76,17 @@ export abstract class Connection extends AppClass {
*/
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
/**
* Normalize a query row before it is used by the framework.
* This helps account for differences in return values from the dialects.
* @param row
* @param fields
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public normalizeRow(row: QueryRow, fields: Collection<ModelField>): QueryRow {
return row
}
/**
* Fire a QueryExecutedEvent for the given query string.
* @param query

View File

@@ -80,9 +80,21 @@ export class PostgresConnection extends Connection {
}
await this.client.query('BEGIN')
const result = await closure()
await this.client.query('COMMIT')
return result
try {
const result = await closure()
await this.client.query('COMMIT')
return result
} catch (e) {
await this.client.query('ROLLBACK')
if ( e instanceof Error ) {
throw this.app().errorWrapContext(e, {
connection: this.name,
})
}
throw e
}
}
public schema(name?: string): Schema {

View File

@@ -0,0 +1,117 @@
import {Connection, ConnectionNotReadyError} from './Connection'
import {Logging} from '../../service/Logging'
import {Inject} from '../../di'
import {open, Database} from 'sqlite'
import {FieldType, QueryResult, QueryRow} from '../types'
import {Schema} from '../schema/Schema'
import {Awaitable, collect, Collection, hasOwnProperty, UniversalPath} from '../../util'
import {SQLDialect} from '../dialect/SQLDialect'
import {SQLiteDialect} from '../dialect/SQLiteDialect'
import {SQLiteSchema} from '../schema/SQLiteSchema'
import * as sqlite3 from 'sqlite3'
import {ModelField} from '../model/Field'
export interface SQLiteConnectionConfig {
filename: string,
}
export class SQLiteConnection extends Connection {
@Inject()
protected readonly logging!: Logging
protected client?: Database
public dialect(): SQLDialect {
return this.make(SQLiteDialect)
}
public async init(): Promise<void> {
if ( this.config?.filename instanceof UniversalPath ) {
this.config.filename = this.config.filename.toLocal
}
this.logging.debug(`Opening SQLite connection ${this.name} (${this.config?.filename})...`)
this.client = await open({
...this.config,
driver: sqlite3.Database,
})
}
public async close(): Promise<void> {
this.logging.debug(`Closing SQLite connection ${this.name}...`)
if ( this.client ) {
await this.client.close()
}
}
public async query(query: string): Promise<QueryResult> {
if ( !this.client ) {
throw new ConnectionNotReadyError(this.name, {
config: this.config,
})
}
this.logging.verbose(`Executing query in connection ${this.name}: \n${query.split('\n').map(x => ' ' + x)
.join('\n')}`)
try {
const result = await this.client.all(query) // FIXME: this probably won't work for non-select statements?
await this.queryExecuted(query)
return {
rows: collect(result),
rowCount: result.length,
}
} catch (e) {
if ( e instanceof Error ) {
throw this.app().errorWrapContext(e, {
query,
connection: this.name,
})
}
throw e
}
}
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
if ( !this.client ) {
throw new ConnectionNotReadyError(this.name, {
config: this.config,
})
}
// fixme: sqlite doesn't support tx's properly in node atm
await this.client.run('BEGIN')
try {
const result = await closure()
await this.client.run('COMMIT')
return result
} catch (e) {
await this.client.run('ROLLBACK')
if ( e instanceof Error ) {
throw this.app().errorWrapContext(e, {
connection: this.name,
})
}
throw e
}
}
public normalizeRow(row: QueryRow, fields: Collection<ModelField>): QueryRow {
fields.where('type', '=', FieldType.json)
.pluck('databaseKey')
.filter(key => hasOwnProperty(row, key))
.filter(key => typeof row[key] === 'string')
.each(key => row[key] = JSON.parse(row[key]))
return row
}
public schema(): Schema {
return new SQLiteSchema(this)
}
}

View File

@@ -1,5 +1,5 @@
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, SpecifiedField} from '../types'
import {Constraint, inverseFieldType, isConstraintGroup, isConstraintItem, QuerySource, SpecifiedField} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
import {collect, Collectable, Collection, ErrorWithContext, hasOwnProperty, Maybe} from '../../util'
@@ -44,6 +44,19 @@ export class PostgreSQLDialect extends SQLDialect {
}
}
public renderQuerySource(source: QuerySource): string {
if ( source instanceof QuerySafeValue ) {
return String(source)
} else if ( typeof source === 'string' ) {
return source.replace(/"/g, '""')
.split('.')
.map(x => '"' + x + '"')
.join('.')
}
return `${this.renderQuerySource(source.table)} AS "${source.alias.replace(/"/g, '""')}"`
}
public renderCount(query: string): string {
return [
'SELECT COUNT(*) AS "extollo_render_count"',
@@ -59,7 +72,7 @@ export class PostgreSQLDialect extends SQLDialect {
'FROM (',
...query.split('\n').map(x => ` ${x}`),
') AS extollo_target_query',
`OFFSET ${start} LIMIT ${(end - start) + 1}`, // FIXME - the +1 is only needed when start === end
`OFFSET ${start} LIMIT ${start === end ? ((end - start) + 1) : (end - start)}`,
].join('\n')
}
@@ -111,10 +124,7 @@ export class PostgreSQLDialect extends SQLDialect {
// 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}"`))
queryLines.push('FROM ' + this.renderQuerySource(source))
}
// Add constraints
@@ -164,22 +174,20 @@ export class PostgreSQLDialect extends SQLDialect {
const queryLines: string[] = []
// Add table source
const source = builder.querySource
let sourceAlias = 'extollo_update_source'
let source = builder.querySource
if ( !source ) {
throw new ErrorWithContext('No table specified for update query')
}
const tableString = typeof source === 'string' ? source : source.table
const table: string = tableString.split('.')
.map(x => `"${x}"`)
.join('.')
if ( typeof source !== 'string' && source.alias ) {
sourceAlias = source.alias
source = (typeof source !== 'string' && !(source instanceof QuerySafeValue)) ? source : {
table: source,
alias: 'extollo_update_source',
}
queryLines.push('UPDATE ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
const sourceAlias = source.alias
const sourceTable = source.table
queryLines.push('UPDATE ' + this.renderQuerySource(source))
queryLines.push('SET')
const updateFields = this.getAllFieldsFromUpdateRows(rows)
@@ -192,7 +200,7 @@ export class PostgreSQLDialect extends SQLDialect {
// FIXME: This is fairly inefficient. Probably a better way with a FROM ... SELECT
// return raw(`"${sourceAlias}"."${field}"`)
return raw(`(SELECT "${field}" FROM ${table} WHERE "${primaryKey}" = ${this.escape(row[primaryKey])})`)
return raw(`(SELECT "${field}" FROM ${sourceTable} WHERE "${primaryKey}" = ${this.escape(row[primaryKey])})`)
})
})
@@ -238,10 +246,7 @@ export class PostgreSQLDialect extends SQLDialect {
// 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('UPDATE ' + this.renderQuerySource(source))
}
queryLines.push(this.renderUpdateSet(data))
@@ -306,10 +311,7 @@ export class PostgreSQLDialect extends SQLDialect {
// 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}"`)
queryLines.push('INSERT INTO ' + this.renderQuerySource(source)
+ (columns.length ? ` (${columns.map(x => `"${x}"`).join(', ')})` : ''))
}
@@ -352,10 +354,7 @@ export class PostgreSQLDialect extends SQLDialect {
// 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}"`))
queryLines.push('DELETE FROM ' + this.renderQuerySource(source))
}
// Add constraints
@@ -662,4 +661,8 @@ export class PostgreSQLDialect extends SQLDialect {
return parts.join('\n')
}
public currentTimestamp(): QuerySafeValue {
return raw('NOW()')
}
}

View File

@@ -1,4 +1,4 @@
import {Constraint} from '../types'
import {Constraint, QuerySource} from '../types'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {AppClass} from '../../lifecycle/AppClass'
import {ColumnBuilder, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
@@ -55,6 +55,12 @@ export abstract class SQLDialect extends AppClass {
*/
public abstract escape(value: EscapeValue): QuerySafeValue
/**
* Render a query source object as a qualified table name string ("tablename" as "alias").
* @param source
*/
public abstract renderQuerySource(source: QuerySource): string;
/**
* Render the given query builder as a "SELECT ..." query string.
*
@@ -261,6 +267,12 @@ export abstract class SQLDialect extends AppClass {
*/
public abstract renderTransaction(queries: string[]): string;
/**
* Get the expression for the current timestamp as an escaped value.
* @example `raw('NOW()')`
*/
public abstract currentTimestamp(): QuerySafeValue;
/**
* Given a table schema-builder, render a series of queries as a transaction
* that apply the given schema to database.

View File

@@ -0,0 +1,617 @@
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect'
import {Collection, ErrorWithContext, Maybe} from '../../util'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {
Constraint,
FieldType,
inverseFieldType,
isConstraintGroup,
isConstraintItem,
QuerySource,
SpecifiedField,
} from '../types'
import {ColumnBuilder, ConstraintBuilder, ConstraintType, IndexBuilder, TableBuilder} from '../schema/TableBuilder'
export class SQLiteDialect extends SQLDialect {
public escape(value: EscapeValue): QuerySafeValue {
if ( value instanceof QuerySafeValue ) {
return value
} else if ( Array.isArray(value) || value instanceof Collection ) {
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 ( value === null || typeof value === 'undefined' ) {
return new QuerySafeValue(value, 'NULL')
} else if ( !isNaN(Number(value)) ) {
return new QuerySafeValue(value, String(Number(value)))
} else {
const escaped = value.replace(/'/g, '\'\'')
return new QuerySafeValue(value, `'${escaped}'`)
}
}
public renderQuerySource(source: QuerySource): string {
if ( source instanceof QuerySafeValue ) {
return String(source)
} else if ( typeof source === 'string' ) {
return source.replace(/"/g, '""')
.split('.')
.map(x => '"' + x + '"')
.join('.')
}
return `${this.renderQuerySource(source.table)} AS "${source.alias.replace(/"/g, '""')}"`
}
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',
`LIMIT ${start === end ? ((end - start) + 1) : (end - start)} OFFSET ${start}`,
].join('\n')
}
/** Render the fields from the builder class to PostgreSQL syntax. */
protected renderFields(builder: AbstractBuilder<any>): string[] {
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 rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
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 ) {
queryLines.push('FROM ' + this.renderQuerySource(source))
}
// 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(y => '"' + y + '"')
.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(`LIMIT -1 OFFSET ${pagination.skip}`)
}
return queryLines.join('\n')
}
public renderBatchUpdate(): string {
throw new ErrorWithContext('SQLite dialect does not support batch updates.')
}
// TODO support FROM, RETURNING
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const queryLines: string[] = []
// Add table source
const source = builder.querySource
if ( source ) {
queryLines.push('UPDATE ' + this.renderQuerySource(source))
}
queryLines.push(this.renderUpdateSet(data))
// Add constraints
const wheres = this.renderConstraints(builder.appliedConstraints)
if ( wheres.trim() ) {
queryLines.push('WHERE')
queryLines.push(wheres)
}
const fields = this.renderFields(builder).map(x => ` ${x}`)
.join(',\n')
if ( fields ) {
queryLines.push('RETURNING')
queryLines.push(fields)
}
return queryLines.join('\n')
}
public renderExistential(builder: AbstractBuilder<any>): string {
const rawSql = builder.appliedRawSql
if ( rawSql ) {
return `
SELECT EXISTS(
${rawSql}
)
`
}
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 rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const indent = (item: string, level = 1) => Array(level + 1).fill('')
.join(' ') + item
const queryLines: string[] = []
if ( !Array.isArray(data) ) {
data = [data]
}
if ( data.length < 1 ) {
return ''
}
const columns = Object.keys(data[0])
// Add table source
const source = builder.querySource
if ( source ) {
queryLines.push('INSERT INTO ' + this.renderQuerySource(source)
+ (columns.length ? ` (${columns.map(x => `"${x}"`).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 rawSql = builder.appliedRawSql
if ( rawSql ) {
return rawSql
}
const indent = (item: string, level = 1) => Array(level + 1).fill('')
.join(' ') + item
const queryLines: string[] = []
// Add table source
const source = builder.querySource
if ( source ) {
queryLines.push('DELETE FROM ' + this.renderQuerySource(source))
}
// 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(allConstraints: Constraint[], startingLevel = 1): string {
const constraintsToSql = (constraints: Constraint[], level = startingLevel): string => {
const indent = Array(level * 2).fill(' ')
.join('')
const 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) ) {
if ( Array.isArray(constraint.operand) && !constraint.operand.length ) {
statements.push(`${indent}1 = 0 -- ${constraint.field} ${constraint.operator} empty set`)
continue
}
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}`)
} else if ( constraint instanceof QuerySafeValue ) {
statements.push(`${indent}${statements.length < 1 ? '' : 'AND '}${constraint.toString()}`)
}
}
return statements.filter(Boolean).join('\n')
}
return constraintsToSql(allConstraints)
}
public renderUpdateSet(data: {[key: string]: EscapeValue}): string {
const sets = []
for ( const key in data ) {
if ( !Object.prototype.hasOwnProperty.call(data, key) ) {
continue
}
sets.push(` "${key}" = ${this.escape(data[key])}`)
}
return `SET\n${sets.join(',\n')}`
}
public renderCreateTable(builder: TableBuilder): string {
const cols = this.renderTableColumns(builder).map(x => ` ${x}`)
const builderConstraints = builder.getConstraints()
const constraints: string[] = []
for ( const constraintName in builderConstraints ) {
if ( !Object.prototype.hasOwnProperty.call(builderConstraints, constraintName) ) {
continue
}
const constraintBuilder = builderConstraints[constraintName]
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
if ( constraintDefinition ) {
constraints.push(` CONSTRAINT ${constraintDefinition}`)
}
}
const parts = [
`CREATE TABLE ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name} (`,
[
...cols,
...constraints,
].join(',\n'),
`)`,
]
return parts.join('\n')
}
public renderTableColumns(builder: TableBuilder): string[] {
const defined = builder.getColumns()
const rendered: string[] = []
for ( const columnName in defined ) {
if ( !Object.prototype.hasOwnProperty.call(defined, columnName) ) {
continue
}
const columnBuilder = defined[columnName]
rendered.push(this.renderColumnDefinition(columnBuilder))
}
return rendered
}
/**
* Given a constraint schema-builder, render the constraint definition.
* @param builder
* @protected
*/
protected renderConstraintDefinition(builder: ConstraintBuilder): Maybe<string> {
const constraintType = builder.getType()
if ( constraintType === ConstraintType.Unique ) {
const fields = builder.getFields()
.map(x => `"${x}"`)
.join(',')
return `${builder.name} UNIQUE(${fields})`
} else if ( constraintType === ConstraintType.Check ) {
const expression = builder.getExpression()
if ( !expression ) {
throw new ErrorWithContext('Cannot create check constraint without expression.', {
constraintName: builder.name,
tableName: builder.parent.name,
})
}
return `${builder.name} CHECK(${expression})`
}
}
/**
* Given a column-builder, render the SQL-definition as used in
* CREATE TABLE and ALTER TABLE statements.
* @fixme Type `serial` only exists on CREATE TABLE... queries
* @param builder
* @protected
*/
protected renderColumnDefinition(builder: ColumnBuilder): string {
const type = builder.getType()
if ( !type ) {
throw new ErrorWithContext(`Missing field type for column: ${builder.name}`, {
columnName: builder.name,
columnType: type,
})
}
let render = `${builder.name} ${inverseFieldType(this.mapFieldType(type))}`
if ( builder.getLength() ) {
render += `(${builder.getLength()})`
}
const defaultValue = builder.getDefaultValue()
if ( typeof defaultValue !== 'undefined' ) {
render += ` DEFAULT ${this.escape(defaultValue)}`
}
if ( builder.isPrimary() ) {
render += ` PRIMARY KEY`
}
if ( type === FieldType.serial ) {
render += ` AUTOINCREMENT`
}
if ( builder.isUnique() ) {
render += ` UNIQUE`
}
render += ` ${builder.isNullable() ? 'NULL' : 'NOT NULL'}`
return render
}
private mapFieldType(type: FieldType): FieldType {
if ( type === FieldType.serial ) {
return FieldType.integer
}
return type
}
public renderDropTable(builder: TableBuilder): string {
return `DROP TABLE ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`
}
public renderCreateIndex(builder: IndexBuilder): string {
const cols = builder.getFields().map(x => `"${x}"`)
const parts = [
`CREATE ${builder.isUnique() ? 'UNIQUE ' : ''}INDEX ${builder.isSkippedIfExisting() ? 'IF NOT EXISTS ' : ''}${builder.name}`,
` ON ${builder.parent.name}`,
` (${cols.join(',')})`,
]
return parts.join('\n')
}
public renderAlterTable(builder: TableBuilder): string {
const alters: string[] = []
const columns = builder.getColumns()
for ( const columnName in columns ) {
if ( !Object.prototype.hasOwnProperty.call(columns, columnName) ) {
continue
}
const columnBuilder = columns[columnName]
if ( !columnBuilder.isExisting() ) {
// The column doesn't exist on the table, but was added to the schema
alters.push(` ADD COLUMN ${this.renderColumnDefinition(columnBuilder)}`)
} else if ( columnBuilder.isDirty() && columnBuilder.originalFromSchema ) {
// The column exists in the table, but was modified in the schema
if ( columnBuilder.isDropping() || columnBuilder.isDroppingIfExists() ) {
alters.push(` DROP COLUMN "${columnBuilder.name}"`)
continue
}
// Change the data type of the column
if ( columnBuilder.getType() !== columnBuilder.originalFromSchema.getType() ) {
const renderedType = `${columnBuilder.getType()}${columnBuilder.getLength() ? `(${columnBuilder.getLength()})` : ''}`
alters.push(` ALTER COLUMN "${columnBuilder.name}" TYPE ${renderedType}`)
}
// Change the default value of the column
if ( columnBuilder.getDefaultValue() !== columnBuilder.originalFromSchema.getDefaultValue() ) {
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET default ${this.escape(columnBuilder.getDefaultValue())}`)
}
// Change the nullable-status of the column
if ( columnBuilder.isNullable() !== columnBuilder.originalFromSchema.isNullable() ) {
if ( columnBuilder.isNullable() ) {
alters.push(` ALTER COLUMN "${columnBuilder.name}" DROP NOT NULL`)
} else {
alters.push(` ALTER COLUMN "${columnBuilder.name}" SET NOT NULL`)
}
}
// Change the name of the column
if ( columnBuilder.getRename() ) {
alters.push(` RENAME COLUMN "${columnBuilder.name}" TO "${columnBuilder.getRename()}"`)
}
}
}
const constraints = builder.getConstraints()
for ( const constraintName in constraints ) {
if ( !Object.prototype.hasOwnProperty.call(constraints, constraintName) ) {
continue
}
const constraintBuilder = constraints[constraintName]
// Drop the constraint if specified
if ( constraintBuilder.isDropping() ) {
alters.push(` DROP CONSTRAINT ${constraintBuilder.name}`)
continue
}
// Drop the constraint with IF EXISTS if specified
if ( constraintBuilder.isDroppingIfExists() ) {
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
continue
}
// Otherwise, drop and recreate the constraint if it was modified
if ( constraintBuilder.isDirty() ) {
if ( constraintBuilder.isExisting() ) {
alters.push(` DROP CONSTRAINT IF EXISTS ${constraintBuilder.name}`)
}
const constraintDefinition = this.renderConstraintDefinition(constraintBuilder)
if ( constraintDefinition ) {
alters.push(` ADD CONSTRAINT ${constraintDefinition}`)
}
}
}
if ( builder.getRename() ) {
alters.push(` RENAME TO "${builder.getRename()}"`)
}
return 'ALTER TABLE ' + builder.name + '\n' + alters.join(',\n')
}
public renderDropIndex(builder: IndexBuilder): string {
return `DROP INDEX ${builder.isDroppingIfExists() ? 'IF EXISTS ' : ''}${builder.name}`
}
public renderTransaction(queries: string[]): string {
return queries.join(';\n\n') // fixme: sqlite3 in node doesn't properly support transactions
// const parts = [
// 'BEGIN',
// ...queries,
// 'COMMIT;',
// ]
//
// return parts.join(';\n\n')
}
public renderRenameIndex(builder: IndexBuilder): string {
return `ALTER INDEX ${builder.name} RENAME TO ${builder.getRename()}`
}
public renderRecreateIndex(builder: IndexBuilder): string {
return `${this.renderDropIndex(builder)};\n\n${this.renderCreateIndex(builder)}`
}
public renderDropColumn(builder: ColumnBuilder): string {
const parts = [
`ALTER TABLE ${builder.parent.name} ${builder.parent.isSkippedIfExisting() ? 'IF EXISTS ' : ''}`,
` DROP COLUMN ${builder.isSkippedIfExisting() ? 'IF EXISTS ' : ''}${builder.name}`,
]
return parts.join('\n')
}
public currentTimestamp(): QuerySafeValue {
return raw('CURRENT_TIMESTAMP')
}
}

View File

@@ -7,11 +7,13 @@ export * from './builder/Builder'
export * from './connection/Connection'
export * from './connection/PostgresConnection'
export * from './connection/SQLiteConnection'
export * from './connection/event/QueryExecutedEvent'
export * from './connection/event/QueryExecutedEventSerializer'
export * from './dialect/SQLDialect'
export * from './dialect/PostgreSQLDialect'
export * from './dialect/SQLiteDialect'
export * from './model/Field'
export * from './model/ModelBuilder'
@@ -45,6 +47,7 @@ export * from './types'
export * from './schema/TableBuilder'
export * from './schema/Schema'
export * from './schema/PostgresSchema'
export * from './schema/SQLiteSchema'
export * from './services/Migrations'
export * from './migrations/Migrator'

View File

@@ -4,7 +4,7 @@ import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, Collection, uuid4, isKeyof, Pipeline, hasOwnProperty} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {EscapeValueObject, QuerySafeValue} from '../dialect/SQLDialect'
import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection'
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
@@ -162,7 +162,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
builder.connection(this.getConnection())
if ( typeof source === 'string' ) {
if ( typeof source === 'string' || source instanceof QuerySafeValue ) {
builder.from(source)
} else {
builder.from(source.table, source.alias)
@@ -368,7 +368,7 @@ export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>>
builder.connection(ModelClass.getConnection())
if ( typeof source === 'string' ) {
if ( typeof source === 'string' || source instanceof QuerySafeValue ) {
builder.from(source)
} else {
builder.from(source.table, source.alias)

View File

@@ -2,9 +2,10 @@ import {Model} from './Model'
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
import {Connection} from '../connection/Connection'
import {ModelBuilder} from './ModelBuilder'
import {Container, Instantiable} from '../../di'
import {Instantiable} from '../../di'
import {QueryRow} from '../types'
import {collect, Collection} from '../../util'
import {getFieldsMeta} from './Field'
/**
* Implementation of the result iterable that returns query results as instances of the defined model.
@@ -60,8 +61,11 @@ export class ModelResultIterable<T extends Model<T>> extends AbstractResultItera
* @protected
*/
protected async inflateRow(row: QueryRow): Promise<T> {
return Container.getContainer().make<T>(this.ModelClass)
.assumeFromSource(row)
const model = this.make<T>(this.ModelClass)
const fields = getFieldsMeta(model)
return model.assumeFromSource(
this.connection.normalizeRow(row, fields),
)
}
/**

View File

@@ -0,0 +1,94 @@
import {Schema} from './Schema'
import {SQLiteConnection} from '../connection/SQLiteConnection'
import {Awaitable} from '../../util'
import {TableBuilder} from './TableBuilder'
import {Builder} from '../builder/Builder'
import {raw} from '../dialect/SQLDialect'
export class SQLiteSchema extends Schema {
constructor(
connection: SQLiteConnection,
) {
super(connection)
}
hasColumn(table: string, name: string): Awaitable<boolean> {
return (new Builder()).connection(this.connection)
.select(raw('*'))
.from(`pragma_table_info(${this.connection.dialect().escape(table)})`) // FIXME: probably needs to be raw(...)
.where('name', '=', name)
.exists()
}
hasColumns(table: string, name: string[]): Awaitable<boolean> {
return (new Builder()).connection(this.connection)
.select(raw('*'))
.from(`pragma_table_info(${this.connection.dialect().escape(table)})`) // FIXME: probably needs to be raw(...)
.whereIn('name', name)
.get()
.count()
.then(num => num === name.length)
}
hasTable(name: string): Awaitable<boolean> {
return (new Builder()).connection(this.connection)
.select(raw('*'))
.from('sqlite_master')
.where('type', '=', 'table')
.where('name', '=', name)
.exists()
}
table(table: string): Awaitable<TableBuilder> {
return this.populateTable(new TableBuilder(table))
}
protected async populateTable(table: TableBuilder): Promise<TableBuilder> {
if ( !(await this.hasTable(table.name)) ) {
return table
}
// Load the existing columns
await (new Builder()).connection(this.connection)
.select(raw('*'))
.from(raw(`pragma_table_info(${this.connection.dialect().escape(table.name)})`))
.get()
.each(col => {
table.column(col.name)
.type(col.type)
.pipe(line => {
return line.unless(col.notnull, builder => builder.nullable())
.when(col.dflt_value, builder => builder.default(raw(col.dflt_value)))
.when(col.pk, builder => builder.primary())
})
.flagAsExistingInSchema()
})
// TODO: Load the existing constraints
// TODO: Look up and apply the check constraints
// Look up table indexes
await (new Builder()).connection(this.connection)
.select(raw('*'))
.from(raw(`pragma_index_list(${this.connection.dialect().escape(table.name)})`))
.get()
.each(async idx => {
const indexBuilder = table.index(idx.name)
.pipe(line => line.when(idx.unique, builder => builder.unique()))
const idxColumns = await (new Builder()).connection(this.connection)
.select(raw('*'))
.from(raw(`pragma_index_xinfo(${this.connection.dialect().escape(idx.name)})`))
.whereNotNull('name')
.get()
.pluck('name')
idxColumns.whereDefined()
.each(col => indexBuilder.field(col))
indexBuilder.flagAsExistingInSchema()
})
return table.flagAsExistingInSchema()
}
}

View File

@@ -6,6 +6,7 @@ import {Unit} from '../../lifecycle/Unit'
import {Config} from '../../service/Config'
import {Logging} from '../../service/Logging'
import {MigratorFactory} from '../migrations/MigratorFactory'
import {SQLiteConnection} from '../connection/SQLiteConnection'
/**
* Application unit responsible for loading and creating database connections from config.
@@ -48,6 +49,8 @@ export class Database extends Unit {
let conn
if ( config?.dialect === 'postgres' ) {
conn = <PostgresConnection> this.app().make(PostgresConnection, key, config)
} else if ( config?.dialect === 'sqlite' ) {
conn = <SQLiteConnection> this.app().make(SQLiteConnection, key, config)
} else {
const e = new ErrorWithContext(`Invalid or missing database dialect: ${config.dialect}. Should be one of: postgres`)
e.context = { connectionName: key }

View File

@@ -87,7 +87,7 @@ export type SpecifiedField = string | QuerySafeValue | { field: string | QuerySa
/**
* Type alias for an item that refers to a table in a database.
*/
export type QuerySource = string | { table: string, alias: string }
export type QuerySource = string | QuerySafeValue | { table: string | QuerySafeValue, alias: string }
/**
* Possible SQL order-by clause directions.