diff --git a/lib/src/collection/AsyncCollection.ts b/lib/src/collection/AsyncCollection.ts index 77ac240..5f79f42 100644 --- a/lib/src/collection/AsyncCollection.ts +++ b/lib/src/collection/AsyncCollection.ts @@ -496,7 +496,9 @@ export class AsyncCollection { if ( !fetched_indices.includes(index) ) { fetched_indices.push(index) - random_items.push(await this._items.at_index(index)) + const item = await this._items.at_index(index) + if ( typeof item !== 'undefined' ) + random_items.push(item) } } diff --git a/lib/src/collection/Iterable.ts b/lib/src/collection/Iterable.ts index 8999855..3fc9031 100644 --- a/lib/src/collection/Iterable.ts +++ b/lib/src/collection/Iterable.ts @@ -8,7 +8,7 @@ export class StopIteration extends Error {} export abstract class Iterable { protected index = 0 - abstract async at_index(i: number): Promise + abstract async at_index(i: number): Promise abstract async from_range(start: number, end: number): Promise> abstract async count(): Promise abstract clone(): Iterable diff --git a/lib/src/service/logging/Logger.ts b/lib/src/service/logging/Logger.ts index acf70f2..b591735 100644 --- a/lib/src/service/logging/Logger.ts +++ b/lib/src/service/logging/Logger.ts @@ -29,17 +29,17 @@ export default abstract class Logger { case LoggingLevel.Success: return green('success') case LoggingLevel.Error: - return red('error') + return red(' error') case LoggingLevel.Warning: return yellow('warning') case LoggingLevel.Info: - return blue('info') + return blue(' info') case LoggingLevel.Debug: - return cyan('debug') + return cyan(' debug') case LoggingLevel.Verbose: return gray('verbose') case LoggingLevel.Silent: - return gray('silent') + return gray(' silent') } } } diff --git a/lib/src/service/logging/Logging.ts b/lib/src/service/logging/Logging.ts index 1bdfef7..51b120b 100644 --- a/lib/src/service/logging/Logging.ts +++ b/lib/src/service/logging/Logging.ts @@ -59,6 +59,7 @@ class Logging { level, output, date: new Date, + caller_name: this.get_caller_info(), } } @@ -73,6 +74,14 @@ class Logging { public remove_logger(logger_class: typeof Logger) { this._loggers = this._loggers.filter(x => !(x instanceof logger_class)) } + + protected get_caller_info(level = 5): string { + let e = new Error + if ( !e.stack ) return 'Unknown' + return e.stack.split(' at ') + .slice(level) + .map((x: string): string => x.trim().split(' (')[0].split('.')[0].split(':')[0])[0] + } } export { Logging } diff --git a/lib/src/service/logging/StandardLogger.ts b/lib/src/service/logging/StandardLogger.ts index 95f4b4a..f4afbd6 100644 --- a/lib/src/service/logging/StandardLogger.ts +++ b/lib/src/service/logging/StandardLogger.ts @@ -1,11 +1,11 @@ import AbstractLogger from './Logger.ts' import { LogMessage } from './types.ts' -import { gray } from '../../external/std.ts' +import { gray, cyan } from '../../external/std.ts' export default class StandardLogger extends AbstractLogger { public async write(message: LogMessage): Promise { const prefix = this.level_display(message.level) - const text = `${prefix} ${gray(this.format_date(message.date))}` + const text = `${prefix} ${gray(this.format_date(message.date))} (${cyan(message.caller_name || 'Unknown')})` console.log(text, message.output) } } diff --git a/lib/src/service/logging/types.ts b/lib/src/service/logging/types.ts index 9227b85..dfff201 100644 --- a/lib/src/service/logging/types.ts +++ b/lib/src/service/logging/types.ts @@ -24,6 +24,7 @@ interface LogMessage { level: LoggingLevel, date: Date, output: any, + caller_name: string, } const isLogMessage = (something: any): something is LogMessage => { diff --git a/lib/src/support/BehaviorSubject.ts b/lib/src/support/BehaviorSubject.ts new file mode 100644 index 0000000..8e39fba --- /dev/null +++ b/lib/src/support/BehaviorSubject.ts @@ -0,0 +1,115 @@ +export class UnsubscribeError extends Error {} +export class CompletedObservableError extends Error { + constructor() { + super('This observable can no longer be pushed to, as it has been completed.') + } +} + +export type SubscriberFunction = (val: T) => any +export type SubscriberErrorFunction = (error: Error) => any +export type SubscriberCompleteFunction = (val?: T) => any + +export type ComplexSubscriber = { + next?: SubscriberFunction, + error?: SubscriberErrorFunction, + complete?: SubscriberCompleteFunction, +} + +export type Subscription = SubscriberFunction | ComplexSubscriber +export type Unsubscribe = { unsubscribe: () => void } + +export class BehaviorSubject { + protected subscribers: ComplexSubscriber[] = [] + protected _is_complete: boolean = false + protected _value?: T + protected _has_push: boolean = false + + public subscribe(subscriber: Subscription): Unsubscribe { + if ( typeof subscriber === 'function' ) { + this.subscribers.push({ next: subscriber }) + } else { + this.subscribers.push(subscriber) + } + + return { + unsubscribe: () => { + this.subscribers = this.subscribers.filter(x => x !== subscriber) + } + } + } + + public to_promise(): Promise { + return new Promise((resolve, reject) => { + const { unsubscribe } = this.subscribe({ + next: (val: T) => { + resolve(val) + unsubscribe() + }, + error: (error: Error) => { + reject(error) + unsubscribe() + }, + complete: (val?: T) => { + resolve(val) + unsubscribe() + } + }) + }) + } + + public async next(val: T): Promise { + if ( this._is_complete ) throw new CompletedObservableError() + this._value = val + this._has_push = true + for ( const subscriber of this.subscribers ) { + if ( subscriber.next ) { + try { + await subscriber.next(val) + } catch (e) { + if ( e instanceof UnsubscribeError ) { + this.subscribers = this.subscribers.filter(x => x !== subscriber) + } else if (subscriber.error) { + await subscriber.error(e) + } else { + throw e + } + } + } + } + } + + public async push(vals: T[]): Promise { + if ( this._is_complete ) throw new CompletedObservableError() + await Promise.all(vals.map(val => this.next(val))) + } + + public async complete(final_val?: T): Promise { + if ( this._is_complete ) throw new CompletedObservableError() + if ( typeof final_val === 'undefined' ) final_val = this.value() + else this._value = final_val + + for ( const subscriber of this.subscribers ) { + if ( subscriber.complete ) { + try { + await subscriber.complete(final_val) + } catch (e) { + if ( subscriber.error ) { + await subscriber.error(e) + } else { + throw e + } + } + } + } + + this._is_complete = true + } + + public value(): T | undefined { + return this._value + } + + public is_complete(): boolean { + return this._is_complete + } +} diff --git a/lib/src/support/mixins.ts b/lib/src/support/mixins.ts index b2eb24f..72c282b 100644 --- a/lib/src/support/mixins.ts +++ b/lib/src/support/mixins.ts @@ -7,3 +7,5 @@ export function applyMixins(derivedCtor: any, baseCtors: any[]) { }) }) } + +export type Constructor = new (...args: any[]) => T diff --git a/orm/src/builder/Builder.ts b/orm/src/builder/Builder.ts index 1b1234d..0b3df1e 100644 --- a/orm/src/builder/Builder.ts +++ b/orm/src/builder/Builder.ts @@ -2,8 +2,10 @@ import {escape, EscapedValue, FieldSet, QuerySource} from './types.ts' import { Select } from './type/Select.ts' import RawValue from './RawValue.ts' import {Statement} from './Statement.ts' -import {Update} from "./type/Update.ts"; -import {Insert} from "./type/Insert.ts"; +import {Update} from './type/Update.ts' +import {Insert} from './type/Insert.ts' +import {Delete} from './type/Delete.ts' +import {Truncate} from "./type/Truncate.ts"; export function raw(value: string) { return new RawValue(value) @@ -15,29 +17,39 @@ export class IncorrectInterpolationError extends Error { } } -export class Builder { - // create table, insert, delete, alter table, drop table, select +export class Builder { + // create table, alter table, drop table, select - public select(...fields: FieldSet[]) { + public select(...fields: FieldSet[]): Select { fields = fields.flat() - const select = new Select() + const select = new Select() return select.fields(...fields) } - public update(target?: QuerySource, alias?: string) { - const update = new Update() + public update(target?: QuerySource, alias?: string): Update { + const update = new Update() if ( target ) update.to(target, alias) return update } - public insert(target?: QuerySource, alias?: string) { - const insert = new Insert() + public delete(target?: QuerySource, alias?: string): Delete { + const del = new Delete() + if ( target ) del.from(target, alias) + return del + } + + public insert(target?: QuerySource, alias?: string): Insert { + const insert = new Insert() if ( target ) insert.into(target, alias) return insert } - public statement(statement: string, ...interpolations: EscapedValue[]) { - return new Statement(statement, interpolations) + public statement(statement: string, ...interpolations: EscapedValue[]): Statement { + return new Statement(statement, interpolations) + } + + public truncate(target?: QuerySource, alias?: string): Truncate { + return new Truncate(target, alias) } public static raw(value: string) { diff --git a/orm/src/builder/Scope.ts b/orm/src/builder/Scope.ts new file mode 100644 index 0000000..9c2e351 --- /dev/null +++ b/orm/src/builder/Scope.ts @@ -0,0 +1,5 @@ +import {WhereBuilder} from './type/WhereBuilder.ts' + +export abstract class Scope { + abstract apply(query: WhereBuilder): WhereBuilder +} diff --git a/orm/src/builder/Statement.ts b/orm/src/builder/Statement.ts index 3402d8a..123d4fe 100644 --- a/orm/src/builder/Statement.ts +++ b/orm/src/builder/Statement.ts @@ -2,7 +2,7 @@ import {EscapedValue, escape} from './types.ts' import {IncorrectInterpolationError} from './Builder.ts' import ConnectionExecutable from './type/ConnectionExecutable.ts' -export class Statement extends ConnectionExecutable { +export class Statement extends ConnectionExecutable { constructor( public statement: string, public interpolations: EscapedValue[] diff --git a/orm/src/builder/scope/FunctionScope.ts b/orm/src/builder/scope/FunctionScope.ts new file mode 100644 index 0000000..151474b --- /dev/null +++ b/orm/src/builder/scope/FunctionScope.ts @@ -0,0 +1,14 @@ +import {Scope} from '../Scope.ts' +import {WhereBuilder} from '../type/WhereBuilder.ts' + +export type ScopeFunction = (query: WhereBuilder) => WhereBuilder + +export class FunctionScope extends Scope { + constructor(protected _fn: ScopeFunction) { + super() + } + + apply(query: WhereBuilder): WhereBuilder { + return this._fn(query) + } +} diff --git a/orm/src/builder/type/ConnectionExecutable.ts b/orm/src/builder/type/ConnectionExecutable.ts index f5ad134..161aa8b 100644 --- a/orm/src/builder/type/ConnectionExecutable.ts +++ b/orm/src/builder/type/ConnectionExecutable.ts @@ -6,53 +6,59 @@ import {Connection} from '../../db/Connection.ts' import {ResultCollection} from './result/ResultCollection.ts' import {ResultIterable} from './result/ResultIterable.ts' import ResultOperator from './result/ResultOperator.ts' -import ObjectResultOperator from './result/ObjectResultOperator.ts' +import {Collection} from '../../../../lib/src/collection/Collection.ts' +import NoTargetOperatorError from '../../error/NoTargetOperatorError.ts' -export default abstract class ConnectionExecutable { +export default abstract class ConnectionExecutable { abstract sql(level: number): string to_count(): string { return `SELECT COUNT(*) AS to_count FROM (${this.sql(0)}) AS target_query` } - async get_row(i: number) { + async get_row(i: number): Promise { if ( !(this.__target_connection instanceof Connection) ) { throw new Error('Unable to execute database item: no target connection.') } + if ( !this.__target_operator ) throw new NoTargetOperatorError() + const query = `SELECT * FROM (${this.sql(0)}) AS target_query OFFSET ${i} LIMIT 1` const result = await this.__target_connection.query(query) const row = result.rows.first() if ( row ) return this.__target_operator.inflate_row(row) } - async get_range(start: number, end: number) { + async get_range(start: number, end: number): Promise> { if ( !(this.__target_connection instanceof Connection) ) { throw new Error('Unable to execute database item: no target connection.') } const query = `SELECT * FROM (${this.sql(0)}) AS target_query OFFSET ${start} LIMIT ${(end - start) + 1}` const result = await this.__target_connection.query(query) - return result.rows.map(row => this.__target_operator.inflate_row(row)) + return result.rows.map(row => { + if ( !this.__target_operator ) throw new NoTargetOperatorError() + return this.__target_operator.inflate_row(row) + }) } - iterator() { - return new ResultIterable(this) + iterator(): ResultIterable { + return new ResultIterable(this) } results(chunk_size = 1000) { - return new ResultCollection(this.iterator(), chunk_size) + return new ResultCollection(this.iterator(), chunk_size) } __target_connection?: Connection - __target_operator: ResultOperator = new ObjectResultOperator() + __target_operator?: ResultOperator target_connection(connection: string | Connection) { this.__target_connection = typeof connection === 'string' ? make(Database).connection(connection) : connection return this } - target_operator(operator: ResultOperator) { + target_operator(operator: ResultOperator) { this.__target_operator = operator return this } diff --git a/orm/src/builder/type/ConnectionMutable.ts b/orm/src/builder/type/ConnectionMutable.ts index d13476e..7c25b42 100644 --- a/orm/src/builder/type/ConnectionMutable.ts +++ b/orm/src/builder/type/ConnectionMutable.ts @@ -1,21 +1,26 @@ import ConnectionExecutable from './ConnectionExecutable.ts' -import {QueryResult, QueryRow} from "../../db/types.ts"; -import {Connection} from "../../db/Connection.ts"; -import {Collection} from "../../../../lib/src/collection/Collection.ts"; +import {QueryResult, QueryRow} from '../../db/types.ts' +import {Connection} from '../../db/Connection.ts' +import {Collection} from '../../../../lib/src/collection/Collection.ts' +import NoTargetOperatorError from '../../error/NoTargetOperatorError.ts' -export default abstract class ConnectionMutable extends ConnectionExecutable { +export default abstract class ConnectionMutable extends ConnectionExecutable { __execution_result?: QueryResult - async get_row(i: number) { + async get_row(i: number): Promise { const result = await this.get_execution_result() const row = result.rows.at(i) + if ( !this.__target_operator ) throw new NoTargetOperatorError() if ( row ) return this.__target_operator.inflate_row(row) } - async get_range(start: number, end: number) { + async get_range(start: number, end: number): Promise> { const result = await this.get_execution_result() const rows: Collection = result.rows.slice(start, end + 1) as Collection - return rows.map(row => this.__target_operator.inflate_row(row)) + return rows.map(row => { + if ( !this.__target_operator ) throw new NoTargetOperatorError() + return this.__target_operator.inflate_row(row) + }) } async count() { diff --git a/orm/src/builder/type/Delete.ts b/orm/src/builder/type/Delete.ts new file mode 100644 index 0000000..d4b1a39 --- /dev/null +++ b/orm/src/builder/type/Delete.ts @@ -0,0 +1,60 @@ +import ConnectionMutable from './ConnectionMutable.ts' +import {WhereBuilder} from './WhereBuilder.ts' +import {applyMixins} from '../../../../lib/src/support/mixins.ts' +import {QuerySource, WhereStatement, FieldSet} from '../types.ts' +import {TableRefBuilder} from './TableRefBuilder.ts' +import {MalformedSQLGrammarError} from './Select.ts' +import {Scope} from '../Scope.ts' + +export class Delete extends ConnectionMutable { + protected _target?: QuerySource = undefined + protected _wheres: WhereStatement[] = [] + protected _scopes: Scope[] = [] + protected _fields: string[] = [] + protected _only: boolean = false + + sql(level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + if ( typeof this._target === 'undefined' ) + throw new MalformedSQLGrammarError('No table reference has been provided.') + + const table_ref = this.source_alias_to_table_ref(this._target) + const wheres = this.wheres_to_sql(this._wheres, level + 1) + const returning_fields = this._fields.join(', ') + + return [ + `DELETE FROM ${this._only ? 'ONLY ' : ''}${this.serialize_table_ref(table_ref)}`, + ...(wheres.trim() ? ['WHERE', wheres] : []), + ...(returning_fields.trim() ? [`RETURNING ${returning_fields}`] : []), + ].filter(x => String(x).trim()).join(`\n${indent}`) + } + + only() { + this._only = true + return this + } + + from(source: QuerySource, alias?: string) { + if ( !alias ) this._target = source + else this._target = { ref: source, alias } + return this + } + + returning(...fields: FieldSet[]) { + for ( const field_set of fields ) { + if ( typeof field_set === 'string' ) { + if ( !this._fields.includes(field_set) ) + this._fields.push(field_set) + } else { + for ( const field of field_set ) { + if ( !this._fields.includes(field) ) + this._fields.push(field) + } + } + } + return this + } +} + +export interface Delete extends WhereBuilder, TableRefBuilder {} +applyMixins(Delete, [WhereBuilder, TableRefBuilder]) diff --git a/orm/src/builder/type/Insert.ts b/orm/src/builder/type/Insert.ts index 44c20c8..43006cd 100644 --- a/orm/src/builder/type/Insert.ts +++ b/orm/src/builder/type/Insert.ts @@ -8,7 +8,7 @@ import {raw} from '../Builder.ts' // TODO support DEFAULT VALUES // TODO support ON CONFLICT -export class Insert extends ConnectionMutable { +export class Insert extends ConnectionMutable { protected _target?: QuerySource = undefined protected _columns: string[] = [] protected _rows: string[] = [] @@ -114,5 +114,5 @@ export class Insert extends ConnectionMutable { } } -export interface Insert extends TableRefBuilder {} +export interface Insert extends TableRefBuilder {} applyMixins(Insert, [TableRefBuilder]) diff --git a/orm/src/builder/type/Select.ts b/orm/src/builder/type/Select.ts index 6b016f0..4349d3b 100644 --- a/orm/src/builder/type/Select.ts +++ b/orm/src/builder/type/Select.ts @@ -19,16 +19,18 @@ import {RightOuterJoin} from './join/RightOuterJoin.ts' import {FullOuterJoin} from './join/FullOuterJoin.ts' import {HavingBuilder} from './HavingBuilder.ts' import ConnectionExecutable from './ConnectionExecutable.ts' +import {Scope} from '../Scope.ts' export type WhereBuilderFunction = (group: WhereBuilder) => any export type HavingBuilderFunction = (group: HavingBuilder) => any export type JoinFunction = (join: Join) => any export class MalformedSQLGrammarError extends Error {} -export class Select extends ConnectionExecutable { +export class Select extends ConnectionExecutable { protected _fields: string[] = [] protected _source?: QuerySource = undefined protected _wheres: WhereStatement[] = [] + protected _scopes: Scope[] = [] protected _havings: HavingStatement[] = [] protected _limit?: number protected _offset?: number @@ -200,5 +202,5 @@ export class Select extends ConnectionExecutable { // TODO raw() } -export interface Select extends WhereBuilder, TableRefBuilder, HavingBuilder {} +export interface Select extends WhereBuilder, TableRefBuilder, HavingBuilder {} applyMixins(Select, [WhereBuilder, TableRefBuilder, HavingBuilder]) diff --git a/orm/src/builder/type/Truncate.ts b/orm/src/builder/type/Truncate.ts new file mode 100644 index 0000000..653302d --- /dev/null +++ b/orm/src/builder/type/Truncate.ts @@ -0,0 +1,72 @@ +import ConnectionMutable from "./ConnectionMutable.ts"; +import {MalformedSQLGrammarError} from "./Select.ts"; +import {TableRefBuilder} from "./TableRefBuilder.ts"; +import {applyMixins} from "../../../../lib/src/support/mixins.ts"; +import {QuerySource} from "../types.ts"; + +export class Truncate extends ConnectionMutable { + protected _source?: QuerySource + protected _only: boolean = false + protected _restart: boolean = false + protected _continue: boolean = false + protected _cascade: boolean = false + protected _restrict: boolean = false + + constructor(table?: QuerySource, alias?: string) { + super() + if ( table ) this.table(table, alias) + } + + sql(level: number = 0): string { + const indent = Array(level).fill(' ').join('') + if ( typeof this._source === 'undefined' ) + throw new MalformedSQLGrammarError(`No table reference has been provided.`) + const table_ref = this.source_alias_to_table_ref(this._source) + + let identity = '' + if ( this._continue ) identity = 'CONTINUE IDENTITY ' + else if ( this._restart ) identity = 'RESTART IDENTITY ' + + let cascade_redirect = '' + if ( this._cascade ) cascade_redirect = 'CASCADE ' + else if ( this._restrict ) cascade_redirect = 'RESTRICT' + + return [ + `TRUNCATE TABLE ${this._only ? 'ONLY ' : ''}${this.serialize_table_ref(table_ref)}`, + `${identity}${cascade_redirect}`, + ].filter(x => String(x).trim()).join(`\n${indent}`) + } + + table(source: QuerySource, alias?: string) { + if ( !alias ) this._source = source + else this._source = { ref: source, alias } + return this + } + + restart_identity() { + this._continue = false + this._restart = true + return this + } + + continue_identity() { + this._continue = true + this._restart = false + return this + } + + cascade() { + this._cascade = true + this._restrict = false + return this + } + + restrict() { + this._cascade = false + this._restrict = true + return this + } +} + +export interface Truncate extends TableRefBuilder {} +applyMixins(Truncate, [TableRefBuilder]) diff --git a/orm/src/builder/type/Update.ts b/orm/src/builder/type/Update.ts index 885117f..f2a09dd 100644 --- a/orm/src/builder/type/Update.ts +++ b/orm/src/builder/type/Update.ts @@ -6,14 +6,16 @@ import {applyMixins} from '../../../../lib/src/support/mixins.ts' import {TableRefBuilder} from './TableRefBuilder.ts' import {MalformedSQLGrammarError} from './Select.ts' import ConnectionMutable from './ConnectionMutable.ts' +import {Scope} from '../Scope.ts' // TODO FROM // TODO WHERE CURRENT OF -export class Update extends ConnectionMutable { +export class Update extends ConnectionMutable { protected _target?: QuerySource = undefined protected _only = false protected _sets: Collection = new Collection() protected _wheres: WhereStatement[] = [] + protected _scopes: Scope[] = [] protected _fields: string[] = [] sql(level = 0): string { @@ -84,5 +86,5 @@ export class Update extends ConnectionMutable { } } -export interface Update extends WhereBuilder, TableRefBuilder {} +export interface Update extends WhereBuilder, TableRefBuilder {} applyMixins(Update, [WhereBuilder, TableRefBuilder]) diff --git a/orm/src/builder/type/WhereBuilder.ts b/orm/src/builder/type/WhereBuilder.ts index 050038f..89949a9 100644 --- a/orm/src/builder/type/WhereBuilder.ts +++ b/orm/src/builder/type/WhereBuilder.ts @@ -1,15 +1,40 @@ import {EscapedValue, isWhereClause, isWhereGroup, WhereStatement} from '../types.ts' import {escape, SQLWhereOperator, WherePreOperator} from '../types.ts' -import {WhereBuilderFunction} from "./Select.ts"; +import {WhereBuilderFunction} from './Select.ts' +import {apply_filter_to_where, QueryFilter} from '../../model/filter.ts' +import {Scope} from '../Scope.ts' +import {FunctionScope, ScopeFunction} from '../scope/FunctionScope.ts' +import {make} from '../../../../di/src/global.ts' export class WhereBuilder { protected _wheres: WhereStatement[] = [] + protected _scopes: Scope[] = [] get where_items() { return this._wheres } + without_scope(scope: typeof Scope) { + this._scopes = this._scopes.filter(x => !(x instanceof Scope)) + return this + } + + with_scope(scope: Scope | ScopeFunction) { + if ( scope instanceof Scope ) { + this._scopes.push(scope) + } else { + this._scopes.push(new FunctionScope(scope)) + } + return this + } + + with_scopes(scopes: (Scope | ScopeFunction)[]) { + scopes.forEach(scope => this.with_scope(scope)) + return this + } + wheres_to_sql(wheres?: WhereStatement[], level = 0): string { + this._scopes.forEach(scope => scope.apply(this)) const indent = Array(level * 2).fill(' ').join('') let statements = [] for ( const where of wheres || this._wheres ) { @@ -97,4 +122,48 @@ export class WhereBuilder { }) return this } + + whereBetween(field: string, lower_bound: EscapedValue, upper_bound: EscapedValue) { + this._wheres.push({ + field, + operator: 'BETWEEN', + operand: `${escape(lower_bound)} AND ${escape(upper_bound)}`, + preop: 'AND', + }) + return this + } + + orWhereBetween(field: string, lower_bound: EscapedValue, upper_bound: EscapedValue) { + this._wheres.push({ + field, + operator: 'BETWEEN', + operand: `${escape(lower_bound)} AND ${escape(upper_bound)}`, + preop: 'OR', + }) + return this + } + + whereNotBetween(field: string, lower_bound: EscapedValue, upper_bound: EscapedValue) { + this._wheres.push({ + field, + operator: 'NOT BETWEEN', + operand: `${escape(lower_bound)} AND ${escape(upper_bound)}`, + preop: 'AND', + }) + return this + } + + orWhereNotBetween(field: string, lower_bound: EscapedValue, upper_bound: EscapedValue) { + this._wheres.push({ + field, + operator: 'NOT BETWEEN', + operand: `${escape(lower_bound)} AND ${escape(upper_bound)}`, + preop: 'OR', + }) + return this + } + + filter(filter: QueryFilter) { + return apply_filter_to_where(filter, this) + } } diff --git a/orm/src/builder/type/join/Join.ts b/orm/src/builder/type/join/Join.ts index 7fcc377..6c665ec 100644 --- a/orm/src/builder/type/join/Join.ts +++ b/orm/src/builder/type/join/Join.ts @@ -2,10 +2,12 @@ import {JoinOperator, TableRef, WhereStatement} from '../../types.ts' import {TableRefBuilder} from '../TableRefBuilder.ts' import {applyMixins} from '../../../../../lib/src/support/mixins.ts' import {WhereBuilder} from '../WhereBuilder.ts' +import {Scope} from '../../Scope.ts' export class Join { public readonly operator: JoinOperator = 'JOIN' protected _wheres: WhereStatement[] = [] + protected _scopes: Scope[] = [] constructor( public readonly table_ref: TableRef diff --git a/orm/src/builder/type/result/ResultCollection.ts b/orm/src/builder/type/result/ResultCollection.ts index bffb8d8..16919d4 100644 --- a/orm/src/builder/type/result/ResultCollection.ts +++ b/orm/src/builder/type/result/ResultCollection.ts @@ -1,21 +1,21 @@ import {AsyncCollection} from '../../../../../lib/src/collection/AsyncCollection.ts' import {ResultIterable} from './ResultIterable.ts' -import {Collection} from "../../../../../lib/src/collection/Collection.ts"; +import {Collection} from '../../../../../lib/src/collection/Collection.ts' -export class ResultCollection extends AsyncCollection { +export class ResultCollection extends AsyncCollection { constructor( - executable: ResultIterable, + executable: ResultIterable, chunk_size: number = 1000 ) { super(executable, chunk_size) } - then(func?: (items: Collection) => any) { + then(func?: (items: Collection) => any) { if ( func ) { - this.collect().then((items: Collection) => func(items)) + this.collect().then((items: Collection) => func(items)) } else { return new Promise(res => [ - this.collect().then((items: Collection) => res(items)) + this.collect().then((items: Collection) => res(items)) ]) } } diff --git a/orm/src/builder/type/result/ResultIterable.ts b/orm/src/builder/type/result/ResultIterable.ts index 4b03121..9d7e07c 100644 --- a/orm/src/builder/type/result/ResultIterable.ts +++ b/orm/src/builder/type/result/ResultIterable.ts @@ -1,18 +1,19 @@ import {Iterable} from '../../../../../lib/src/collection/Iterable.ts' import ConnectionExecutable from '../ConnectionExecutable.ts' import {Collection} from '../../../../../lib/src/collection/Collection.ts' +import {QueryRow} from '../../../db/types.ts' -export class ResultIterable extends Iterable { +export class ResultIterable extends Iterable { constructor( - protected executable: ConnectionExecutable + protected executable: ConnectionExecutable ) { super() } - async at_index(i: number): Promise { + async at_index(i: number): Promise { return this.executable.get_row(i) } - async from_range(start: number, end: number): Promise> { + async from_range(start: number, end: number): Promise> { return this.executable.get_range(start, end) } diff --git a/orm/src/builder/types.ts b/orm/src/builder/types.ts index 22fe144..02eba23 100644 --- a/orm/src/builder/types.ts +++ b/orm/src/builder/types.ts @@ -10,7 +10,7 @@ export type WherePreOperator = 'AND' | 'OR' | 'AND NOT' | 'OR NOT' export type WhereClause = { field: string, operator: SQLWhereOperator, operand: string, preop: WherePreOperator } export type WhereGroup = { items: WhereStatement[], preop: WherePreOperator } export type WhereStatement = WhereClause | WhereGroup -export type SQLWhereOperator = WhereOperator | 'IN' | 'NOT IN' | 'LIKE' +export type SQLWhereOperator = WhereOperator | 'IN' | 'NOT IN' | 'LIKE' | 'BETWEEN' | 'NOT BETWEEN' export type OrderDirection = 'ASC' | 'DESC' export type OrderStatement = { direction: OrderDirection, field: string } diff --git a/orm/src/db/types.ts b/orm/src/db/types.ts index cfa6c44..e5a649f 100644 --- a/orm/src/db/types.ts +++ b/orm/src/db/types.ts @@ -1,6 +1,7 @@ import { Collection } from '../../../lib/src/collection/Collection.ts' export type QueryRow = { [key: string]: any } +export type ModelKey = string | number export interface QueryResult { rows: Collection, diff --git a/orm/src/error/NoTargetOperatorError.ts b/orm/src/error/NoTargetOperatorError.ts new file mode 100644 index 0000000..1c7ea3e --- /dev/null +++ b/orm/src/error/NoTargetOperatorError.ts @@ -0,0 +1,5 @@ +export default class NoTargetOperatorError extends Error { + constructor(msg = 'This query has no defined target operator.') { + super(msg) + } +} \ No newline at end of file diff --git a/orm/src/model/Model.ts b/orm/src/model/Model.ts index 23a03c4..2fcda14 100644 --- a/orm/src/model/Model.ts +++ b/orm/src/model/Model.ts @@ -2,26 +2,43 @@ import { Builder } from '../builder/Builder.ts' import {FieldSet, FieldValueObject, QuerySource} from '../builder/types.ts' import {make} from '../../../di/src/global.ts' import Database from '../service/Database.ts' -import {QueryRow} from '../db/types.ts' +import {QueryRow, ModelKey} from '../db/types.ts' import ModelResultOperator from './ModelResultOperator.ts' import {get_fields_meta, ModelField, set_model_fields_meta} from './Field.ts' import {Collection} from '../../../lib/src/collection/Collection.ts' import {logger} from '../../../lib/src/service/logging/global.ts' import ObjectResultOperator from '../builder/type/result/ObjectResultOperator.ts' +import {QueryFilter} from './filter.ts' +import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts' +import {Scope} from '../builder/Scope.ts' // TODO separate read/write connections // TODO manual dirty flags -export abstract class Model extends Builder { +export abstract class Model> extends Builder { protected static connection: string protected static table: string protected static key: string protected static readonly CREATED_AT = 'created_at' protected static readonly UPDATED_AT = 'updated_at' - protected static timestamps = false + protected static timestamps = true + + protected static global_scopes: Scope[] = [] + protected static appends: string[] = [] + protected static masks: string[] = [] protected _original?: QueryRow + protected retrieved$ = new BehaviorSubject>() + protected saving$ = new BehaviorSubject>() + protected saved$ = new BehaviorSubject>() + protected updating$ = new BehaviorSubject>() + protected updated$ = new BehaviorSubject>() + protected creating$ = new BehaviorSubject>() + protected created$ = new BehaviorSubject>() + protected deleting$ = new BehaviorSubject>() + protected deleted$ = new BehaviorSubject>() + public static table_name() { return this.table } @@ -46,10 +63,19 @@ export abstract class Model extends Builder { return this.prototype.insert() } + public static delete() { + return this.prototype.delete() + } + + public static truncate() { + return this.prototype.truncate() + } + public update(target?: QuerySource, alias?: string) { const constructor = (this.constructor as typeof Model) return super.update() .to(constructor.table_name()) + .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } @@ -58,6 +84,7 @@ export abstract class Model extends Builder { const constructor = (this.constructor as typeof Model) return super.select(...fields) .from(constructor.table_name()) + .with_scopes(constructor.global_scopes) .target_connection(constructor.get_connection()) .target_operator(make(ModelResultOperator, constructor)) } @@ -70,6 +97,23 @@ export abstract class Model extends Builder { .target_operator(make(ModelResultOperator, constructor)) } + public delete(target?: QuerySource, alias?: string) { + const constructor = (this.constructor as typeof Model) + return super.delete() + .from(constructor.table_name()) + .with_scopes(constructor.global_scopes) + .target_connection(constructor.get_connection()) + .target_operator(make(ModelResultOperator, constructor)) + } + + public truncate(target?: QuerySource, alias?: string) { + const constructor = (this.constructor as typeof Model) + return super.truncate() + .table(constructor.table_name()) + .target_connection(constructor.get_connection()) + .target_operator(make(ObjectResultOperator)) + } + constructor( values?: any ) { @@ -94,22 +138,52 @@ export abstract class Model extends Builder { // @ts-ignore this[field_def.model_key] = row[field_def.database_key] }) + this.retrieved$.next(this) return this } - // changes - // date format - // appends - // caching - // relations - // timestamps - // hidden - // fillable - // guarded - // key type - // with - // per page - // exists + public timestamps(): { updated_at?: Date, created_at?: Date } { + const constructor = (this.constructor as typeof Model) + const timestamps: { updated_at?: Date, created_at?: Date } = {} + if ( constructor.timestamps ) { + // @ts-ignore + if ( constructor.CREATED_AT ) timestamps.created_at = this[constructor.CREATED_AT] + + // @ts-ignore + if ( constructor.UPDATED_AT ) timestamps.updated_at = this[constructor.UPDATED_AT] + } + return timestamps + } + + public static model_select(...other_fields: FieldSet[]) { + const fields = get_fields_meta(this.prototype).pluck('database_key').toArray() + return this.select(...[...fields, ...other_fields]) + } + + public static async find(filter: QueryFilter = {}) { + // @ts-ignore + return await this.model_select().filter(filter).results() + } + + public static async find_one(filter: QueryFilter = {}) { + // @ts-ignore + const result = await this.model_select().filter(filter).limit(1).results() + return result.first() + } + + public static async find_by_key(key: ModelKey): Promise { + const result = await this.model_select() + .where(this.qualified_key_name(), '=', key) + .limit(1) + .results() + + return result.first() + } + + public static async count(): Promise { + const result = this.model_select().results() + return await result.count() + } protected get _is_dirty() { return (field_def: ModelField) => { @@ -118,18 +192,16 @@ export abstract class Model extends Builder { } } - // to_row public to_row(): QueryRow { - const data = {} - const meta = (this.constructor as typeof Model).fields() - meta.each(field => { - - }) - return {} + const row = {} + this.field_defs() + .each(field_def => { + // @ts-ignore + row[field_def.database_key] = this[field_def.model_key] + }) + return row } - // relations_to_row - // dirty_to_row public dirty_to_row(): QueryRow { const row = {} this.field_defs() @@ -142,8 +214,6 @@ export abstract class Model extends Builder { return row } - // attributes - /** * Get a collection of field definitions that contains information * on which database fields correspond to which model fields, and @@ -229,10 +299,10 @@ export abstract class Model extends Builder { return !this.is_dirty() } - // was changed - pass attribute(s) - // observe/observers - retrieved, saving, saved, updating, updated, creating, created, deleting, deleted - // global scopes - // non-global scopes + public was_changed(field: string) { + // @ts-ignore + return this.field_defs().pluck('model_key').includes(field) && this[field] !== this._original[field] + } // has one // morph one @@ -244,28 +314,48 @@ export abstract class Model extends Builder { // morph to many // morphed by many // is relation loaded - // touch - update update timestamp, created if necessary - // touch created - update update and created timestamp - // set created at/set updated at - // is fillable - // is guarded - // without touching - - // all // load relations // load missing relations - // increment column - // decrement column + // relations + // with + // relations_to_row + + // support for soft deletes! + // force delete - for soft deleted models + + public touch() { + const constructor = (this.constructor as typeof Model) + if ( constructor.timestamps ) { + if ( this.exists() && constructor.UPDATED_AT ) { + // @ts-ignore + this[constructor.UPDATED_AT] = new Date() + } + + if ( constructor.CREATED_AT ) { + // @ts-ignore + this[constructor.CREATED_AT] = new Date() + } + } + return this + } - // update - bulk - // push - update single + public static all() { + return this.model_select().results() + } - // save - update or create instance - public async save(): Promise { + public async save({ without_timestamps = false }): Promise> { + await this.saving$.next(this) const constructor = (this.constructor as typeof Model) // TODO timestamps if ( this.exists() && this.is_dirty() ) { // We're updating an existing record + await this.updating$.next(this) + + if ( !without_timestamps && constructor.timestamps && constructor.UPDATED_AT ) { + // @ts-ignore + this[constructor.UPDATED_AT] = new Date() + } + const mutable = this.update() .data(this.dirty_to_row()) .where(constructor.qualified_key_name(), '=', this.key()) @@ -280,8 +370,22 @@ export abstract class Model extends Builder { logger.warn(`Model update modified ${modified_rows} rows! (Key: ${constructor.qualified_key_name()})`) } - this.assume_from_source(result.firstWhere(this.key_name(), '=', this.key())) + const model = result.firstWhere(this.key_name(), '=', this.key()) + if ( model ) this.assume_from_source(model) + await this.updated$.next(this) } else if ( !this.exists() ) { // We're inserting a new record + await this.creating$.next(this) + + if ( !without_timestamps && constructor.timestamps && constructor.CREATED_AT ) { + // @ts-ignore + this[constructor.CREATED_AT] = new Date() + } + + if ( constructor.timestamps && constructor.UPDATED_AT ) { + // @ts-ignore + this[constructor.UPDATED_AT] = new Date() + } + const insert_object: FieldValueObject = this._build_insert_field_object() const mutable = this.insert() .row(insert_object) @@ -296,18 +400,21 @@ export abstract class Model extends Builder { logger.warn(`Model insert created ${inserted_rows} rows! (Key: ${constructor.qualified_key_name()})`) } - this.assume_from_source(result.first()) + const model = result.first() + if ( model ) this.assume_from_source(model) + await this.created$.next(this) } + await this.saved$.next(this) return this } protected _build_insert_field_object(): FieldValueObject { - const fields = this.field_defs() + const fields = this.field_defs().whereNot('model_key', '=', this.key_name()) const values = {} fields.each(field_def => { // @ts-ignore - values[field_def.database_key] = this[field_def.model_key] + values[field_def.database_key] = this[field_def.model_key] ?? null }) return values } @@ -317,20 +424,112 @@ export abstract class Model extends Builder { return Object.keys(this._original).map(String) } - // destroy - bulk - // delete single - // force delete - for soft deleted models - // without scope - // without global scope - // without global scopes - - // to object - // to json - // fresh - get new instance of this model - // refresh - reload this instance - // replicate to new instance - // is - check if two models are the same - // isNot + public static async destroy(id_or_ids: ModelKey | ModelKey[]) { + const ids = Array.isArray(id_or_ids) ? id_or_ids : [id_or_ids] + const constructor = (this.constructor as typeof Model) + const mutable = this.delete() + .whereIn(constructor.qualified_key_name(), ids) + .target_operator(make(ObjectResultOperator)) + .results() + + const result = await mutable + + const modified_rows = await mutable.count() + if ( modified_rows !== ids.length ) { + logger.warn(`Model bulk destroy modified ${modified_rows} when ${ids.length} keys were provided. (Key: ${constructor.qualified_key_name()})`) + } + } + + public async destroy(): Promise { + await this.deleting$.next(this) + + const constructor = (this.constructor as typeof Model) + const mutable = this.delete() + .where(constructor.qualified_key_name, '=', this.key()) + .target_operator(make(ObjectResultOperator)) + .results() + + const result = await mutable + + const modified_rows = await mutable.count() + if ( modified_rows !== 1 ) { + logger.warn(`Model delete modified ${modified_rows} rows! (Key: ${constructor.qualified_key_name()})`) + } + + await this.deleted$.next(this) + } + + public to_object(): { [key: string]: any } { + const constructor = (this.constructor as typeof Model) + const obj = {} + this.field_defs() + .each(field_def => { + // @ts-ignore + obj[field_def.model_key] = this[field_def.model_key] + }) + + constructor.appends.forEach(appended_field => { + // @ts-ignore + obj[appended_field] = this[appended_field] + }) + + constructor.masks.forEach(masked_field => { + // @ts-ignore + delete obj[masked_field] + }) + + return obj + } + + public to_json(): string { + return JSON.stringify(this.to_object()) + } + + public async fresh(): Promise> { + const constructor = (this.constructor as typeof Model) + const fields = this.field_defs() + .whereNot('model_key', '=', this.key_name()) + .pluck('database_key') + .toArray() + + const result = await constructor.select(...fields) + .where(constructor.qualified_key_name(), '=', this.key()) + .limit(1) + .results() + return result.first() + } + + public async refresh() { + const constructor = (this.constructor as typeof Model) + const values = await this.select(this._loaded_database_fields()) + .where(constructor.qualified_key_name(), '=', this.key()) + .limit(1) + .target_operator(make(ObjectResultOperator)) + .results() + + const row = values.first() + if ( row ) this.assume_from_source(row) + } + + public populate(other: Model): Model { + const row = this.to_row() + delete row[this.key_name()] + other.assume_from_source(row) + return other + } + + public is(other_instance: Model): boolean { + const this_constructor = (this.constructor as typeof Model) + const other_constructor = (other_instance.constructor as typeof Model) + return ( + other_instance.key() === this.key() + && this_constructor.qualified_key_name() === other_constructor.qualified_key_name() + ) + } + + public is_not(other_instance: Model): boolean { + return !this.is(other_instance) + } /** * Returns the field name of the primary key for this model. @@ -343,7 +542,7 @@ export abstract class Model extends Builder { /** * If defined, returns the value of the primary key for this model. */ - public key() { + public key(): ModelKey { return this?._original?.[(this.constructor as typeof Model).key] } diff --git a/orm/src/model/ModelResultOperator.ts b/orm/src/model/ModelResultOperator.ts index 7072002..e584403 100644 --- a/orm/src/model/ModelResultOperator.ts +++ b/orm/src/model/ModelResultOperator.ts @@ -1,25 +1,23 @@ import ResultOperator from '../builder/type/result/ResultOperator.ts' import {Model} from './Model.ts' import {Injectable} from '../../../di/src/decorator/Injection.ts' -import {Container} from '../../../di/src/Container.ts' import {QueryRow} from '../db/types.ts' import Instantiable from '../../../di/src/type/Instantiable.ts' +import {make} from '../../../di/src/global.ts' @Injectable() -export default class ModelResultOperator extends ResultOperator { - +export default class ModelResultOperator> extends ResultOperator { constructor( - protected injector: Container, - protected ModelClass: Instantiable, + protected ModelClass: Instantiable, ) { - super(); + super() } - inflate_row(row: QueryRow): Model { - return this.injector.make(this.ModelClass).assume_from_source(row) + inflate_row(row: QueryRow): T { + return make(this.ModelClass).assume_from_source(row) } - deflate_row(item: Model): QueryRow { + deflate_row(item: T): QueryRow { return item.to_row() } diff --git a/orm/src/model/filter.ts b/orm/src/model/filter.ts new file mode 100644 index 0000000..62dc55b --- /dev/null +++ b/orm/src/model/filter.ts @@ -0,0 +1,54 @@ +import {WhereBuilder} from '../builder/type/WhereBuilder.ts' +import {logger} from '../../../lib/src/service/logging/global.ts' +import {EscapedValue} from '../builder/types.ts' + +export enum FilterOp { + eq = '$eq', + in = '$in', + lt = '$lt', + lte = '$lte', + gt = '$gt', + gte = '$gte', +} + +export type QueryFilter = { [key: string]: any } + +export function apply_filter_to_where(filter: QueryFilter, where: WhereBuilder): WhereBuilder { + for ( const field in filter ) { + if ( !filter.hasOwnProperty(field) ) continue + const filter_val = filter[field] + if ( Array.isArray(filter_val) ) { + where = where.whereIn(field, filter_val) + } else if ( typeof filter_val === 'object' ) { + for ( const op in filter_val ) { + if ( !filter_val.hasOwnProperty(op) ) continue + switch (op) { + case FilterOp.eq: + where = where.where(field, '=', filter_val[op]) + break + case FilterOp.in: + where = where.whereIn(field, filter_val[op]) + break + case FilterOp.lt: + where = where.where(field, '<', filter_val[op]) + break + case FilterOp.lte: + where = where.where(field, '<=', filter_val[op]) + break + case FilterOp.gt: + where = where.where(field, '>', filter_val[op]) + break + case FilterOp.gte: + where = where.where(field, '>=', filter_val[op]) + break + default: + logger.warn(`Invalid filter operator attempted: ${op}`) + } + } + } else { + where = where.where(field, '=', filter_val) + } + } + + return where +} \ No newline at end of file diff --git a/test.ts b/test.ts index cf651b1..eefcac8 100644 --- a/test.ts +++ b/test.ts @@ -1,5 +1,70 @@ -import { make, container } from "./di/src/global.ts"; +import {make} from "./di/src/global.ts"; +import Application from "./lib/src/lifecycle/Application.ts"; import Scaffolding from "./lib/src/unit/Scaffolding.ts"; +import {Logging} from "./lib/src/service/logging/Logging.ts"; +import Database from "./orm/src/service/Database.ts"; +import { Model } from './orm/src/model/Model.ts' +import {Field} from "./orm/src/model/Field.ts"; +import {QueryRow, Type} from './orm/src/db/types.ts'; +import {Builder} from "./orm/src/builder/Builder.ts"; +import ObjectResultOperator from "./orm/src/builder/type/result/ObjectResultOperator.ts"; +import {BehaviorSubject} from "./lib/src/support/BehaviorSubject.ts"; -const scaf = make(Scaffolding) -scaf.up() +// TODO enum bit fields +// TODO JSON field support + +;(async () => { + const scaf = make(Scaffolding) + await scaf.up() + + const logger = make(Logging) + + const app = make(Application) + await app.run() + + const db = make(Database) + + await db.postgres('garrettmills', { + user: 'garrettmills', + password: 'garrettmills', + database: 'garrettmills', + hostname: 'localhost', + port: 5432, + }) + + class User extends Model { + protected static connection = 'garrettmills' + protected static table = 'daton_users' + protected static key = 'user_id' + + protected static appends: string[] = ['display_name'] + protected static masks: string[] = ['active'] + + @Field(Type.int) + public user_id!: number + + @Field(Type.varchar) + public username!: string + + @Field(Type.varchar) + public first_name!: string + + @Field(Type.varchar) + public last_name!: string + + @Field(Type.boolean) + public active!: boolean + + @Field(Type.timestamp) + public updated_at!: Date + + @Field(Type.timestamp) + public created_at!: Date + + get display_name(): string { + return `${this.last_name}, ${this.first_name}` + } + } + + const u = await User.find_one({ username: 'garrettmills' }) +})()