AsyncPipe; table schemata; migrations; File logging
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
345
src/orm/schema/PostgresSchema.ts
Normal file
345
src/orm/schema/PostgresSchema.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import {Schema} from './Schema'
|
||||
import {Awaitable, collect, Collection} from '../../util'
|
||||
import {ConstraintType, TableBuilder} from './TableBuilder'
|
||||
import {PostgresConnection} from '../connection/PostgresConnection'
|
||||
import {Builder} from '../builder/Builder'
|
||||
import {raw} from '../dialect/SQLDialect'
|
||||
import {QueryRow} from '../types'
|
||||
|
||||
/**
|
||||
* A PostgreSQL-compatible schema implementation.
|
||||
*/
|
||||
export class PostgresSchema extends Schema {
|
||||
constructor(
|
||||
connection: PostgresConnection,
|
||||
public readonly schema: string = 'public',
|
||||
) {
|
||||
super(connection)
|
||||
}
|
||||
|
||||
hasColumn(table: string, name: string): Awaitable<boolean> {
|
||||
return (new Builder()).connection(this.connection)
|
||||
.select(raw('*'))
|
||||
.from('information_schema.columns')
|
||||
.where('table_schema', '=', this.schema)
|
||||
.where('table_name', '=', table)
|
||||
.where('column_name', '=', name)
|
||||
.exists()
|
||||
}
|
||||
|
||||
async hasColumns(table: string, name: string[]): Promise<boolean> {
|
||||
const num = await (new Builder()).connection(this.connection)
|
||||
.select(raw('*'))
|
||||
.from('information_schema.columns')
|
||||
.where('table_schema', '=', this.schema)
|
||||
.where('table_name', '=', table)
|
||||
.whereIn('column_name', name)
|
||||
.get()
|
||||
.count()
|
||||
|
||||
return num === name.length
|
||||
}
|
||||
|
||||
hasTable(name: string): Awaitable<boolean> {
|
||||
return (new Builder()).connection(this.connection)
|
||||
.select(raw('*'))
|
||||
.from('information_schema.tables')
|
||||
.where('table_schema', '=', this.schema)
|
||||
.where('table_name', '=', name)
|
||||
.exists()
|
||||
}
|
||||
|
||||
async table(table: string): Promise<TableBuilder> {
|
||||
return this.populateTable(new TableBuilder(table))
|
||||
}
|
||||
|
||||
/**
|
||||
* If the table for the given TableBuilder already exists in the
|
||||
* database, fill in the columns, constraints, and indexes.
|
||||
* @param table
|
||||
* @protected
|
||||
*/
|
||||
protected async populateTable(table: TableBuilder): Promise<TableBuilder> {
|
||||
if ( await this.hasTable(table.name) ) {
|
||||
// Load the existing columns
|
||||
const cols = await this.getColumns(table.name)
|
||||
cols.each(col => {
|
||||
table.column(col.column_name)
|
||||
.type(col.data_type)
|
||||
.pipe()
|
||||
.when(col.is_nullable, builder => {
|
||||
builder.isNullable()
|
||||
return builder
|
||||
})
|
||||
.when(col.column_default, builder => {
|
||||
builder.default(raw(col.column_default))
|
||||
return builder
|
||||
})
|
||||
})
|
||||
|
||||
// Load the existing constraints
|
||||
const constraints = await this.getConstraints(table.name)
|
||||
|
||||
// Apply the unique constraints
|
||||
const uniques = constraints.where('constraint_type', '=', 'u')
|
||||
.sortBy('constraint_name')
|
||||
.groupBy('constraint_name')
|
||||
|
||||
for ( const key in uniques ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(uniques, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
table.constraint(key)
|
||||
.type(ConstraintType.Unique)
|
||||
.pipe()
|
||||
.peek(constraint => {
|
||||
collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase
|
||||
.pluck<string>('column_name')
|
||||
.each(column => constraint.field(column))
|
||||
})
|
||||
.get()
|
||||
.flagAsExistingInSchema()
|
||||
}
|
||||
|
||||
// Apply the primary key constraints
|
||||
constraints.where('constraint_type', '=', 'p')
|
||||
.pipe()
|
||||
.when(c => c.count() > 0, pk => {
|
||||
pk.each(constraint => {
|
||||
table.column(constraint.column_name)
|
||||
.primary()
|
||||
})
|
||||
|
||||
return pk
|
||||
})
|
||||
|
||||
// Apply the non-null constraints
|
||||
// Builder columns are non-null by default, so mark the others as nullable
|
||||
const nonNullable = constraints.filter(x => !x.constraint_type)
|
||||
.where('is_nullable', '=', 'NO')
|
||||
|
||||
collect<string>(Object.keys(table.getColumns()))
|
||||
.map(column => {
|
||||
return {
|
||||
column,
|
||||
}
|
||||
})
|
||||
.whereNotIn('column', nonNullable.pluck('column_name'))
|
||||
.pluck<string>('column')
|
||||
.each(column => {
|
||||
table.column(column)
|
||||
.nullable()
|
||||
})
|
||||
|
||||
// Look up and apply the check constraints
|
||||
const checkConstraints = await this.getCheckConstraints(table.name)
|
||||
|
||||
checkConstraints.each(constraint => {
|
||||
table.constraint(constraint.constraint_name)
|
||||
.type(ConstraintType.Check)
|
||||
.expression(constraint.check_clause)
|
||||
.flagAsExistingInSchema()
|
||||
})
|
||||
|
||||
// Mark the columns as existing in the database
|
||||
cols.each(col => {
|
||||
table.column(col.column_name)
|
||||
.flagAsExistingInSchema()
|
||||
})
|
||||
|
||||
// Look up table indexes
|
||||
const indexes = await this.getIndexes(table.name)
|
||||
const groupedIndexes = indexes.groupBy('index_name')
|
||||
|
||||
for ( const key in groupedIndexes ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(groupedIndexes, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
table.index(key)
|
||||
.pipe()
|
||||
.peek(idx => {
|
||||
collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase
|
||||
.pluck<string>('column_name')
|
||||
.each(col => idx.field(col))
|
||||
})
|
||||
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
|
||||
idx.primary()
|
||||
})
|
||||
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
|
||||
idx.unique()
|
||||
})
|
||||
.get()
|
||||
.flagAsExistingInSchema()
|
||||
}
|
||||
|
||||
table.flagAsExistingInSchema()
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the database to look up all indexes on a table, by column.
|
||||
* @see https://stackoverflow.com/a/2213199/4971138
|
||||
* @param table
|
||||
* @protected
|
||||
*/
|
||||
protected async getIndexes(table: string): Promise<Collection<QueryRow>> {
|
||||
const rawQuery = `
|
||||
select
|
||||
t.relname as table_name,
|
||||
i.relname as index_name,
|
||||
a.attname as column_name,
|
||||
ix.*
|
||||
from pg_class t
|
||||
left join pg_attribute a
|
||||
on a.attrelid = t.oid
|
||||
left join pg_index ix
|
||||
on t.oid = ix.indrelid
|
||||
left join pg_class i
|
||||
on i.oid = ix.indexrelid
|
||||
left join pg_namespace n
|
||||
on n.oid = i.relnamespace
|
||||
where
|
||||
a.attnum = any(ix.indkey)
|
||||
and t.relkind = 'r'
|
||||
and t.relname = '${table}'
|
||||
and n.nspname = '${this.schema}'
|
||||
order by
|
||||
t.relname,
|
||||
i.relname;
|
||||
`
|
||||
|
||||
return (new Builder()).connection(this.connection)
|
||||
.raw(rawQuery)
|
||||
.get()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the database to look up all constraints on a table, by column.
|
||||
* @see https://dba.stackexchange.com/a/290854
|
||||
* @param table
|
||||
* @protected
|
||||
*/
|
||||
protected async getConstraints(table: string): Promise<Collection<QueryRow>> {
|
||||
const rawQuery = `
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
pgc.contype AS constraint_type,
|
||||
pgc.conname AS constraint_name,
|
||||
ccu.table_schema AS table_schema,
|
||||
kcu.table_name AS table_name,
|
||||
CASE WHEN (pgc.contype = 'f') THEN kcu.COLUMN_NAME ELSE ccu.COLUMN_NAME END AS column_name,
|
||||
CASE WHEN (pgc.contype = 'f') THEN ccu.TABLE_NAME ELSE (null) END AS reference_table,
|
||||
CASE WHEN (pgc.contype = 'f') THEN ccu.COLUMN_NAME ELSE (null) END AS reference_col,
|
||||
CASE WHEN (pgc.contype = 'p') THEN 'yes' ELSE 'no' END AS auto_inc,
|
||||
CASE WHEN (pgc.contype = 'p') THEN 'NO' ELSE 'YES' END AS is_nullable,
|
||||
'integer' AS data_type,
|
||||
'0' AS numeric_scale,
|
||||
'32' AS numeric_precision
|
||||
FROM
|
||||
pg_constraint AS pgc
|
||||
JOIN pg_namespace nsp
|
||||
ON nsp.oid = pgc.connamespace
|
||||
JOIN pg_class cls
|
||||
ON pgc.conrelid = cls.oid
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = pgc.conname
|
||||
LEFT JOIN information_schema.constraint_column_usage ccu
|
||||
ON pgc.conname = ccu.CONSTRAINT_NAME
|
||||
AND nsp.nspname = ccu.CONSTRAINT_SCHEMA
|
||||
WHERE
|
||||
kcu.table_name = '${table}'
|
||||
UNION
|
||||
SELECT
|
||||
NULL AS constraint_type,
|
||||
NULL AS constraint_name,
|
||||
table_schema,
|
||||
table_name,
|
||||
column_name,
|
||||
NULL AS refrence_table,
|
||||
NULL AS refrence_col,
|
||||
'no' AS auto_inc,
|
||||
is_nullable,
|
||||
data_type,
|
||||
numeric_scale,
|
||||
numeric_precision
|
||||
FROM information_schema.columns cols
|
||||
WHERE
|
||||
table_schema = '${this.schema}'
|
||||
AND table_name = '${table}'
|
||||
) AS child
|
||||
ORDER BY table_name DESC
|
||||
`
|
||||
|
||||
return (new Builder()).connection(this.connection)
|
||||
.raw(rawQuery)
|
||||
.get()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://dataedo.com/kb/query/postgresql/list-table-check-constraints
|
||||
* @param table
|
||||
* @protected
|
||||
*/
|
||||
protected async getCheckConstraints(table: string): Promise<Collection<QueryRow>> {
|
||||
const rawQuery = `
|
||||
SELECT
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
ARRAY_AGG(col.column_name) AS columns,
|
||||
tc.constraint_name,
|
||||
cc.check_clause
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.check_constraints cc
|
||||
ON tc.constraint_schema = cc.constraint_schema
|
||||
AND tc.constraint_name = cc.constraint_name
|
||||
JOIN pg_namespace nsp
|
||||
ON nsp.nspname = cc.constraint_schema
|
||||
JOIN pg_constraint pgc
|
||||
ON pgc.conname = cc.constraint_name
|
||||
AND pgc.connamespace = nsp.oid
|
||||
AND pgc.contype = 'c'
|
||||
JOIN information_schema.columns col
|
||||
ON col.table_schema = tc.table_schema
|
||||
AND col.table_name = tc.table_name
|
||||
AND col.ordinal_position = ANY(pgc.conkey)
|
||||
WHERE
|
||||
tc.constraint_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
AND tc.table_schema = '${this.schema}'
|
||||
AND tc.table_name = '${table}'
|
||||
GROUP BY
|
||||
tc.table_schema,
|
||||
tc.table_name,
|
||||
tc.constraint_name,
|
||||
cc.check_clause
|
||||
ORDER BY
|
||||
tc.table_schema,
|
||||
tc.table_name
|
||||
`
|
||||
|
||||
return (new Builder()).connection(this.connection)
|
||||
.raw(rawQuery)
|
||||
.get()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the database to look up all columns on a table.
|
||||
* @param table
|
||||
* @protected
|
||||
*/
|
||||
protected async getColumns(table: string): Promise<Collection<QueryRow>> {
|
||||
return (new Builder()).connection(this.connection)
|
||||
.select(raw('*'))
|
||||
.from('information_schema.columns')
|
||||
.where('table_schema', '=', this.schema)
|
||||
.where('table_name', '=', table)
|
||||
.get()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,51 @@
|
||||
import {Connection} from '../connection/Connection'
|
||||
import {Awaitable} from '../../util'
|
||||
import {TableBuilder} from './TableBuilder'
|
||||
|
||||
/**
|
||||
* Represents a SQL-schema implementation.
|
||||
*/
|
||||
export abstract class Schema {
|
||||
constructor(
|
||||
/** The SQL connection to execute against. */
|
||||
protected readonly connection: Connection,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Resolve true if the schema has a table with the given name.
|
||||
* @param name
|
||||
*/
|
||||
public abstract hasTable(name: string): Awaitable<boolean>
|
||||
|
||||
/**
|
||||
* Resolve true if the schema table with the given name has a column with the given name.
|
||||
* @param table
|
||||
* @param name
|
||||
*/
|
||||
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
|
||||
|
||||
/**
|
||||
* Resolve true if the schema table with the given name has all the specified columns.
|
||||
* @param table
|
||||
* @param name
|
||||
*/
|
||||
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
|
||||
|
||||
/**
|
||||
* Get a TableBuilder instance for a table on the schema.
|
||||
* @param table
|
||||
*/
|
||||
public abstract table(table: string): Awaitable<TableBuilder>
|
||||
|
||||
/**
|
||||
* Apply the table to the schema.
|
||||
* @param schema
|
||||
*/
|
||||
public async commit(schema: TableBuilder): Promise<void> {
|
||||
const query = this.connection
|
||||
.dialect()
|
||||
.renderCommitSchemaTransaction(schema)
|
||||
|
||||
await this.connection.query(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +1,770 @@
|
||||
import {Pipe} from '../../util'
|
||||
import {collect, Maybe, ParameterizedCallback, Pipe} from '../../util'
|
||||
import {FieldType} from '../types'
|
||||
import {EscapeValue, QuerySafeValue, raw} from '../dialect/SQLDialect'
|
||||
|
||||
/**
|
||||
* Base class with shared logic for the various schema
|
||||
* builders (table, column, index).
|
||||
*/
|
||||
export abstract class SchemaBuilderBase {
|
||||
/**
|
||||
* Whether or not the schema item should be dropped.
|
||||
* - `exists` - drop if exists
|
||||
* @protected
|
||||
*/
|
||||
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
|
||||
|
||||
/**
|
||||
* The name the schema item should have if renaming.
|
||||
* @protected
|
||||
*/
|
||||
protected shouldRenameTo?: string
|
||||
|
||||
/**
|
||||
* If true, apply IF NOT EXISTS syntax.
|
||||
* @protected
|
||||
*/
|
||||
protected shouldSkipIfExists = false
|
||||
|
||||
/** True if the schema has been modified since created/loaded. */
|
||||
protected dirty = false
|
||||
|
||||
/** True if this resource exists, in some form, in the schema. */
|
||||
protected existsInSchema = false
|
||||
|
||||
/** If the resource exists in the schema, the unaltered values it has. */
|
||||
public originalFromSchema?: SchemaBuilderBase
|
||||
|
||||
constructor(
|
||||
protected readonly name: string,
|
||||
/** The name of the schema item. */
|
||||
public readonly name: string,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Clone the properties of this resource to a different instance.
|
||||
* @param newBuilder
|
||||
*/
|
||||
public cloneTo(newBuilder: SchemaBuilderBase): SchemaBuilderBase {
|
||||
newBuilder.shouldDrop = this.shouldDrop
|
||||
newBuilder.shouldRenameTo = this.shouldRenameTo
|
||||
newBuilder.shouldSkipIfExists = this.shouldSkipIfExists
|
||||
newBuilder.dirty = this.dirty
|
||||
newBuilder.existsInSchema = this.existsInSchema
|
||||
return newBuilder
|
||||
}
|
||||
|
||||
/** True if this resource should be dropped. */
|
||||
public isDropping(): boolean {
|
||||
return this.shouldDrop === 'yes'
|
||||
}
|
||||
|
||||
/** True if this resource should be dropped with IF EXISTS syntax. */
|
||||
public isDroppingIfExists(): boolean {
|
||||
return this.shouldDrop === 'exists'
|
||||
}
|
||||
|
||||
/** True if this resource should be created with IF NOT EXISTS syntax. */
|
||||
public isSkippedIfExisting(): boolean {
|
||||
return this.shouldSkipIfExists
|
||||
}
|
||||
|
||||
/** True if the resource already exists in some form in the schema. */
|
||||
public isExisting(): boolean {
|
||||
return this.existsInSchema
|
||||
}
|
||||
|
||||
/** True if the resource has been modified since created/loaded. */
|
||||
public isDirty(): boolean {
|
||||
return this.dirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name this resource should be renamed to, if it exists.
|
||||
*/
|
||||
public getRename(): Maybe<string> {
|
||||
return this.shouldRenameTo
|
||||
}
|
||||
|
||||
/** Mark the resource to be removed. */
|
||||
public drop(): this {
|
||||
this.dirty = true
|
||||
this.shouldDrop = 'yes'
|
||||
return this
|
||||
}
|
||||
|
||||
/** Mark the resource to be removed, if it exists. */
|
||||
public dropIfExists(): this {
|
||||
this.dirty = true
|
||||
this.shouldDrop = 'exists'
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the resource to a different name.
|
||||
* @param to
|
||||
*/
|
||||
public rename(to: string): this {
|
||||
this.dirty = true
|
||||
this.shouldRenameTo = to
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the resource to use IF NOT EXISTS syntax.
|
||||
*/
|
||||
public ifNotExists(): this {
|
||||
this.shouldSkipIfExists = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Used internally.
|
||||
* Mark that the resource exists in the schema in some form,
|
||||
* and reset the `dirty` flag.
|
||||
*/
|
||||
public flagAsExistingInSchema(): this {
|
||||
this.existsInSchema = true
|
||||
this.dirty = false
|
||||
this.originalFromSchema = this.cloneTo(this.cloneInstance())
|
||||
return this
|
||||
}
|
||||
|
||||
/** Get a Pipe containing this instance. */
|
||||
pipe(): Pipe<this> {
|
||||
return Pipe.wrap<this>(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new instance of the concrete implementation of this class.
|
||||
* @protected
|
||||
*/
|
||||
protected abstract cloneInstance(): this
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder to specify the schema of a table column.
|
||||
*/
|
||||
export class ColumnBuilder extends SchemaBuilderBase {
|
||||
/** The data type of the column. */
|
||||
protected targetType?: FieldType
|
||||
|
||||
}
|
||||
/** True if the column should allow NULL values. */
|
||||
protected shouldBeNullable = false
|
||||
|
||||
export class IndexBuilder extends SchemaBuilderBase {
|
||||
/** The default value of the column, if one should exist. */
|
||||
protected defaultValue?: EscapeValue
|
||||
|
||||
protected fields: Set<string> = new Set<string>()
|
||||
|
||||
protected removedFields: Set<string> = new Set<string>()
|
||||
|
||||
protected shouldBeUnique = false
|
||||
/** The data length of this column, if set */
|
||||
protected targetLength?: number
|
||||
|
||||
/** True if this is a primary key constraint. */
|
||||
protected shouldBePrimary = false
|
||||
|
||||
protected field(name: string): this {
|
||||
/** True if this column should contain distinct values. */
|
||||
protected shouldBeUnique = false
|
||||
|
||||
public originalFromSchema?: ColumnBuilder
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
|
||||
/** The table this column belongs to. */
|
||||
public readonly parent: TableBuilder,
|
||||
) {
|
||||
super(name)
|
||||
}
|
||||
|
||||
public cloneTo(newBuilder: ColumnBuilder): ColumnBuilder {
|
||||
super.cloneTo(newBuilder)
|
||||
newBuilder.targetType = this.targetType
|
||||
newBuilder.shouldBeNullable = this.shouldBeNullable
|
||||
newBuilder.defaultValue = this.defaultValue
|
||||
newBuilder.targetLength = this.targetLength
|
||||
newBuilder.shouldBePrimary = this.shouldBePrimary
|
||||
newBuilder.shouldBeUnique = this.shouldBeUnique
|
||||
return newBuilder
|
||||
}
|
||||
|
||||
/** Get the field type of the column, if it exists. */
|
||||
public getType(): Maybe<FieldType> {
|
||||
return this.targetType
|
||||
}
|
||||
|
||||
/** Get the data-type length of the column, if it exists. */
|
||||
public getLength(): Maybe<number> {
|
||||
return this.targetLength
|
||||
}
|
||||
|
||||
/** Get the default value of the column, if it exists. */
|
||||
public getDefaultValue(): Maybe<EscapeValue> {
|
||||
return this.defaultValue
|
||||
}
|
||||
|
||||
/** True if the column should allow NULL values. */
|
||||
public isNullable(): boolean {
|
||||
return this.shouldBeNullable
|
||||
}
|
||||
|
||||
/** True if the column is a primary key. */
|
||||
public isPrimary(): boolean {
|
||||
return this.shouldBePrimary
|
||||
}
|
||||
|
||||
/** True if the column should require unique values. */
|
||||
public isUnique(): boolean {
|
||||
return this.shouldBeUnique
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the data type of the column.
|
||||
* @param type
|
||||
*/
|
||||
public type(type: FieldType): this {
|
||||
if ( this.targetType === type ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.targetType = type
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the column nullable.
|
||||
*/
|
||||
public nullable(): this {
|
||||
if ( this.shouldBeNullable ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBeNullable = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the column non-nullable.
|
||||
*/
|
||||
public required(): this {
|
||||
if ( !this.shouldBeNullable ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBeNullable = false
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the default value of the column.
|
||||
* @param value
|
||||
*/
|
||||
public default(value: EscapeValue): this {
|
||||
if ( this.defaultValue === value ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.defaultValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the length of this column's data type.
|
||||
* @param value
|
||||
*/
|
||||
public length(value: number): this {
|
||||
if ( this.targetLength === value ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.targetLength = value
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this a primary-key column.
|
||||
*/
|
||||
primary(): this {
|
||||
if ( this.shouldBePrimary ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBePrimary = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this column require distinct values.
|
||||
*/
|
||||
unique(): this {
|
||||
if ( this.shouldBeUnique ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBeUnique = true
|
||||
return this
|
||||
}
|
||||
|
||||
protected cloneInstance(): this {
|
||||
return new ColumnBuilder(this.name, this.parent) as this
|
||||
}
|
||||
}
|
||||
|
||||
/** Valid constraint types. */
|
||||
export enum ConstraintType {
|
||||
Unique = 'un',
|
||||
Check = 'ck',
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder to specify the schema of a table constraint.
|
||||
*/
|
||||
export class ConstraintBuilder extends SchemaBuilderBase {
|
||||
public originalFromSchema?: ConstraintBuilder
|
||||
|
||||
/** The fields included in this constraint. */
|
||||
protected fields: Set<string> = new Set<string>()
|
||||
|
||||
/** The type of this constraint. */
|
||||
protected constraintType: ConstraintType = ConstraintType.Unique
|
||||
|
||||
/** The expression defining this constraint, if applicable. */
|
||||
protected constraintExpression?: QuerySafeValue
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
|
||||
/** The table this constraint belongs to. */
|
||||
public readonly parent: TableBuilder,
|
||||
) {
|
||||
super(name)
|
||||
}
|
||||
|
||||
/** Get the type of this constraint. */
|
||||
public getType(): ConstraintType {
|
||||
return this.constraintType
|
||||
}
|
||||
|
||||
/** Get the fields included in this constraint. */
|
||||
public getFields(): string[] {
|
||||
return [...this.fields]
|
||||
}
|
||||
|
||||
/** Get the expression used to evaluate this constraint, if it exists. */
|
||||
public getExpression(): Maybe<QuerySafeValue> {
|
||||
return this.constraintExpression
|
||||
}
|
||||
|
||||
public cloneTo(newBuilder: ConstraintBuilder): ConstraintBuilder {
|
||||
super.cloneTo(newBuilder)
|
||||
newBuilder.fields = new Set<string>([...this.fields])
|
||||
newBuilder.constraintType = this.constraintType
|
||||
return newBuilder
|
||||
}
|
||||
|
||||
protected cloneInstance(): this {
|
||||
return new ConstraintBuilder(this.name, this.parent) as this
|
||||
}
|
||||
|
||||
/** Add a field to this constraint. */
|
||||
public field(name: string): this {
|
||||
if ( this.fields.has(name) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.fields.add(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Remove a field from this constraint. */
|
||||
public removeField(name: string): this {
|
||||
if ( !this.fields.has(name) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.fields.delete(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Specify the type of this constraint. */
|
||||
public type(type: ConstraintType): this {
|
||||
if ( this.constraintType === type ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.constraintType = type
|
||||
return this
|
||||
}
|
||||
|
||||
/** Specify the expression used to evaluate this constraint, if applicable. */
|
||||
public expression(sql: string | QuerySafeValue): this {
|
||||
if ( String(this.constraintExpression) === String(sql) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
|
||||
if ( sql instanceof QuerySafeValue ) {
|
||||
this.constraintExpression = sql
|
||||
}
|
||||
|
||||
this.constraintExpression = raw(sql)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder to specify the schema of a table index.
|
||||
*/
|
||||
export class IndexBuilder extends SchemaBuilderBase {
|
||||
/** The fields included in the index. */
|
||||
protected fields: Set<string> = new Set<string>()
|
||||
|
||||
/** Fields to remove from the index. */
|
||||
protected removedFields: Set<string> = new Set<string>()
|
||||
|
||||
/** True if this is a unique index. */
|
||||
protected shouldBeUnique = false
|
||||
|
||||
/** True if this is a primary key index. */
|
||||
protected shouldBePrimary = false
|
||||
|
||||
public originalFromSchema?: IndexBuilder
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
|
||||
/** The table this index belongs to. */
|
||||
public readonly parent: TableBuilder,
|
||||
) {
|
||||
super(name)
|
||||
}
|
||||
|
||||
public cloneTo(newBuilder: IndexBuilder): IndexBuilder {
|
||||
super.cloneTo(newBuilder)
|
||||
newBuilder.fields = new Set<string>([...this.fields])
|
||||
newBuilder.removedFields = new Set<string>([...this.removedFields])
|
||||
newBuilder.shouldBeUnique = this.shouldBeUnique
|
||||
newBuilder.shouldBePrimary = this.shouldBePrimary
|
||||
return newBuilder
|
||||
}
|
||||
|
||||
/** Get the fields in this index. */
|
||||
public getFields(): string[] {
|
||||
return [...this.fields]
|
||||
}
|
||||
|
||||
/** True if this index is a unique index. */
|
||||
public isUnique(): boolean {
|
||||
return this.shouldBeUnique
|
||||
}
|
||||
|
||||
/** True if this index is the primary key index. */
|
||||
public isPrimary(): boolean {
|
||||
return this.shouldBePrimary
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given field to this index.
|
||||
* @param name
|
||||
*/
|
||||
public field(name: string): this {
|
||||
if ( this.fields.has(name) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.fields.add(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given field from this index.
|
||||
* @param name
|
||||
* @protected
|
||||
*/
|
||||
protected removeField(name: string): this {
|
||||
if ( !this.fields.has(name) ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.removedFields.add(name)
|
||||
this.fields.delete(name)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this a primary-key index.
|
||||
*/
|
||||
primary(): this {
|
||||
if ( this.shouldBePrimary ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBePrimary = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Make this a unique index.
|
||||
*/
|
||||
unique(): this {
|
||||
if ( this.shouldBeUnique ) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.dirty = true
|
||||
this.shouldBeUnique = true
|
||||
return this
|
||||
}
|
||||
|
||||
protected cloneInstance(): this {
|
||||
return new IndexBuilder(this.name, this.parent) as this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder to specify the schema of a table.
|
||||
*/
|
||||
export class TableBuilder extends SchemaBuilderBase {
|
||||
|
||||
/**
|
||||
* Mapping of column name to column schemata.
|
||||
* @protected
|
||||
*/
|
||||
protected columns: {[key: string]: ColumnBuilder} = {}
|
||||
|
||||
/**
|
||||
* Mapping of index name to index schemata.
|
||||
* @protected
|
||||
*/
|
||||
protected indexes: {[key: string]: IndexBuilder} = {}
|
||||
|
||||
/**
|
||||
* Mapping of constraint name to constraint schemata.
|
||||
* @protected
|
||||
*/
|
||||
protected constraints: {[key: string]: ConstraintBuilder} = {}
|
||||
|
||||
public originalFromSchema?: TableBuilder
|
||||
|
||||
public cloneTo(newBuilder: TableBuilder): TableBuilder {
|
||||
super.cloneTo(newBuilder)
|
||||
newBuilder.columns = {...this.columns}
|
||||
newBuilder.indexes = {...this.indexes}
|
||||
newBuilder.constraints = {...this.constraints}
|
||||
return newBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the columns defined on this table.
|
||||
*/
|
||||
public getColumns(): {[key: string]: ColumnBuilder} {
|
||||
return {
|
||||
...this.columns,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the indices defined on this table.
|
||||
*/
|
||||
public getIndexes(): {[key: string]: IndexBuilder} {
|
||||
return {
|
||||
...this.indexes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the constraints defined on this table.
|
||||
*/
|
||||
public getConstraints(): {[key: string]: ConstraintBuilder} {
|
||||
return {
|
||||
...this.constraints,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a column to be dropped.
|
||||
* @param name
|
||||
*/
|
||||
public dropColumn(name: string): this {
|
||||
this.dirty = true
|
||||
this.column(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a column to be renamed.
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
public renameColumn(from: string, to: string): this {
|
||||
this.column(from).rename(to)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an index to be dropped.
|
||||
* @param name
|
||||
*/
|
||||
public dropIndex(name: string): this {
|
||||
this.dirty = true
|
||||
this.index(name).drop()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an index to be renamed.
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
public renameIndex(from: string, to: string): this {
|
||||
this.index(from).rename(to)
|
||||
return this
|
||||
}
|
||||
|
||||
public column(name: string) {
|
||||
/**
|
||||
* Add a column to this table.
|
||||
* @param name
|
||||
* @param callback
|
||||
*/
|
||||
public column(name: string, callback?: ParameterizedCallback<ColumnBuilder>): ColumnBuilder {
|
||||
if ( !this.columns[name] ) {
|
||||
this.columns[name] = new ColumnBuilder(name)
|
||||
this.dirty = true
|
||||
this.columns[name] = new ColumnBuilder(name, this)
|
||||
}
|
||||
|
||||
if ( callback ) {
|
||||
callback(this.columns[name])
|
||||
}
|
||||
|
||||
return this.columns[name]
|
||||
}
|
||||
|
||||
public index(name: string) {
|
||||
/**
|
||||
* Add an index to this table.
|
||||
* @param name
|
||||
* @param callback
|
||||
*/
|
||||
public index(name: string, callback?: ParameterizedCallback<IndexBuilder>): IndexBuilder {
|
||||
if ( !this.indexes[name] ) {
|
||||
this.indexes[name] = new IndexBuilder(name)
|
||||
this.dirty = true
|
||||
this.indexes[name] = new IndexBuilder(name, this)
|
||||
}
|
||||
|
||||
if ( callback ) {
|
||||
callback(this.indexes[name])
|
||||
}
|
||||
|
||||
return this.indexes[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a constraint to this table.
|
||||
* @param name
|
||||
* @param callback
|
||||
*/
|
||||
public constraint(name: string, callback?: ParameterizedCallback<ConstraintBuilder>): ConstraintBuilder {
|
||||
if ( !this.constraints[name] ) {
|
||||
this.dirty = true
|
||||
this.constraints[name] = new ConstraintBuilder(name, this)
|
||||
}
|
||||
|
||||
if ( callback ) {
|
||||
callback(this.constraints[name])
|
||||
}
|
||||
|
||||
return this.constraints[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a programmatically-incrementing constraint name.
|
||||
* @param suffix
|
||||
* @protected
|
||||
*/
|
||||
protected getNextAvailableConstraintName(suffix: ConstraintType): string {
|
||||
let current = 1
|
||||
let name = `${this.name}_${current}_${suffix}`
|
||||
|
||||
while ( this.constraints[name] ) {
|
||||
current += 1
|
||||
name = `${this.name}_${current}_${suffix}`
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new check constraint with the given expression.
|
||||
* @param expression
|
||||
*/
|
||||
public check(expression: string | QuerySafeValue): this {
|
||||
const name = this.getNextAvailableConstraintName(ConstraintType.Check)
|
||||
this.constraint(name)
|
||||
.type(ConstraintType.Check)
|
||||
.expression(expression)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new unique constraint for the given fields.
|
||||
* @param fields
|
||||
*/
|
||||
public unique(...fields: string[]): this {
|
||||
// First, check if an existing constraint exists with these fields
|
||||
for ( const key in this.constraints ) {
|
||||
if ( !Object.prototype.hasOwnProperty.call(this.constraints, key) ) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ( this.constraints[key].getType() !== ConstraintType.Unique ) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existingFields = collect<string>(this.constraints[key].getFields())
|
||||
const intersection = existingFields.intersect(fields)
|
||||
|
||||
if ( existingFields.length === fields.length && intersection.length === fields.length ) {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// If an existing constraint can't satisfy this, create a new one
|
||||
const name = this.getNextAvailableConstraintName(ConstraintType.Unique)
|
||||
this.constraint(name)
|
||||
.type(ConstraintType.Unique)
|
||||
.pipe()
|
||||
.peek(constraint => {
|
||||
fields.forEach(field => constraint.field(field))
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a primary key (column & index) to this table.
|
||||
* @param name
|
||||
* @param type
|
||||
*/
|
||||
public primaryKey(name: string, type: FieldType = FieldType.serial): ColumnBuilder {
|
||||
this.dirty = true
|
||||
|
||||
return this.column(name)
|
||||
.type(type)
|
||||
.primary()
|
||||
}
|
||||
|
||||
protected cloneInstance(): this {
|
||||
return new TableBuilder(this.name) as this
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user