Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
68
src/orm/DatabaseService.ts
Normal file
68
src/orm/DatabaseService.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {Connection} from "./connection/Connection";
|
||||
import {Inject, Singleton} from "../di";
|
||||
import {ErrorWithContext, uuid_v4} from "../util";
|
||||
import {AppClass} from "../lifecycle/AppClass";
|
||||
import {Logging} from "../service/Logging";
|
||||
|
||||
/**
|
||||
* A singleton, non-unit service that stores and retrieves database connections by name.
|
||||
*/
|
||||
@Singleton()
|
||||
export class DatabaseService extends AppClass {
|
||||
@Inject()
|
||||
protected logging!: Logging
|
||||
|
||||
/** Mapping of connection name -> connection instance for connections registered with this service. */
|
||||
protected readonly connections: { [key: string]: Connection } = {}
|
||||
|
||||
/**
|
||||
* Register a new connection instance by name.
|
||||
* @param name
|
||||
* @param connection
|
||||
*/
|
||||
register(name: string, connection: Connection) {
|
||||
if ( this.connections[name] ) {
|
||||
this.logging.warn(`Overriding duplicate connection: ${name}`)
|
||||
}
|
||||
|
||||
this.connections[name] = connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a connection is registered with the given name.
|
||||
* @param name
|
||||
*/
|
||||
has(name: string) {
|
||||
return !!this.connections[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connection instance by its name. Throws if none exists.
|
||||
* @param name
|
||||
*/
|
||||
get(name: string): Connection {
|
||||
if ( !this.has(name) ) {
|
||||
throw new ErrorWithContext(`No such connection is registered: ${name}`)
|
||||
}
|
||||
|
||||
return this.connections[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of the names of all registered connections.
|
||||
*/
|
||||
names(): string[] {
|
||||
return Object.keys(this.connections)
|
||||
}
|
||||
|
||||
/** Get a guaranteed-unique connection name. */
|
||||
uniqueName(): string {
|
||||
let name: string;
|
||||
|
||||
do {
|
||||
name = uuid_v4()
|
||||
} while (this.has(name))
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
||||
574
src/orm/builder/AbstractBuilder.ts
Normal file
574
src/orm/builder/AbstractBuilder.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import {Inject} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {
|
||||
Constraint, ConstraintConnectionOperator,
|
||||
ConstraintOperator,
|
||||
OrderDirection,
|
||||
OrderStatement, QueryResult,
|
||||
QuerySource,
|
||||
SpecifiedField
|
||||
} from "../types";
|
||||
import {Connection} from "../connection/Connection";
|
||||
import {deepCopy, ErrorWithContext} from "../../util";
|
||||
import {EscapeValue, QuerySafeValue, raw} from "../dialect/SQLDialect";
|
||||
import {ResultCollection} from "./result/ResultCollection";
|
||||
import {AbstractResultIterable} from "./result/AbstractResultIterable";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
||||
/**
|
||||
* Type alias for a function that applies some constraints to a builder group.
|
||||
*/
|
||||
export type ConstraintGroupClosure<T> = (group: AbstractBuilder<T>) => any
|
||||
|
||||
/**
|
||||
* A base class that facilitates building database queries using a fluent interface.
|
||||
* This can be specialized by child-classes to yield query results of the given type `T`.
|
||||
*/
|
||||
export abstract class AbstractBuilder<T> extends AppClass {
|
||||
@Inject()
|
||||
protected readonly databaseService!: DatabaseService
|
||||
|
||||
/** Constraints applied to this query. */
|
||||
protected constraints: Constraint[] = []
|
||||
|
||||
/** The source table to query from. */
|
||||
protected source?: QuerySource
|
||||
|
||||
/** The fields to query from the table. */
|
||||
protected _fields: SpecifiedField[] = []
|
||||
|
||||
/** The number of records to skip before the result set. */
|
||||
protected _skip?: number
|
||||
|
||||
/** The max number of records to include in the result set. */
|
||||
protected _take?: number
|
||||
|
||||
/** If true, the query should refer to distinct records. */
|
||||
protected _distinct: boolean = false
|
||||
|
||||
/** Array of SQL group-by clauses. */
|
||||
protected _groupings: string[] = []
|
||||
|
||||
/** Array of SQL order-by clauses. */
|
||||
protected _orders: OrderStatement[] = []
|
||||
|
||||
/** The connection on which the query should be executed. */
|
||||
protected _connection?: Connection
|
||||
|
||||
/**
|
||||
* Create a new, empty, instance of the current builder.
|
||||
*/
|
||||
public abstract getNewInstance(): AbstractBuilder<T>
|
||||
|
||||
/**
|
||||
* Get a result iterable for the built query.
|
||||
*/
|
||||
public abstract getResultIterable(): AbstractResultIterable<T>
|
||||
|
||||
/**
|
||||
* Clone the current query to a new AbstractBuilder instance with the same properties.
|
||||
*/
|
||||
public clone(): AbstractBuilder<T> {
|
||||
const bldr = this.getNewInstance()
|
||||
|
||||
bldr.constraints = deepCopy(this.constraints)
|
||||
bldr.source = deepCopy(this.source)
|
||||
bldr._fields = deepCopy(this._fields)
|
||||
bldr._skip = deepCopy(this._skip)
|
||||
bldr._take = deepCopy(this._take)
|
||||
bldr._distinct = deepCopy(this._distinct)
|
||||
bldr._groupings = deepCopy(this._groupings)
|
||||
bldr._orders = deepCopy(this._orders)
|
||||
bldr._connection = this._connection
|
||||
|
||||
return bldr
|
||||
}
|
||||
|
||||
/** Get the constraints applied to this query. */
|
||||
public get appliedConstraints() {
|
||||
return deepCopy(this.constraints)
|
||||
}
|
||||
|
||||
/** Get the fields that should be included in this query. */
|
||||
public get appliedFields() {
|
||||
return deepCopy(this._fields)
|
||||
}
|
||||
|
||||
/** Get the skip/take values of this query. */
|
||||
public get appliedPagination() {
|
||||
return { skip: this._skip, take: this._take }
|
||||
}
|
||||
|
||||
/** True if the query should be DISTINCT */
|
||||
public get appliedDistinction() {
|
||||
return this._distinct
|
||||
}
|
||||
|
||||
/** Get the SQL group-by clauses applied to this query. */
|
||||
public get appliedGroupings() {
|
||||
return deepCopy(this._groupings)
|
||||
}
|
||||
|
||||
/** Get the SQL order-by clauses applied to this query. */
|
||||
public get appliedOrder() {
|
||||
return deepCopy(this._orders)
|
||||
}
|
||||
|
||||
/** Get the source table for this query. */
|
||||
public get querySource() {
|
||||
if ( this.source ) return deepCopy(this.source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the source table (and optional alias) for this query.
|
||||
* @param table
|
||||
* @param alias
|
||||
*/
|
||||
from(table: string, alias?: string) {
|
||||
if ( alias ) {
|
||||
this.source = { table, alias }
|
||||
} else {
|
||||
this.source = table
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `from()`.
|
||||
* @param table
|
||||
* @param alias
|
||||
*/
|
||||
table(table: string, alias?: string) {
|
||||
return this.from(table, alias)
|
||||
}
|
||||
|
||||
/**
|
||||
* Include the given field (and optional alias) in the query.
|
||||
* @param field
|
||||
* @param alias
|
||||
*/
|
||||
field(field: string | QuerySafeValue, alias?: string) {
|
||||
if ( alias ) {
|
||||
this._fields.push({ field, alias })
|
||||
} else {
|
||||
this._fields.push(field)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Include the given fields in the query.
|
||||
* @param fields
|
||||
*/
|
||||
fields(...fields: SpecifiedField[]): this {
|
||||
this._fields = [...this._fields, ...fields]
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `fields()`.
|
||||
* @param fields
|
||||
*/
|
||||
returning(...fields: SpecifiedField[]): this {
|
||||
return this.fields(...fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `fields()`.
|
||||
* @param fields
|
||||
*/
|
||||
select(...fields: SpecifiedField[]): this {
|
||||
return this.fields(...fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all selected fields from this query.
|
||||
*/
|
||||
clearFields() {
|
||||
this._fields = []
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
where(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('AND', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE constraint to the query, without escaping `operand`. Prefer `where()`.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
whereRaw(field: string, operator: ConstraintOperator, operand: string) {
|
||||
this.createConstraint('AND', field, operator, raw(operand))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE NOT constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
whereNot(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('AND NOT', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhere(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('OR', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE NOT constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhereNot(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('OR NOT', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE constraint to the query, without escaping `operand`. Prefer `orWhere()`.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhereRaw(field: string, operator: ConstraintOperator, operand: string) {
|
||||
this.createConstraint('OR', field, operator, raw(operand))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
operand: values,
|
||||
preop: 'AND',
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE NOT IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereNotIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
operand: values,
|
||||
preop: 'AND',
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
operand: values,
|
||||
preop: 'OR'
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE NOT IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereNotIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
operand: values,
|
||||
preop: 'OR'
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the query to a maximum number of rows.
|
||||
* @param rows
|
||||
*/
|
||||
limit(rows: number) {
|
||||
this._take = rows
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `limit()`.
|
||||
* @param rows
|
||||
*/
|
||||
take(rows: number) {
|
||||
return this.limit(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the first `rows` many rows in the result set.
|
||||
* @param rows
|
||||
*/
|
||||
skip(rows: number) {
|
||||
this._skip = rows
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `skip()`.
|
||||
* @param rows
|
||||
*/
|
||||
offset(rows: number) {
|
||||
return this.skip(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the query return only distinct rows.
|
||||
*/
|
||||
distinct() {
|
||||
this._distinct = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow the query to return non-distinct rows. (Undoes `distinct()`.)
|
||||
*/
|
||||
notDistinct() {
|
||||
this._distinct = false
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `skip()` and `take()` calls to retrieve the records that should appear on
|
||||
* the `pageNum` page, assuming each page has `pageSize` many records.
|
||||
* @param pageNum
|
||||
* @param pageSize
|
||||
*/
|
||||
page(pageNum: number = 1, pageSize: number = 20) {
|
||||
this.skip(pageSize * (pageNum - 1))
|
||||
this.take(pageSize)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one or more GROUP-BY clauses to the query.
|
||||
* @param groupings
|
||||
*/
|
||||
groupBy(...groupings: string[]) {
|
||||
this._groupings = groupings
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field.
|
||||
* @param field
|
||||
* @param direction
|
||||
*/
|
||||
orderBy(field: string, direction: OrderDirection = 'ASC') {
|
||||
this._orders.push({ field, direction })
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field, ascending.
|
||||
* @param field
|
||||
*/
|
||||
orderByAscending(field: string) {
|
||||
return this.orderBy(field, 'ASC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field, descending.
|
||||
* @param field
|
||||
*/
|
||||
orderByDescending(field: string) {
|
||||
return this.orderBy(field, 'DESC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the connection name or instance to execute the query on.
|
||||
* @param nameOrInstance
|
||||
*/
|
||||
connection(nameOrInstance: string | Connection) {
|
||||
if ( nameOrInstance instanceof Connection ) {
|
||||
this._connection = nameOrInstance
|
||||
} else {
|
||||
this._connection = this.databaseService.get(nameOrInstance)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a result iterable for the rows of this query.
|
||||
*/
|
||||
iterator(): AbstractResultIterable<T> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
|
||||
}
|
||||
|
||||
return this.getResultIterable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an async collection of the rows resulting from this query.
|
||||
*/
|
||||
get(): ResultCollection<T> {
|
||||
return new ResultCollection<T>(this.iterator())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first record matched by this query, if it exists.
|
||||
*/
|
||||
async first(): Promise<T | undefined> {
|
||||
return this.iterator().at(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an UPDATE query for all rows matched by this query, setting the given data.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* query.table('my_table').update({ my_col: 4 })
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* UPDATE TO my_table
|
||||
* SET
|
||||
* my_col = 4
|
||||
* ```
|
||||
*
|
||||
* @param data
|
||||
*/
|
||||
async update(data: {[key: string]: EscapeValue}): Promise<QueryResult> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderUpdate(this, data)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a DELETE based on this query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* query.table('my_table').where('id', <, 44).delete()
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* DELETE
|
||||
* FROM my_table
|
||||
* WHERE
|
||||
* id < 44
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
async delete(): Promise<QueryResult> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderDelete(this)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given rows into the table for this query, returning the fields specified in this query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const rows = [
|
||||
* { name: 'A' },
|
||||
* { name: 'B' },
|
||||
* ]
|
||||
*
|
||||
* query.table('my_table')
|
||||
* .returning('id', 'name')
|
||||
* .insert(rows)
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* INSERT INTO my_table (name)
|
||||
* VALUES ('A'), ('B')
|
||||
* RETURNING id, name
|
||||
* ```
|
||||
*
|
||||
* @param rowOrRows
|
||||
*/
|
||||
async insert(rowOrRows: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[]) {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderInsert(this, rowOrRows)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one row matches the current query.
|
||||
*/
|
||||
async exists() {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderExistential(this)
|
||||
const result = await this._connection.query(query)
|
||||
return !!result.rows.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
|
||||
* @param preop
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
* @private
|
||||
*/
|
||||
private createConstraint(preop: ConstraintConnectionOperator, field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: any) {
|
||||
if ( typeof field === 'function' ) {
|
||||
const builder = this.getNewInstance()
|
||||
field(builder)
|
||||
this.constraints.push({
|
||||
preop,
|
||||
items: builder.appliedConstraints
|
||||
})
|
||||
} else if ( field && operator && typeof operand !== 'undefined' ) {
|
||||
this.constraints.push({
|
||||
field, operator, operand, preop, // FIXME escape operand
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/orm/builder/Builder.ts
Normal file
23
src/orm/builder/Builder.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {ErrorWithContext} from "../../util";
|
||||
import {Container} from "../../di";
|
||||
import {ResultIterable} from "./result/ResultIterable";
|
||||
import {QueryRow} from "../types";
|
||||
import {AbstractBuilder} from "./AbstractBuilder";
|
||||
import {AbstractResultIterable} from "./result/AbstractResultIterable";
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder class that returns simple QueryRow objects.
|
||||
*/
|
||||
export class Builder extends AbstractBuilder<QueryRow> {
|
||||
public getNewInstance(): AbstractBuilder<QueryRow> {
|
||||
return Container.getContainer().make<Builder>(Builder);
|
||||
}
|
||||
|
||||
public getResultIterable(): AbstractResultIterable<QueryRow> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
|
||||
}
|
||||
|
||||
return Container.getContainer().make<ResultIterable>(ResultIterable, this, this._connection)
|
||||
}
|
||||
}
|
||||
49
src/orm/builder/result/AbstractResultIterable.ts
Normal file
49
src/orm/builder/result/AbstractResultIterable.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {Collection, Iterable} from "../../../util"
|
||||
import {Connection} from "../../connection/Connection";
|
||||
import {AbstractBuilder} from "../AbstractBuilder";
|
||||
|
||||
/**
|
||||
* Base Iterable class that generates the results of a Builder query.
|
||||
*/
|
||||
export abstract class AbstractResultIterable<T> extends Iterable<T> {
|
||||
protected constructor(
|
||||
/** The builder whose results should be iterated */
|
||||
public readonly builder: AbstractBuilder<T>,
|
||||
|
||||
/** The connection on which to execute the builder. */
|
||||
public readonly connection: Connection,
|
||||
) { super() }
|
||||
|
||||
/**
|
||||
* Get the SQL string for the SELECT query for this iterable.
|
||||
*/
|
||||
public abstract get selectSQL(): string
|
||||
|
||||
/**
|
||||
* Get the result at index i.
|
||||
* @param i
|
||||
*/
|
||||
public abstract at(i: number): Promise<T | undefined>
|
||||
|
||||
/**
|
||||
* Get the results starting at index `start` and ending at index `end`.
|
||||
* @param start
|
||||
* @param end
|
||||
*/
|
||||
public abstract range(start: number, end: number): Promise<Collection<T>>
|
||||
|
||||
/**
|
||||
* Count the number of results of the query.
|
||||
*/
|
||||
public abstract count(): Promise<number>
|
||||
|
||||
/**
|
||||
* Return all items resulting from this query.
|
||||
*/
|
||||
public abstract all(): Promise<Collection<T>>
|
||||
|
||||
/**
|
||||
* Create a new iterable based on this query.
|
||||
*/
|
||||
public abstract clone(): AbstractResultIterable<T>
|
||||
}
|
||||
17
src/orm/builder/result/ResultCollection.ts
Normal file
17
src/orm/builder/result/ResultCollection.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {AsyncCollection} from "../../../util";
|
||||
import {AbstractResultIterable} from "./AbstractResultIterable";
|
||||
|
||||
/**
|
||||
* Async collection class that iterates AbstractResultIterables in chunks.
|
||||
*/
|
||||
export class ResultCollection<T> extends AsyncCollection<T> {
|
||||
constructor(
|
||||
/** The result iterable to base the collection on. */
|
||||
iterator: AbstractResultIterable<T>,
|
||||
|
||||
/** The max number of records to request per-query, by default. */
|
||||
chunkSize: number = 500
|
||||
) {
|
||||
super(iterator, chunkSize)
|
||||
}
|
||||
}
|
||||
44
src/orm/builder/result/ResultIterable.ts
Normal file
44
src/orm/builder/result/ResultIterable.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {QueryRow} from "../../types";
|
||||
import {Builder} from "../Builder";
|
||||
import {Connection} from "../../connection/Connection";
|
||||
import {AbstractResultIterable} from "./AbstractResultIterable";
|
||||
import {Collection} from "../../../util";
|
||||
|
||||
/**
|
||||
* Implementation of AbstractResultIterable that yields simple QueryRow instances (objects).
|
||||
*/
|
||||
export class ResultIterable extends AbstractResultIterable<QueryRow> {
|
||||
constructor(
|
||||
public readonly builder: Builder,
|
||||
public readonly connection: Connection,
|
||||
) { super(builder, connection) }
|
||||
|
||||
public get selectSQL() {
|
||||
return this.connection.dialect().renderSelect(this.builder)
|
||||
}
|
||||
|
||||
async at(i: number): Promise<QueryRow | undefined> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, i, i + 1)
|
||||
return (await this.connection.query(query)).rows.first()
|
||||
}
|
||||
|
||||
async range(start: number, end: number): Promise<Collection<QueryRow>> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
||||
return (await this.connection.query(query)).rows
|
||||
}
|
||||
|
||||
async count() {
|
||||
const query = this.connection.dialect().renderCount(this.selectSQL)
|
||||
const result = (await this.connection.query(query)).rows.first()
|
||||
return result?.extollo_render_count ?? 0
|
||||
}
|
||||
|
||||
async all(): Promise<Collection<QueryRow>> {
|
||||
const result = await this.connection.query(this.selectSQL)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ResultIterable(this.builder, this.connection)
|
||||
}
|
||||
}
|
||||
65
src/orm/connection/Connection.ts
Normal file
65
src/orm/connection/Connection.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {Collection, ErrorWithContext} from "../../util";
|
||||
import {QueryResult} from "../types";
|
||||
import {SQLDialect} from "../dialect/SQLDialect";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
||||
/**
|
||||
* Error thrown when a connection is used before it is ready.
|
||||
* @extends Error
|
||||
*/
|
||||
export class ConnectionNotReadyError extends ErrorWithContext {
|
||||
constructor(name = '', context: {[key: string]: any} = {}) {
|
||||
super(`The connection ${name} is not ready and cannot execute queries.`)
|
||||
this.context = context
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for database connections.
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class Connection extends AppClass {
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The name of this connection
|
||||
* @type string
|
||||
*/
|
||||
public readonly name: string,
|
||||
/**
|
||||
* This connection's config object
|
||||
*/
|
||||
public readonly config: any = {},
|
||||
) { super() }
|
||||
|
||||
public abstract dialect(): SQLDialect
|
||||
|
||||
/**
|
||||
* Open the connection.
|
||||
* @return Promise<void>
|
||||
*/
|
||||
public abstract init(): Promise<void>
|
||||
|
||||
/**
|
||||
* Execute an SQL query and get the result.
|
||||
* @param {string} query
|
||||
* @return Promise<QueryResult>
|
||||
*/
|
||||
public abstract query(query: string): Promise<QueryResult>
|
||||
|
||||
/**
|
||||
* Close the connection.
|
||||
* @return Promise<void>
|
||||
*/
|
||||
public abstract close(): Promise<void>
|
||||
|
||||
// public abstract databases(): Promise<Collection<Database>>
|
||||
|
||||
// public abstract database(name: string): Promise<Database | undefined>
|
||||
|
||||
// public abstract database_as_schema(name: string): Promise<Database>
|
||||
|
||||
// public abstract tables(database_name: string): Promise<Collection<Table>>
|
||||
|
||||
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
|
||||
}
|
||||
66
src/orm/connection/PostgresConnection.ts
Normal file
66
src/orm/connection/PostgresConnection.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {Connection, ConnectionNotReadyError} from "./Connection";
|
||||
import {Client} from "pg";
|
||||
import {Inject} from "../../di";
|
||||
import {QueryResult} from "../types";
|
||||
import {collect} from "../../util";
|
||||
import {SQLDialect} from "../dialect/SQLDialect";
|
||||
import {PostgreSQLDialect} from "../dialect/PostgreSQLDialect";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Type interface representing the config for a PostgreSQL connection.
|
||||
*/
|
||||
export interface PostgresConnectionConfig {
|
||||
user: string,
|
||||
host: string,
|
||||
database: string,
|
||||
password?: string,
|
||||
port?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of a database Connection for dealing with PostgreSQL servers.
|
||||
*/
|
||||
export class PostgresConnection extends Connection {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The `pg` database client. */
|
||||
protected client?: Client
|
||||
|
||||
public dialect(): SQLDialect {
|
||||
return <PostgreSQLDialect> this.app().make(PostgreSQLDialect)
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.logging.debug(`Initializing PostgreSQL connection ${this.name}...`)
|
||||
this.client = new Client(this.config)
|
||||
await this.client.connect()
|
||||
}
|
||||
|
||||
public async close() {
|
||||
this.logging.debug(`Closing PostgreSQL connection ${this.name}...`)
|
||||
if ( this.client ) {
|
||||
await this.client.end()
|
||||
}
|
||||
}
|
||||
|
||||
public async query(query: string): Promise<QueryResult> {
|
||||
if ( !this.client ) throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(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.query(query)
|
||||
|
||||
return {
|
||||
rows: collect(result.rows),
|
||||
rowCount: result.rowCount,
|
||||
}
|
||||
} catch (e) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
270
src/orm/dialect/PostgreSQLDialect.ts
Normal file
270
src/orm/dialect/PostgreSQLDialect.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
169
src/orm/dialect/SQLDialect.ts
Normal file
169
src/orm/dialect/SQLDialect.ts
Normal 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;
|
||||
}
|
||||
29
src/orm/index.ts
Normal file
29
src/orm/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export * from './builder/result/AbstractResultIterable'
|
||||
export * from './builder/result/ResultCollection'
|
||||
export * from './builder/result/ResultIterable'
|
||||
|
||||
export * from './builder/AbstractBuilder'
|
||||
export * from './builder/Builder'
|
||||
|
||||
export * from './connection/Connection'
|
||||
export * from './connection/PostgresConnection'
|
||||
|
||||
export * from './dialect/SQLDialect'
|
||||
export * from './dialect/PostgreSQLDialect'
|
||||
|
||||
export * from './model/Field'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelResultIterable'
|
||||
export * from './model/Model'
|
||||
|
||||
export * from './services/Database'
|
||||
export * from './services/Models'
|
||||
|
||||
export * from './support/SessionModel'
|
||||
export * from './support/ORMSession'
|
||||
export * from './support/CacheModel'
|
||||
export * from './support/ORMCache'
|
||||
|
||||
export * from './DatabaseService'
|
||||
export * from './types'
|
||||
78
src/orm/model/Field.ts
Normal file
78
src/orm/model/Field.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {Collection} from "../../util";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/** The reflection metadata key containing information about the model's fields. */
|
||||
export const EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY = 'extollo:orm:Field.ts'
|
||||
|
||||
/**
|
||||
* Abstract representation of a field on a model.
|
||||
*/
|
||||
export interface ModelField {
|
||||
databaseKey: string,
|
||||
modelKey: string | symbol,
|
||||
type: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a collection of ModelField metadata from the given model.
|
||||
* @param model
|
||||
*/
|
||||
export function getFieldsMeta(model: any): Collection<ModelField> {
|
||||
const fields = Reflect.getMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, model.constructor)
|
||||
if ( !(fields instanceof Collection) ) {
|
||||
return new Collection<ModelField>()
|
||||
}
|
||||
|
||||
return fields as Collection<ModelField>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the collection of ModelField metadata as the field data for the given model.
|
||||
* @param model
|
||||
* @param fields
|
||||
*/
|
||||
export function setFieldsMeta(model: any, fields: Collection<ModelField>) {
|
||||
Reflect.defineMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, fields, model.constructor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that maps the given property to a database column of the specified FieldType.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyModel extends Model<MyModel> {
|
||||
* // Maps the 'name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar)
|
||||
* public name!: string
|
||||
*
|
||||
* // Maps the 'first_name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar, 'first_name')
|
||||
* public firstName!: string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param type
|
||||
* @param databaseKey
|
||||
* @constructor
|
||||
*/
|
||||
export function Field(type: FieldType, databaseKey?: string): PropertyDecorator {
|
||||
return (target, modelKey) => {
|
||||
if ( !databaseKey ) databaseKey = String(modelKey)
|
||||
const fields = getFieldsMeta(target)
|
||||
|
||||
const existingField = fields.firstWhere('modelKey', '=', modelKey)
|
||||
if ( existingField ) {
|
||||
existingField.databaseKey = databaseKey
|
||||
existingField.type = type
|
||||
return setFieldsMeta(target, fields)
|
||||
}
|
||||
|
||||
fields.push({
|
||||
databaseKey,
|
||||
modelKey,
|
||||
type,
|
||||
})
|
||||
|
||||
setFieldsMeta(target, fields)
|
||||
}
|
||||
}
|
||||
813
src/orm/model/Model.ts
Normal file
813
src/orm/model/Model.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
import {ModelKey, QueryRow, QuerySource} from "../types";
|
||||
import {Container, Inject} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {ModelBuilder} from "./ModelBuilder";
|
||||
import {getFieldsMeta, ModelField} from "./Field";
|
||||
import {deepCopy, BehaviorSubject, Pipe, Collection} from "../../util";
|
||||
import {EscapeValueObject} from "../dialect/SQLDialect";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Base for classes that are mapped to tables in a database.
|
||||
*/
|
||||
export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
|
||||
/**
|
||||
* The name of the connection this model should run through.
|
||||
* @type string
|
||||
*/
|
||||
protected static connection: string = 'default'
|
||||
|
||||
/**
|
||||
* The name of the table this model is stored in.
|
||||
* @type string
|
||||
*/
|
||||
protected static table: string
|
||||
|
||||
/**
|
||||
* The name of the column that uniquely identifies this model.
|
||||
* @type string
|
||||
*/
|
||||
protected static key: string
|
||||
|
||||
/**
|
||||
* If false (default), the primary key will be excluded from INSERTs.
|
||||
*/
|
||||
protected static populateKeyOnInsert: boolean = false
|
||||
|
||||
/**
|
||||
* Optionally, the timestamp field set on creation.
|
||||
* @type string
|
||||
*/
|
||||
protected static readonly CREATED_AT: string | null = 'created_at'
|
||||
|
||||
/**
|
||||
* Optionally, the timestamp field set op update.
|
||||
* @type string
|
||||
*/
|
||||
protected static readonly UPDATED_AT: string | null = 'updated_at'
|
||||
|
||||
/**
|
||||
* If true, the CREATED_AT and UPDATED_AT columns will be automatically set.
|
||||
* @type boolean
|
||||
*/
|
||||
protected static timestamps = true
|
||||
|
||||
/**
|
||||
* Array of additional fields on the class that should
|
||||
* be included in the object serializations.
|
||||
* @type string[]
|
||||
*/
|
||||
protected static appends: string[] = []
|
||||
|
||||
/**
|
||||
* Array of fields on the class that should be excluded
|
||||
* from the object serializations.
|
||||
* @type string[]
|
||||
*/
|
||||
protected static masks: string[] = []
|
||||
|
||||
/**
|
||||
* The original row fetched from the database.
|
||||
* @protected
|
||||
*/
|
||||
protected _original?: QueryRow
|
||||
|
||||
/**
|
||||
* Behavior subject that fires after the model is populated.
|
||||
*/
|
||||
protected retrieved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is saved.
|
||||
*/
|
||||
protected saving$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is saved.
|
||||
*/
|
||||
protected saved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is updated.
|
||||
*/
|
||||
protected updating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is updated.
|
||||
*/
|
||||
protected updated$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is inserted.
|
||||
*/
|
||||
protected creating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is inserted.
|
||||
*/
|
||||
protected created$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is deleted.
|
||||
*/
|
||||
protected deleting$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is deleted.
|
||||
*/
|
||||
protected deleted$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Get the table name for this model.
|
||||
*/
|
||||
public static tableName() {
|
||||
return this.table
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the QuerySource object for this model as it should be applied to query builders.
|
||||
*
|
||||
* This sets the alias for the model table equal to the table name itself, so it can be
|
||||
* referenced explicitly in queries if necessary.
|
||||
*/
|
||||
public static querySource(): QuerySource {
|
||||
return {
|
||||
table: this.table,
|
||||
alias: this.table,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the connection where this model's table is found.
|
||||
*/
|
||||
public static connectionName() {
|
||||
return this.connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database connection instance for this model's connection.
|
||||
*/
|
||||
public static getConnection() {
|
||||
return Container.getContainer().make<DatabaseService>(DatabaseService).get(this.connectionName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new query builder that yields instances of this model,
|
||||
* pre-configured with this model's QuerySource, connection, and fields.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const user = await UserModel.query<UserModel>().where('name', 'LIKE', 'John Doe').first()
|
||||
* ```
|
||||
*/
|
||||
public static query<T2 extends Model<T2>>() {
|
||||
const builder = <ModelBuilder<T2>> Container.getContainer().make<ModelBuilder<T2>>(ModelBuilder, this)
|
||||
const source: QuerySource = this.querySource()
|
||||
|
||||
builder.connection(this.getConnection())
|
||||
|
||||
if ( typeof source === 'string' ) builder.from(source)
|
||||
else builder.from(source.table, source.alias)
|
||||
|
||||
getFieldsMeta(this.prototype).each(field => {
|
||||
builder.field(field.databaseKey)
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* Pre-fill the model's properties from the given values.
|
||||
* Calls `boot()` under the hood.
|
||||
*/
|
||||
values?: {[key: string]: any}
|
||||
) {
|
||||
super()
|
||||
this.boot(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model's properties from the given values and do any other initial setup.
|
||||
*
|
||||
* `values` can optionally be an object mapping model properties to the values of those
|
||||
* properties. Only properties with `@Field()` annotations will be set.
|
||||
*
|
||||
* @param values
|
||||
*/
|
||||
public boot(values?: any) {
|
||||
if ( values ) {
|
||||
getFieldsMeta(this).each(field => {
|
||||
this.setFieldFromObject(field.modelKey, String(field.modelKey), values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a row from the database, set the properties on this model that correspond to
|
||||
* fields on that database.
|
||||
*
|
||||
* The `row` maps database fields to values, and the values are set for the properties
|
||||
* that they correspond to based on the model's `@Field()` annotations.
|
||||
*
|
||||
* @param row
|
||||
*/
|
||||
public async assumeFromSource(row: QueryRow) {
|
||||
this._original = row
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
||||
})
|
||||
|
||||
await this.retrieved$.next(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to assumeFromSource, but instead of mapping database fields to model
|
||||
* properties, this function assumes the `object` contains a mapping of model properties
|
||||
* to the values of those properties.
|
||||
*
|
||||
* Only properties with `@Field()` annotations will be set.
|
||||
*
|
||||
* @param object
|
||||
*/
|
||||
public async assume(object: { [key: string]: any }) {
|
||||
getFieldsMeta(this).each(field => {
|
||||
if ( field.modelKey in object ) {
|
||||
this.setFieldFromObject(field.modelKey, String(field.modelKey), object)
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the primary key of this model, if it exists.
|
||||
*/
|
||||
public key() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', ctor.key)
|
||||
|
||||
if ( field ) {
|
||||
// @ts-ignore
|
||||
return this[field.modelKey]
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return this[ctor.key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this instance's record has been persisted into the database.
|
||||
*/
|
||||
public exists() {
|
||||
return !!this._original && !!this.key()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized values of the configured CREATED_AT/UPDATED_AT fields for this model.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* user.timestamps() // => {updated: Date, created: Date}
|
||||
* ```
|
||||
*/
|
||||
public timestamps(): { updated?: Date, created?: Date } {
|
||||
const ctor = this.constructor as typeof Model
|
||||
const timestamps: { updated?: Date, created?: Date } = {}
|
||||
|
||||
if ( ctor.timestamps ) {
|
||||
// @ts-ignore
|
||||
if ( ctor.CREATED_AT ) timestamps.created = this[ctor.CREATED_AT]
|
||||
|
||||
// @ts-ignore
|
||||
if ( ctor.UPDATED_AT ) timestamps.updated = this[ctor.UPDATED_AT]
|
||||
}
|
||||
|
||||
return timestamps
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new query builder that yields instances of this model,
|
||||
* pre-configured with this model's QuerySource, connection, and fields.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await user.query()
|
||||
* .where('name', 'LIKE', 'John Doe')
|
||||
* .update({ username: 'jdoe' })
|
||||
* ```
|
||||
*/
|
||||
public query(): ModelBuilder<T> {
|
||||
const ModelClass = this.constructor as typeof Model
|
||||
const builder = <ModelBuilder<T>> this.app().make<ModelBuilder<T>>(ModelBuilder, ModelClass)
|
||||
const source: QuerySource = ModelClass.querySource()
|
||||
|
||||
builder.connection(ModelClass.getConnection())
|
||||
|
||||
if ( typeof source === 'string' ) builder.from(source)
|
||||
else builder.from(source.table, source.alias)
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
builder.field(field.databaseKey)
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first instance of this model where the primary key matches `key`.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const user = await UserModel.findByKey(45)
|
||||
* ```
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
public static async findByKey<T2 extends Model<T2>>(key: ModelKey): Promise<undefined | T2> {
|
||||
return this.query<T2>()
|
||||
.where(this.qualifyKey(), '=', key)
|
||||
.limit(1)
|
||||
.get()
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all instances of this model.
|
||||
*/
|
||||
public async all() {
|
||||
return this.query().get().all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all instances of this model in the database.
|
||||
*/
|
||||
public async count(): Promise<number> {
|
||||
return this.query().get().count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a column, return the qualified name of the column as it
|
||||
* could appear in a query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* modelInstance.qualify('id') // => 'model_table_name.id'
|
||||
* ```
|
||||
*
|
||||
* @param column
|
||||
*/
|
||||
public qualify(column: string) {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return `${ctor.tableName()}.${column}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the qualified name of the column corresponding to the model's primary key.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A extends Model<A> {
|
||||
* protected static table = 'table_a'
|
||||
* protected static key = 'a_id'
|
||||
* }
|
||||
*
|
||||
* const a = new A()
|
||||
* a.qualifyKey() // => 'table_a.a_id'
|
||||
* ```
|
||||
*/
|
||||
public qualifyKey() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return this.qualify(ctor.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a column, return the qualified name of the column as it
|
||||
* could appear in a query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* SomeModel.qualify('col_name') // => 'model_table_name.col_name'
|
||||
* ```
|
||||
*
|
||||
* @param column
|
||||
*/
|
||||
public static qualify(column: string) {
|
||||
return `${this.tableName()}.${column}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the qualified name of the column corresponding to the model's primary key.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A extends Model<A> {
|
||||
* protected static table = 'table_a'
|
||||
* protected static key = 'a_id'
|
||||
* }
|
||||
*
|
||||
* A.qualifyKey() // => 'table_a.a_id'
|
||||
* ```
|
||||
*/
|
||||
public static qualifyKey() {
|
||||
return this.qualify(this.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a property on the model with a `@Field()` annotation,
|
||||
* return the unqualified name of the database column it corresponds to.
|
||||
* @param modelKey
|
||||
*/
|
||||
public static propertyToColumn(modelKey: string) {
|
||||
return getFieldsMeta(this)
|
||||
.firstWhere('modelKey', '=', modelKey)?.databaseKey || modelKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unqualified name of the column corresponding to the primary key of this model.
|
||||
*/
|
||||
public keyName() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return ctor.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the model to the base QueryRow object. The resultant object maps
|
||||
* DATABASE fields to values, NOT MODEL fields to values.
|
||||
*
|
||||
* Only fields with `@Field()` annotations will be included.
|
||||
*/
|
||||
public toQueryRow(): QueryRow {
|
||||
const row = {}
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
// @ts-ignore
|
||||
row[field.databaseKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query row mapping database columns to values for properties on this
|
||||
* model that (1) have `@Field()` annotations and (2) have been modified since
|
||||
* the record was fetched from the database or created.
|
||||
*/
|
||||
public dirtyToQueryRow(): QueryRow {
|
||||
const row = {}
|
||||
|
||||
getFieldsMeta(this)
|
||||
.filter(this._isDirty)
|
||||
.each(field => {
|
||||
// @ts-ignore
|
||||
row[field.databaseKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object of the database field => value mapping that was originally
|
||||
* fetched from the database. Excludes changes to model properties.
|
||||
*/
|
||||
public getOriginalValues() {
|
||||
return deepCopy(this._original)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an object of only the given properties on this model.
|
||||
*
|
||||
* @example
|
||||
* Assume `a` is an instance of some model `A` with the given fields.
|
||||
* ```typescript
|
||||
* const a = new A({ field1: 'field1 value', field2: 'field2 value', id: 123 })
|
||||
*
|
||||
* a.only('field1', 'id) // => {field1: 'field1 value', id: 123}
|
||||
* ```
|
||||
*
|
||||
* @param fields
|
||||
*/
|
||||
public only(...fields: string[]) {
|
||||
const row = {}
|
||||
|
||||
for ( const field of fields ) {
|
||||
// @ts-ignore
|
||||
row[field] = this[field]
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any of the fields on this model have been modified since they
|
||||
* were fetched from the database (or ones that were never saved to the database).
|
||||
*
|
||||
* Only fields with `@Field()` annotations are checked.
|
||||
*/
|
||||
public isDirty() {
|
||||
return getFieldsMeta(this).some(this._isDirty)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if none of the fields on this model have been modified since they
|
||||
* were fetched from the database (and all exist in the database).
|
||||
*
|
||||
* Only fields with `@Field()` annotations are checked.
|
||||
*/
|
||||
public isClean() {
|
||||
return !this.isDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given field has changed since this model was fetched from
|
||||
* the database, or if the given field never existed in the database.
|
||||
* @param field
|
||||
*/
|
||||
public wasChanged(field: string) {
|
||||
// @ts-ignore
|
||||
return getFieldsMeta(this).pluck('modelKey').includes(field) && this[field] !== this._original[field]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of MODEL fields that have been modified since this record
|
||||
* was fetched from the database or created.
|
||||
*/
|
||||
public getDirtyFields() {
|
||||
return getFieldsMeta(this)
|
||||
.filter(this._isDirty)
|
||||
.pluck('modelKey')
|
||||
.toArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the timestamps for this model, if they are configured.
|
||||
*
|
||||
* If the model doesn't yet exist, set the CREATED_AT date. Always
|
||||
* sets the UPDATED_AT date.
|
||||
*/
|
||||
public touch() {
|
||||
const constructor = (this.constructor as typeof Model)
|
||||
if ( constructor.timestamps ) {
|
||||
if ( constructor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[constructor.UPDATED_AT] = new Date()
|
||||
}
|
||||
|
||||
if ( !this.exists() && constructor.CREATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[constructor.CREATED_AT] = new Date()
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the model into the database. If the model already exists, perform an
|
||||
* update on its fields. Otherwise, insert a new row with its fields.
|
||||
*
|
||||
* Passing the `withoutTimestamps` will prevent the configured CREATED_AT/UPDATED_AT
|
||||
* timestamps from being updated.
|
||||
*
|
||||
* @param withoutTimestamps
|
||||
*/
|
||||
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
||||
await this.saving$.next(this)
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
if ( this.exists() && this.isDirty() ) {
|
||||
await this.updating$.next(this)
|
||||
|
||||
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.UPDATED_AT] = new Date()
|
||||
}
|
||||
|
||||
const result = await this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.clearFields()
|
||||
.returning(...this.getLoadedDatabaseFields())
|
||||
.update(this.dirtyToQueryRow())
|
||||
|
||||
if ( result.rowCount !== 1 ) {
|
||||
this.logging.warn(`Model update modified ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`)
|
||||
}
|
||||
|
||||
const data = result.rows.firstWhere(this.keyName(), '=', this.key())
|
||||
if ( data ) await this.assumeFromSource(data)
|
||||
|
||||
await this.updated$.next(this)
|
||||
} else if ( !this.exists() ) {
|
||||
await this.creating$.next(this)
|
||||
|
||||
if ( !withoutTimestamps ) {
|
||||
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.CREATED_AT] = new Date()
|
||||
}
|
||||
|
||||
if ( ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.UPDATED_AT] = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const row = this._buildInsertFieldObject()
|
||||
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||
|
||||
const result = await this.query()
|
||||
.clearFields()
|
||||
.returning(...returnable.unique().toArray())
|
||||
.insert(row)
|
||||
|
||||
if ( result.rowCount !== 1 ) {
|
||||
this.logging.warn(`Model insert created ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`)
|
||||
}
|
||||
|
||||
const data = result.rows.first()
|
||||
if ( data ) await this.assumeFromSource(result)
|
||||
await this.created$.next(this)
|
||||
}
|
||||
|
||||
await this.saved$.next(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast this model to a simple object mapping model fields to their values.
|
||||
*
|
||||
* Only fields with `@Field()` annotations are included.
|
||||
*/
|
||||
public toObject(): { [key: string]: any } {
|
||||
const ctor = this.constructor as typeof Model
|
||||
const obj = {}
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
// @ts-ignore
|
||||
obj[field.modelKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
ctor.appends.forEach(field => {
|
||||
// @ts-ignore
|
||||
obj[field] = this[field]
|
||||
})
|
||||
|
||||
ctor.masks.forEach(field => {
|
||||
// @ts-ignore
|
||||
delete obj[field]
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the model to an JSON string object.
|
||||
*
|
||||
* Only fields with `@Field()` annotations are included.
|
||||
*/
|
||||
public toJSON(): string {
|
||||
return JSON.stringify(this.toObject())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh instance of this record from the database.
|
||||
*
|
||||
* This returns a NEW instance of the SAME record by matching on
|
||||
* the primary key. It does NOT change the current instance of the record.
|
||||
*/
|
||||
public async fresh(): Promise<Model<T> | undefined> {
|
||||
return this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.limit(1)
|
||||
.get()
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-load the currently-loaded database fields from the table.
|
||||
*
|
||||
* Overwrites any un-persisted changes in the current instance.
|
||||
*/
|
||||
public async refresh() {
|
||||
const results = this.query()
|
||||
.clearFields()
|
||||
.fields(...this.getLoadedDatabaseFields())
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.limit(1)
|
||||
.get()
|
||||
|
||||
const row = await results.first()
|
||||
if ( row ) await this.assumeFromSource(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates an instance of the model with the same database fields that
|
||||
* are set on this model, with the exclusion of the primary key.
|
||||
*
|
||||
* Useful for inserting copies of records.
|
||||
*
|
||||
* @example
|
||||
* Assume a record, `a`, is an instance of some model `A` with the given fields.
|
||||
*
|
||||
* ```typescript
|
||||
* const a = A.find(123) // => A{id: 123, name: 'some_name', other_field: 'a value'}
|
||||
*
|
||||
* const b = a.populate(new A) // => A{name: 'some_name', other_field: 'a value'}
|
||||
* ```
|
||||
*
|
||||
* @param model
|
||||
*/
|
||||
public async populate(model: T): Promise<T> {
|
||||
const row = this.toQueryRow()
|
||||
delete row[this.keyName()]
|
||||
await model.assumeFromSource(row)
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the `other` model refers to the same database record as this instance.
|
||||
*
|
||||
* This is done by comparing the qualified primary keys.
|
||||
*
|
||||
* @param other
|
||||
*/
|
||||
public is(other: Model<any>): boolean {
|
||||
return this.key() === other.key() && this.qualifyKey() === other.qualifyKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `is()`.
|
||||
* @param other
|
||||
*/
|
||||
public isNot(other: Model<any>): boolean {
|
||||
return !this.is(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Pipe instance containing this model instance.
|
||||
*/
|
||||
public pipe(): Pipe<this> {
|
||||
return Pipe.wrap(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a wrapped function that compares whether the given model field
|
||||
* on the current instance differs from the originally fetched value.
|
||||
*
|
||||
* Used to filter for dirty fields.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected get _isDirty() {
|
||||
return (field: ModelField) => {
|
||||
// @ts-ignore
|
||||
return this[field.modelKey] !== this._original[field.databaseKey]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of DATABASE fields that have been loaded for the current instance.
|
||||
* @protected
|
||||
*/
|
||||
protected getLoadedDatabaseFields(): string[] {
|
||||
if ( !this._original ) return []
|
||||
return Object.keys(this._original).map(String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an object mapping database fields to the values that should be inserted for them.
|
||||
* @private
|
||||
*/
|
||||
private _buildInsertFieldObject(): EscapeValueObject {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
return getFieldsMeta(this)
|
||||
.pipe()
|
||||
.unless(ctor.populateKeyOnInsert, fields => {
|
||||
return fields.where('modelKey', '!=', this.keyName())
|
||||
})
|
||||
.get()
|
||||
// @ts-ignore
|
||||
.keyMap('databaseKey', inst => this[inst.modelKey])
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a property on `this` to the value of a given property in `object`.
|
||||
* @param this_field_name
|
||||
* @param object_field_name
|
||||
* @param object
|
||||
* @protected
|
||||
*/
|
||||
protected setFieldFromObject(this_field_name: string | symbol, object_field_name: string, object: { [key: string]: any }) {
|
||||
// @ts-ignore
|
||||
this[this_field_name] = object[object_field_name]
|
||||
}
|
||||
}
|
||||
25
src/orm/model/ModelBuilder.ts
Normal file
25
src/orm/model/ModelBuilder.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {Model} from "./Model";
|
||||
import {AbstractBuilder} from "../builder/AbstractBuilder";
|
||||
import {AbstractResultIterable} from "../builder/result/AbstractResultIterable";
|
||||
import {Instantiable} from "../../di";
|
||||
import {ModelResultIterable} from "./ModelResultIterable";
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
*/
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
constructor(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public getNewInstance(): AbstractBuilder<T> {
|
||||
return this.app().make<ModelBuilder<T>>(ModelBuilder)
|
||||
}
|
||||
|
||||
public getResultIterable(): AbstractResultIterable<T> {
|
||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this._connection, this.ModelClass)
|
||||
}
|
||||
}
|
||||
61
src/orm/model/ModelResultIterable.ts
Normal file
61
src/orm/model/ModelResultIterable.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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 {QueryRow} from "../types";
|
||||
import {Collection} from "../../util";
|
||||
|
||||
/**
|
||||
* Implementation of the result iterable that returns query results as instances of the defined model.
|
||||
*/
|
||||
export class ModelResultIterable<T extends Model<T>> extends AbstractResultIterable<T> {
|
||||
constructor(
|
||||
public readonly builder: ModelBuilder<T>,
|
||||
public readonly connection: Connection,
|
||||
/** The model that should be instantiated for each row. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) { super(builder, connection) }
|
||||
|
||||
public get selectSQL() {
|
||||
return this.connection.dialect().renderSelect(this.builder)
|
||||
}
|
||||
|
||||
async at(i: number) {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, i, i + 1)
|
||||
const row = (await this.connection.query(query)).rows.first()
|
||||
|
||||
if ( row ) {
|
||||
return this.inflateRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
async range(start: number, end: number): Promise<Collection<T>> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
||||
return (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
async count() {
|
||||
const query = this.connection.dialect().renderCount(this.selectSQL)
|
||||
const result = (await this.connection.query(query)).rows.first()
|
||||
return result?.extollo_render_count ?? 0
|
||||
}
|
||||
|
||||
async all(): Promise<Collection<T>> {
|
||||
const result = await this.connection.query(this.selectSQL)
|
||||
return result.rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a query row, create an instance of the configured model class from it.
|
||||
* @param row
|
||||
* @protected
|
||||
*/
|
||||
protected async inflateRow(row: QueryRow): Promise<T> {
|
||||
return Container.getContainer().make<T>(this.ModelClass).assumeFromSource(row)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
||||
}
|
||||
}
|
||||
62
src/orm/services/Database.ts
Normal file
62
src/orm/services/Database.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {Inject, Singleton} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {PostgresConnection} from "../connection/PostgresConnection";
|
||||
import {ErrorWithContext} from "../../util";
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
import {Config} from "../../service/Config";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Application unit responsible for loading and creating database connections from config.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Database extends Unit {
|
||||
@Inject()
|
||||
protected readonly config!: Config;
|
||||
|
||||
@Inject()
|
||||
protected readonly dbService!: DatabaseService;
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
|
||||
/**
|
||||
* Load the `database.connections` config and register Connection instances for each config.
|
||||
* Automatically initializes the connections.
|
||||
*/
|
||||
public async up() {
|
||||
const connections = this.config.get('database.connections')
|
||||
const promises = []
|
||||
|
||||
for ( const key in connections ) {
|
||||
if ( !connections.hasOwnProperty(key) ) continue
|
||||
const config = connections[key]
|
||||
|
||||
this.logging.info(`Initializing database connection: ${key}`)
|
||||
this.logging.verbose(config)
|
||||
|
||||
let conn
|
||||
if ( config?.dialect === 'postgres' ) {
|
||||
conn = <PostgresConnection> this.app().make(PostgresConnection, key, config)
|
||||
} else {
|
||||
const e = new ErrorWithContext(`Invalid or missing database dialect: ${config.dialect}. Should be one of: postgres`)
|
||||
e.context = { connectionName: key }
|
||||
throw e
|
||||
}
|
||||
|
||||
this.dbService.register(key, conn)
|
||||
promises.push(conn.init())
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
this.logging.info('Database connections opened.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the configured connections cleanly before exit.
|
||||
*/
|
||||
public async down() {
|
||||
await Promise.all(this.dbService.names()
|
||||
.map(name => this.dbService.get(name).close()))
|
||||
}
|
||||
}
|
||||
33
src/orm/services/Models.ts
Normal file
33
src/orm/services/Models.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Instantiable, Singleton, Inject} from "../../di";
|
||||
import {CommandLine} from "../../cli";
|
||||
import {model_template} from "../template/model";
|
||||
import {CanonicalStatic} from "../../service/CanonicalStatic";
|
||||
import {CanonicalDefinition} from "../../service/Canonical";
|
||||
|
||||
/**
|
||||
* Canonical unit responsible for loading the model classes defined by the application.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Models extends CanonicalStatic<Model<any>, Instantiable<Model<any>>> {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
protected appPath = ['models']
|
||||
protected canonicalItem = 'model'
|
||||
protected suffix = '.model.js'
|
||||
|
||||
public async up() {
|
||||
await super.up()
|
||||
this.cli.registerTemplate(model_template)
|
||||
}
|
||||
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<Instantiable<Model<any>>> {
|
||||
const item = await super.initCanonicalItem(definition)
|
||||
if ( !(item.prototype instanceof Model) ) {
|
||||
throw new TypeError(`Invalid controller definition: ${definition.originalName}. Models must extend from @extollo/orm.Model.`)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
20
src/orm/support/CacheModel.ts
Normal file
20
src/orm/support/CacheModel.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Field} from "../model/Field";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/**
|
||||
* A model instance which stores records from the ORMCache driver.
|
||||
*/
|
||||
export class CacheModel extends Model<CacheModel> {
|
||||
protected static table = 'caches'; // FIXME allow configuring
|
||||
protected static key = 'cache_key';
|
||||
|
||||
@Field(FieldType.varchar, 'cache_key')
|
||||
public cacheKey!: string;
|
||||
|
||||
@Field(FieldType.text, 'cache_value')
|
||||
public cacheValue!: string;
|
||||
|
||||
@Field(FieldType.timestamp, 'cache_expires')
|
||||
public cacheExpires?: Date;
|
||||
}
|
||||
45
src/orm/support/ORMCache.ts
Normal file
45
src/orm/support/ORMCache.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {Container} from "../../di"
|
||||
import {Cache} from "../../util"
|
||||
import {CacheModel} from "./CacheModel"
|
||||
|
||||
/**
|
||||
* A cache driver whose records are stored in a database table using the CacheModel.
|
||||
*/
|
||||
export class ORMCache extends Cache {
|
||||
public async fetch(key: string): Promise<string | undefined> {
|
||||
const model = await CacheModel.query<CacheModel>()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.first()
|
||||
|
||||
if ( model ) {
|
||||
return model.cacheValue
|
||||
}
|
||||
}
|
||||
|
||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||
let model = await CacheModel.findByKey<CacheModel>(key)
|
||||
if ( !model ) {
|
||||
model = <CacheModel> Container.getContainer().make(CacheModel)
|
||||
}
|
||||
|
||||
model.cacheKey = key
|
||||
model.cacheValue = value
|
||||
model.cacheExpires = expires
|
||||
|
||||
await model.save()
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
return CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.exists()
|
||||
}
|
||||
|
||||
public async drop(key: string): Promise<void> {
|
||||
await CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.delete()
|
||||
}
|
||||
}
|
||||
68
src/orm/support/ORMSession.ts
Normal file
68
src/orm/support/ORMSession.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {SessionModel} from "./SessionModel"
|
||||
import {Container} from "../../di"
|
||||
import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from "../../http/session/Session";
|
||||
|
||||
/**
|
||||
* An implementation of the Session driver whose records are stored in a database table.
|
||||
*/
|
||||
export class ORMSession extends Session {
|
||||
protected key?: string
|
||||
protected data?: SessionData
|
||||
protected session?: SessionModel
|
||||
|
||||
public getKey(): string {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
return this.key
|
||||
}
|
||||
|
||||
public setKey(key: string): void {
|
||||
this.key = key
|
||||
}
|
||||
|
||||
public async load() {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
const session = <SessionModel> await SessionModel.findByKey(this.key)
|
||||
if ( session ) {
|
||||
this.session = session
|
||||
this.data = this.session.json
|
||||
} else {
|
||||
this.session = <SessionModel> Container.getContainer().make(SessionModel)
|
||||
this.session.uuid = this.key
|
||||
this.data = {} as SessionData
|
||||
}
|
||||
}
|
||||
|
||||
public async persist() {
|
||||
if ( !this.key ) throw new NoSessionKeyError()
|
||||
if ( !this.data || !this.session ) throw new SessionNotLoadedError()
|
||||
|
||||
this.session.uuid = this.key
|
||||
this.session.json = JSON.stringify(this.data)
|
||||
await this.session.save()
|
||||
}
|
||||
|
||||
public getData() {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
return this.data
|
||||
}
|
||||
|
||||
public setData(data: SessionData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
public get(key: string, fallback?: any): any {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
return this.data[key] ?? fallback
|
||||
}
|
||||
|
||||
public set(key: string, value: any) {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
this.data[key] = value
|
||||
}
|
||||
}
|
||||
17
src/orm/support/SessionModel.ts
Normal file
17
src/orm/support/SessionModel.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Field} from "../model/Field";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/**
|
||||
* Model used to fetch & store sessions from the ORMSession driver.
|
||||
*/
|
||||
export class SessionModel extends Model<SessionModel> {
|
||||
protected static table = 'sessions'; // FIXME allow configuring
|
||||
protected static key = 'session_uuid';
|
||||
|
||||
@Field(FieldType.varchar, 'session_uuid')
|
||||
public uuid!: string;
|
||||
|
||||
@Field(FieldType.json, 'session_data')
|
||||
public json!: any;
|
||||
}
|
||||
30
src/orm/template/model.ts
Normal file
30
src/orm/template/model.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {Template} from "../../cli"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template for creating new database model classes in app/models.
|
||||
*/
|
||||
const model_template: Template = {
|
||||
name: 'model',
|
||||
fileSuffix: '.model.ts',
|
||||
baseAppPath: ['models'],
|
||||
description: 'Create a new class that represents a record in a database',
|
||||
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => {
|
||||
return `import {Model} from "@extollo/orm"
|
||||
import {Injectable} from "@extollo/di"
|
||||
|
||||
/**
|
||||
* ${name} Model
|
||||
* -----------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Model<${name}> {
|
||||
protected static table = '${name.toLowerCase()}';
|
||||
protected static key = '${name.toLowerCase()}_id';
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { model_template }
|
||||
148
src/orm/types.ts
Normal file
148
src/orm/types.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Collection } from '../util';
|
||||
import {EscapeValue, QuerySafeValue} from "./dialect/SQLDialect";
|
||||
|
||||
/**
|
||||
* A single query row, as an object.
|
||||
*/
|
||||
export type QueryRow = { [key: string]: any }
|
||||
|
||||
/**
|
||||
* A valid key on a model.
|
||||
*/
|
||||
export type ModelKey = string | number
|
||||
|
||||
/**
|
||||
* Interface for the result of a query execution.
|
||||
*/
|
||||
export interface QueryResult {
|
||||
rows: Collection<QueryRow>,
|
||||
rowCount: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL operator that is used to join two constraint clauses.
|
||||
*/
|
||||
export type ConstraintConnectionOperator = 'AND' | 'OR' | 'AND NOT' | 'OR NOT'
|
||||
|
||||
/**
|
||||
* SQL operator that appears in a constraint clause.
|
||||
*/
|
||||
export type ConstraintOperator = '&' | '>' | '>=' | '<' | '<=' | '!=' | '<=>' | '%' | '|' | '!' | '~' | '=' | '^' | 'IN' | 'NOT IN' | 'LIKE' | 'BETWEEN' | 'NOT BETWEEN' | 'IS' | 'IS NOT';
|
||||
|
||||
/**
|
||||
* Interface for storing the various parts of a single SQL constraint.
|
||||
*/
|
||||
export interface ConstraintItem {
|
||||
field: string,
|
||||
operator: ConstraintOperator,
|
||||
operand: EscapeValue,
|
||||
preop: ConstraintConnectionOperator,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for storing a group of constraints connected by the given connection operator.
|
||||
*/
|
||||
export interface ConstraintGroup {
|
||||
items: Constraint[],
|
||||
preop: ConstraintConnectionOperator,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given object is a valid ConstraintGroup.
|
||||
* @param what
|
||||
*/
|
||||
export function isConstraintGroup(what: any): what is ConstraintGroup {
|
||||
return typeof what === 'object' && Array.isArray(what.items) && what.preop
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given object is a valid ConstraintItem
|
||||
* @param what
|
||||
*/
|
||||
export function isConstraintItem(what: any): what is ConstraintItem {
|
||||
return typeof what === 'object' && what.field && what.operator && what.operand && what.preop
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for something that can be either a single constraint or a group of them.
|
||||
*/
|
||||
export type Constraint = ConstraintItem | ConstraintGroup
|
||||
|
||||
/**
|
||||
* Type alias for an item that refers to a field on a table.
|
||||
*/
|
||||
export type SpecifiedField = string | QuerySafeValue | { field: string | QuerySafeValue, alias: string }
|
||||
|
||||
/**
|
||||
* Type alias for an item that refers to a table in a database.
|
||||
*/
|
||||
export type QuerySource = string | { table: string, alias: string }
|
||||
|
||||
/**
|
||||
* Possible SQL order-by clause directions.
|
||||
*/
|
||||
export type OrderDirection = 'ASC' | 'DESC' | 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Interface for storing the parts of a SQL order-by clause.
|
||||
*/
|
||||
export type OrderStatement = { field: string, direction: OrderDirection }
|
||||
|
||||
/**
|
||||
* Database column types.
|
||||
*/
|
||||
export enum FieldType {
|
||||
bigint = 'bigint',
|
||||
int8 = 'bigint',
|
||||
bigserial = 'bigserial',
|
||||
serial8 = 'bigserial',
|
||||
bit = 'bit',
|
||||
bit_varying = 'bit varying',
|
||||
varbit = 'bit varying',
|
||||
boolean = 'boolean',
|
||||
bool = 'boolean',
|
||||
box = 'box',
|
||||
bytea = 'bytea',
|
||||
character = 'character',
|
||||
char = 'character',
|
||||
character_varying = 'character varying',
|
||||
varchar = 'character varying',
|
||||
cidr = 'cidr',
|
||||
circle = 'circle',
|
||||
date = 'date',
|
||||
double_precision = 'double precision',
|
||||
float8 = 'double precision',
|
||||
inet = 'inet',
|
||||
integer = 'integer',
|
||||
int = 'integer',
|
||||
int4 = 'integer',
|
||||
interval = 'interval',
|
||||
json = 'json',
|
||||
line = 'line',
|
||||
lseg = 'lseg',
|
||||
macaddr = 'macaddr',
|
||||
money = 'money',
|
||||
numeric = 'numeric',
|
||||
decimal = 'numeric',
|
||||
path = 'path',
|
||||
point = 'point',
|
||||
polygon = 'polygon',
|
||||
real = 'real',
|
||||
float4 = 'real',
|
||||
smallint = 'smallint',
|
||||
int2 = 'smallint',
|
||||
smallserial = 'smallserial',
|
||||
serial2 = 'smallserial',
|
||||
serial = 'serial',
|
||||
serial4 = 'serial',
|
||||
text = 'text',
|
||||
time = 'time',
|
||||
timestamp = 'timestamp',
|
||||
tsquery = 'tsquery',
|
||||
tsvector = 'tsvector',
|
||||
txid_snapshot = 'txid_snapshot',
|
||||
uuid = 'uuid',
|
||||
xml = 'xml',
|
||||
other = 'other',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user