diff --git a/.gitignore b/.gitignore index 2d4daa4..4573b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea* +test* diff --git a/app/configs/app/app.config.ts b/app/configs/app/app.config.ts new file mode 100644 index 0000000..050f56e --- /dev/null +++ b/app/configs/app/app.config.ts @@ -0,0 +1,5 @@ +import { env } from '../../../lib/src/unit/Scaffolding.ts'; + +export default { + name: env('APP_NAME', 'Daton'), +} diff --git a/app/configs/server.config.ts b/app/configs/server.config.ts new file mode 100644 index 0000000..91a21e0 --- /dev/null +++ b/app/configs/server.config.ts @@ -0,0 +1,4 @@ +export default { + port: 8080, + use_ssl: false, +} diff --git a/app/http/controllers/Test.controller.ts b/app/http/controllers/Test.controller.ts new file mode 100644 index 0000000..08603c4 --- /dev/null +++ b/app/http/controllers/Test.controller.ts @@ -0,0 +1,5 @@ +import Controller from "../../../lib/src/http/Controller.ts"; + +export default class TestController extends Controller { + +} diff --git a/di/src/Container.ts b/di/src/Container.ts index 71b153c..385af5c 100755 --- a/di/src/Container.ts +++ b/di/src/Container.ts @@ -33,6 +33,16 @@ class Container { private factories: Collection = new Collection() private instances: Collection = new Collection() + constructor() { + this.register(Container) + this.instances.push({ + key: Container, + value: this, + }) + + this.register_singleton('injector', this) + } + register(dependency: Instantiable) { if ( this.resolve(dependency) ) throw new DuplicateFactoryKeyError(dependency) @@ -134,14 +144,22 @@ class Container { } make(target: DependencyKey, ...parameters: any[]) { - if ( this.has_key(target) ) { + if ( this.has_key(target) ) return this.resolve_and_create(target, ...parameters) - } else if ( typeof target !== 'string' ) return this.produce_factory(new Factory(target), parameters) else throw new TypeError(`Invalid or unknown make target: ${target}`) } + + get_dependencies(target: DependencyKey): Collection { + const factory = this.resolve(target) + + if ( !factory ) + throw new InvalidDependencyKeyError(target) + + return factory.get_dependency_keys().pluck('key') + } } export { diff --git a/lib/src/collection/ArrayIterable.ts b/lib/src/collection/ArrayIterable.ts new file mode 100644 index 0000000..e2a6741 --- /dev/null +++ b/lib/src/collection/ArrayIterable.ts @@ -0,0 +1,26 @@ +import { Iterable } from './Iterable.ts' +import { collect } from './Collection.ts' + +export class ArrayIterable extends Iterable { + constructor( + protected items: T[], + ) { + super() + } + + async at_index(i: number) { + return this.items[i] + } + + async from_range(start: number, end: number) { + return collect(this.items.slice(start, end + 1)) + } + + async count() { + return this.items.length + } + + clone() { + return new ArrayIterable([...this.items]) + } +} diff --git a/lib/src/collection/AsyncCollection.ts b/lib/src/collection/AsyncCollection.ts new file mode 100644 index 0000000..77ac240 --- /dev/null +++ b/lib/src/collection/AsyncCollection.ts @@ -0,0 +1,663 @@ +import { + AssociatedCollectionItem, + Collection, CollectionIndex, + CollectionItem, ComparisonFunction, + DeterminesEquality, KeyFunction, + KeyOperator, KeyReducerFunction, MaybeCollectionIndex, MaybeCollectionItem +} from './Collection.ts' +import {Iterable, StopIteration} from './Iterable.ts' +import {applyWhere, WhereOperator} from './Where.ts' +type AsyncCollectionComparable = CollectionItem[] | Collection | AsyncCollection +type AsyncKeyFunction = (item: CollectionItem, index: number) => CollectionItem | Promise> +type AsyncCollectionFunction = (items: AsyncCollection) => T2 + +export class AsyncCollection { + constructor( + private _items: Iterable, + private _chunk_size: number = 1000, // TODO fix this. It's just for testing + ) {} + + private async _chunk(callback: (items: Collection) => any): Promise { + await this._items.chunk(this._chunk_size, async items => { + await callback(items) + }) + await this._items.reset() + } + + private async _chunk_all(key: KeyOperator, callback: (items: Collection) => any): Promise { + await this._items.chunk(this._chunk_size, async items => { + await callback(items.pluck(key)) + }) + await this._items.reset() + } + + private async _chunk_all_numbers(key: KeyOperator, callback: (items: number[]) => any): Promise { + await this._items.chunk(this._chunk_size, async items => { + await callback(items.pluck(key).map(x => Number(x)).all()) + }) + await this._items.reset() + } + + private async _chunk_all_associate(key: KeyOperator, callback: (items: AssociatedCollectionItem[]) => any): Promise { + await this._items.chunk(this._chunk_size, async items => { + const assoc_items: AssociatedCollectionItem[] = [] + if ( typeof key === 'function' ) { + items.map((item, index) => { + const key_item = key(item, index) + assoc_items.push({ key: key_item, item }) + }) + } else if ( typeof key === 'string' ) { + items.map((item, index) => { + assoc_items.push({ key: (item)[key], item }) + }) + } + + await callback(assoc_items) + }) + await this._items.reset() + } + + async all(): Promise[]> { + return (await this._items.from_range(0, await this._items.count())).all() + } + + async collect(): Promise> { + return this._items.from_range(0, await this._items.count()) + } + + async average(key?: KeyOperator): Promise { + let running_total = 0 + let running_items = 0 + + const chunk_helper = (items: number[]) => { + running_items += items.length + running_total += items.reduce((prev, curr) => prev + curr) + } + + if ( key ) await this._chunk_all_numbers(key, chunk_helper) + else await this._chunk((items) => { + chunk_helper(items.map(x => Number(x)).all()) + }) + + return running_total / running_items + } + + async median(key?: KeyOperator): Promise { + let items: number[] = [] + + const chunk_helper = (next_items: number[]) => { + items = items.concat(next_items) + } + + if ( key ) await this._chunk_all_numbers(key, chunk_helper) + else await this._chunk(items => { + chunk_helper(items.map(x => Number(x)).all()) + }) + + items = items.sort((a, b) => a - b) + const middle = Math.floor((items.length - 1) / 2) + if ( items.length % 2 ) return items[middle] + else return (items[middle] + items[middle + 1]) / 2 + } + + async mode(key?: KeyOperator): Promise { + let counts: any = {} + + const chunk_helper = (items: number[]) => { + for ( const item of items ) { + if ( !counts[item] ) counts[item] = 1 + else counts[item] += 1 + } + } + + if ( key ) await this._chunk_all_numbers(key, chunk_helper) + else await this._chunk(items => { + chunk_helper(items.map(x => Number(x)).all()) + }) + + return Number(Object.keys(counts).reduce((a, b) => counts[a] > counts[b] ? a : b)[0]) + } + + async collapse(): Promise> { + const items = await this.collect() + return items.collapse() as Collection + } + + async contains(key: KeyOperator, operator: WhereOperator, operand?: any): Promise { + let contains = false + + await this._chunk_all_associate(key, (items: AssociatedCollectionItem[]) => { + const matches = applyWhere(items, operator, operand) + if ( matches.length > 0 ) { + contains = true + throw new StopIteration() + } + }) + + return contains + } + + async clone(): Promise> { + return new AsyncCollection(await this._items.clone()) + } + + async diff(items: AsyncCollectionComparable): Promise> { + const matches: T[] = [] + + await this._chunk(async chunk => { + for ( const item of chunk.all() ) { + if ( !(await items.includes(item)) ) { + matches.push(item) + } + } + }) + + return new Collection(matches) + } + + async diffUsing(items: AsyncCollectionComparable, compare: DeterminesEquality): Promise> { + const matches: T[] = [] + + await this._chunk(async chunk => { + for ( const item of chunk.all() ) { + if ( !(await items.some(exc => compare(item, exc))) ) + matches.push(item) + } + }) + + return new Collection(matches) + } + + async includes(item: CollectionItem): Promise { + let contains = false + + await this._chunk(items => { + if ( items.includes(item) ) { + contains = true + throw new StopIteration() + } + }) + + return contains + } + + async some(operator: (item: T) => boolean): Promise { + let contains = false + + await this._chunk(items => { + for ( const item of items.all() ) { + if ( operator(item) ) { + contains = true + throw new StopIteration() + } + } + }) + + return contains + } + + async each(func: AsyncKeyFunction): Promise { + let index = 0 + + await this._chunk(async items => { + for ( const item of items.all() ) { + await func(item, index) + index += 1 + } + }) + } + + async map(func: AsyncKeyFunction): Promise> { + const new_items: CollectionItem[] = [] + await this.each(async (item, index) => { + new_items.push(await func(item, index)) + }) + return new Collection(new_items) + } + + async every(func: AsyncKeyFunction): Promise { + let pass = true + let index = 0 + + await this._chunk(async items => { + for ( const item of items.all() ) { + if ( !(await func(item, index)) ) { + pass = false + throw new StopIteration() + } + + index += 1 + } + }) + + return pass + } + + async everyWhere(key: KeyOperator, operator: WhereOperator, operand?: any): Promise { + let pass = true + + await this._chunk(async items => { + pass = pass && items.everyWhere(key, operator, operand) + if ( !pass ) { + throw new StopIteration() + } + }) + + return pass + } + + async filter(func: KeyFunction): Promise> { + let new_items: CollectionItem[] = [] + + await this._chunk(async items => { + new_items = new_items.concat(items.filter(func).all()) + }) + + return new Collection(new_items) + } + + when(bool: boolean, then: AsyncCollectionFunction): AsyncCollection { + if ( bool ) then(this) + return this + } + + unless(bool: boolean, then: AsyncCollectionFunction): AsyncCollection { + if ( !bool ) then(this) + return this + } + + async where(key: KeyOperator, operator: WhereOperator, operand?: any): Promise> { + let new_items: CollectionItem[] = [] + await this._chunk(async items => { + new_items = new_items.concat(items.where(key, operator, operand).all()) + }) + return new Collection(new_items) + } + + async whereNot(key: KeyOperator, operator: WhereOperator, operand?: any): Promise> { + let new_items: CollectionItem[] = [] + await this._chunk(async items => { + new_items = new_items.concat(items.whereNot(key, operator, operand).all()) + }) + return new Collection(new_items) + } + + async whereIn(key: KeyOperator, items: AsyncCollectionComparable): Promise> { + let new_items: CollectionItem[] = [] + await this._chunk_all_associate(key,async chunk => { + for ( const item of chunk ) { + if ( await items.includes(item.key) ) { + new_items.push(item.item) + } + } + }) + return new Collection(new_items) + } + + async whereNotIn(key: KeyOperator, items: AsyncCollectionComparable): Promise> { + let new_items: CollectionItem[] = [] + await this._chunk_all_associate(key,async chunk => { + for ( const item of chunk ) { + if ( !(await items.includes(item.key)) ) { + new_items.push(item.item) + } + } + }) + return new Collection(new_items) + } + + async first(): Promise> { + if ( await this._items.count() > 0 ) { + return this._items.at_index(0) + } + } + + async firstWhere(key: KeyOperator, operator: WhereOperator = '=', operand: any = true): Promise> { + let item = undefined + await this._chunk_all_associate(key, async items => { + const matches = applyWhere(items, operator, operand) + if ( matches.length > 0 ) { + item = matches[0] + throw new StopIteration() + } + }) + return item + } + + async firstWhereNot(key: KeyOperator, operator: WhereOperator = '=', operand: any = true): Promise> { + let item: MaybeCollectionItem = undefined + await this._chunk(async items => { + const matches = items.whereNot(key, operator, operand) + if ( matches.length > 0 ) { + item = matches.first() + throw new StopIteration() + } + }) + return item + } + + async count() { + return this._items.count() + } + + async length() { + return this._items.count() + } + + async get(index: number, fallback?: any) { + if ( (await this.count()) > index ) return this._items.at_index(index) + else return fallback + } + + async at(index: number): Promise> { + return this.get(index) + } + + async groupBy(key: KeyOperator): Promise { + return (await this.collect()).groupBy(key) + } + + async associate(key: KeyOperator): Promise { + return (await this.collect()).associate(key) + } + + async join(delimiter: string): Promise { + let running_strings: string[] = [] + + await this._chunk(async items => { + running_strings.push(items.join(delimiter)) + }) + + return running_strings.join(delimiter) + } + + async implode(delimiter: string): Promise { + return this.join(delimiter) + } + + // TODO intersect + + async isEmpty(): Promise { + return (await this._items.count()) < 1 + } + + async isNotEmpty(): Promise { + return (await this._items.count()) > 0 + } + + async last(): Promise> { + const length = await this._items.count() + if ( length > 0 ) return this._items.at_index(length - 1) + } + + async lastWhere(key: KeyOperator, operator: WhereOperator, operand?: any): Promise> { + return (await this.where(key, operator, operand)).last() + } + + async lastWhereNot(key: KeyOperator, operator: WhereOperator, operand?: any): Promise> { + return (await this.whereNot(key, operator, operand)).last() + } + + async pluck(key: KeyOperator): Promise> { + let new_items: CollectionItem[] = [] + + await this._chunk_all(key, async items => { + new_items = new_items.concat(items.all()) + }) + + return new Collection(new_items) + } + + async max(key: KeyOperator): Promise { + let running_max: number + + await this._chunk_all_numbers(key, async items => { + const local_max = Math.max(...items) + if ( typeof running_max === 'undefined' ) running_max = local_max + else running_max = Math.max(running_max, local_max) + }) + + // @ts-ignore + return running_max + } + + async whereMax(key: KeyOperator): Promise> { + return this.where(key, '=', await this.max(key)) + } + + async min(key: KeyOperator): Promise { + let running_min: number + + await this._chunk_all_numbers(key, async items => { + const local_min = Math.min(...items) + if ( typeof running_min === 'undefined' ) running_min = local_min + else running_min = Math.min(running_min, local_min) + }) + + // @ts-ignore + return running_min + } + + async whereMin(key: KeyOperator): Promise> { + return this.where(key, '=', await this.min(key)) + } + + async merge(merge_with: AsyncCollectionComparable): Promise> { + let items: T2[] + if ( merge_with instanceof Collection ) items = await merge_with.all() + else if ( merge_with instanceof AsyncCollection ) items = await merge_with.all() + else if ( Array.isArray(merge_with) ) items = merge_with + + // @ts-ignore + return new Collection([...items, ...await this.all()]) + } + + async nth(n: number): Promise> { + const matches: CollectionItem[] = [] + let current = 1 + + await this._chunk(async chunk => { + for ( const item of chunk.all() ) { + if ( current === 1 ) matches.push(item) + current += 1 + if ( current > n ) current = 1 + } + }) + + return new Collection(matches) + } + + async forPage(page: number, per_page: number): Promise> { + const start = page * per_page - per_page + const end = page * per_page - 1 + return this._items.from_range(start, end) + } + + pipe(func: AsyncCollectionFunction): any { + return func(this) + } + + /*async pop(): Promise> { + const next_item = await this._items.next() + if ( !next_item.done ) { + return next_item.value + } + }*/ // TODO Fix this + + async random(n: number): Promise> { + const random_items: CollectionItem[] = [] + const fetched_indices: number[] = [] + + const max_n = await this._items.count() + if ( n > max_n ) n = max_n + + while ( random_items.length < n ) { + const index = Math.floor(Math.random() * max_n) + + if ( !fetched_indices.includes(index) ) { + fetched_indices.push(index) + random_items.push(await this._items.at_index(index)) + } + } + + return new Collection(random_items) + } + + async reduce(reducer: KeyReducerFunction, initial_value?: T2): Promise { + let current_value = initial_value + let index = 0 + + await this._chunk(async items => { + for ( const item of items.all() ) { + current_value = reducer(current_value, item, index) + index += 1 + } + }) + + return current_value + } + + async reject(truth_test: AsyncKeyFunction): Promise> { + let rejected: CollectionItem[] = [] + + await this._chunk(async items => { + rejected = rejected.concat(items.all().filter((item, index) => { + return !truth_test(item, index) + })) + }) + + return new Collection(rejected) + } + + async reverse(): Promise> { + return (await this.collect()).reverse() + } + + async search(item: CollectionItem): Promise { + let found_index + let index = 0 + + await this._chunk(async items => { + items.some(possible_item => { + if ( possible_item === item ) { + found_index = index + throw new StopIteration() + } + + index += 1 + return false + }) + }) + + return found_index + } + + async shift(): Promise> { + const next_item = await this._items.next() + if ( !next_item.done ) { + return next_item.value + } + } + + async shuffle(): Promise> { + return (await this.collect()).shuffle() + } + + async slice(start: number, end: number): Promise> { + return this._items.from_range(start, end - 1) + } + + async sort(compare_func?: ComparisonFunction): Promise> { + return (await this.collect()).sort(compare_func) + } + + async sortBy(key?: KeyOperator): Promise> { + return (await this.collect()).sortBy(key) + } + + async sortDesc(compare_func?: ComparisonFunction): Promise> { + return (await this.collect()).sortDesc(compare_func) + } + + async sortByDesc(key?: KeyOperator): Promise> { + return (await this.collect()).sortByDesc(key) + } + + async splice(start: CollectionIndex, deleteCount?: number): Promise> { + return (await this.collect()).splice(start, deleteCount) + } + + async sum(key?: KeyOperator): Promise { + let running_sum: number = 0 + + const chunk_handler = (items: number[]) => { + for ( const item of items ) { + running_sum += item + } + } + + if ( key ) await this._chunk_all_numbers(key, chunk_handler) + else await this._chunk(async chunk => { + chunk_handler(chunk.map(x => Number(x)).all()) + }) + + return running_sum + } + + async take(limit: number): Promise> { + if ( limit === 0 ) return new Collection() + else if ( limit > 0 ) { + return this.slice(0, limit) + } else { + const cnt = await this._items.count() + return this._items.from_range(cnt - (-1 * limit), cnt - 1) + } + } + + async tap(func: AsyncCollectionFunction): Promise> { + await func(this) + return this + } + + async unique(key?: KeyOperator): Promise> { + const has: CollectionItem[] = [] + + if ( !key ) { + await this._chunk(async items => { + for ( const item of items.all() ) { + if ( !has.includes(item) ) has.push(item) + } + }) + } else { + await this._chunk_all(key, async items => { + for ( const item of items.all() ) { + if ( !has.includes(item) ) has.push(item) + } + }) + } + + return new Collection(has) + } + + async toArray(): Promise { + const returns: any = [] + for ( const item of (await this.all()) ) { + if ( item instanceof Collection ) returns.push(item.toArray()) + else if ( item instanceof AsyncCollection ) returns.push(await item.toArray()) + else returns.push(item) + } + return returns + } + + async toJSON(replacer = undefined, space = 4): Promise { + return JSON.stringify(this.toArray(), replacer, space) + } + + [Symbol.asyncIterator]() { + return this._items.clone() + } + + iterator() { + return this._items.clone() + } +} diff --git a/lib/src/collection/Collection.ts b/lib/src/collection/Collection.ts index a0bfb8c..b10c64d 100755 --- a/lib/src/collection/Collection.ts +++ b/lib/src/collection/Collection.ts @@ -108,7 +108,7 @@ class Collection { const middle = Math.floor((items.length - 1) / 2) if ( items.length % 2 ) return items[middle] - else return (items[middle] + items[middle + 1]) / 2; + else return (items[middle] + items[middle + 1]) / 2 } mode(key?: KeyOperator): number { @@ -121,7 +121,7 @@ class Collection { counts[item] = (counts[item] ?? -1) + 1 } - return Math.max(...Object.values(counts).map(Number)) + return Number(Object.keys(counts).reduce((a, b) => counts[a] > counts[b] ? a : b)[0]) } collapse(): Collection { @@ -153,6 +153,10 @@ class Collection { // TODO crossJoin + clone(): Collection { + return new Collection(this._items) + } + diff(items: CollectionComparable): Collection { const exclude = items instanceof Collection ? items.all() : items const matches = [] @@ -172,12 +176,20 @@ class Collection { return new Collection(matches) } - each(func: KeyFunction): Collection { - return new Collection(this._items.map(func)) + some(func: (item: T) => boolean): boolean { + return this._items.some(func) + } + + each(func: KeyFunction): void { + this._items.map(func) } map(func: KeyFunction): Collection { - return this.each(func) + const new_items: CollectionItem[] = [] + this.each(((item, index) => { + new_items.push(func(item, index)) + })) + return new Collection(new_items) } every(func: KeyFunction): boolean { @@ -396,16 +408,6 @@ class Collection { return this } - pull(index: number, fallback: any): MaybeCollectionItem { - let value - const new_items = [] - this._items.forEach((item, item_index) => { - if ( item_index !== index ) new_items.push(item) - else value = item - }) - return value ?? fallback - } - put(index: number, item: CollectionItem): Collection { const new_items = [] let inserted = false @@ -582,7 +584,7 @@ class Collection { return { current_index: 0, next() { - if ( items.length < 1 || this.current_index + 1 >= items.length ) { + if ( items.length < 1 || this.current_index + 1 > items.length ) { return { done: true } } diff --git a/lib/src/collection/Iterable.ts b/lib/src/collection/Iterable.ts new file mode 100644 index 0000000..8999855 --- /dev/null +++ b/lib/src/collection/Iterable.ts @@ -0,0 +1,62 @@ +import {Collection} from './Collection.ts' + +export type MaybeIterationItem = { done: boolean, value?: T } +export type ChunkCallback = (items: Collection) => any + +export class StopIteration extends Error {} + +export abstract class Iterable { + protected index = 0 + + abstract async at_index(i: number): Promise + abstract async from_range(start: number, end: number): Promise> + abstract async count(): Promise + abstract clone(): Iterable + + public async next(): Promise> { + const i = this.index + + if ( i >= await this.count() ) { + return { done: true } + } + + this.index = i + 1 + return { done: false, value: await this.at_index(i) } + } + + [Symbol.asyncIterator]() { + return this + } + + public async chunk(size: number, callback: ChunkCallback) { + const total = await this.count() + + while ( this.index < total ) { + const items = await this.from_range(this.index, this.index + size - 1) + + try { + await callback(items) + } catch ( error ) { + if ( error instanceof StopIteration ) break + else throw error + } + + this.index += size + } + } + + public async seek(index: number) { + if ( index < 0 ) throw new TypeError('Cannot seek to negative index.') + else if ( index >= await this.count() ) throw new TypeError('Cannot seek past last item.') + this.index = index + } + + public async peek(): Promise { + if ( this.index + 1 >= await this.count() ) return undefined + else return this.at_index(this.index + 1) + } + + public async reset() { + this.index = 0 + } +} diff --git a/lib/src/const/status.ts b/lib/src/const/status.ts index 3ba7446..11b793a 100644 --- a/lib/src/const/status.ts +++ b/lib/src/const/status.ts @@ -1,15 +1,17 @@ -const STATUS_STOPPED = Symbol('status stopped') -const STATUS_STARTING = Symbol('status starting') -const STATUS_RUNNING = Symbol('status running') -const STATUS_STOPPING = Symbol('status stopping') -const STATUS_ERROR = Symbol('status error') +enum Status { + Stopped = 'stopped', + Starting = 'starting', + Running = 'running', + Stopping = 'stopping', + Error = 'error', +} const isStatus = (something: any) => [ - STATUS_STOPPED, - STATUS_STARTING, - STATUS_RUNNING, - STATUS_STOPPING, - STATUS_ERROR, + Status.Stopped, + Status.Starting, + Status.Running, + Status.Stopping, + Status.Error, ].includes(something) -export { STATUS_STOPPED, STATUS_STARTING, STATUS_RUNNING, STATUS_STOPPING, STATUS_ERROR, isStatus } +export { Status, isStatus } diff --git a/lib/src/error/RunLevelErrorHandler.ts b/lib/src/error/RunLevelErrorHandler.ts new file mode 100644 index 0000000..7a35cf0 --- /dev/null +++ b/lib/src/error/RunLevelErrorHandler.ts @@ -0,0 +1,39 @@ +import { red, bgRed } from '../external/std.ts' +import { Service } from '../../../di/src/decorator/Service.ts' +import { Logging } from '../service/logging/Logging.ts' + +@Service() +export default class RunLevelErrorHandler { + constructor( + protected logger: Logging, + ) {} + + get handle(): (e: Error) => void { + return (e: Error) => { + this.display(e) + Deno.exit(1) + } + } + + display(e: Error) { + try { + const error_string = `RunLevelErrorHandler invoked: + +${bgRed(' ')} +${bgRed(' UNCAUGHT TOP-LEVEL ERROR ')} +${bgRed(' ')} + +${e.constructor ? e.constructor.name : e.name} +${red(`---------------------------------------------------`)} +${e.stack} +` + this.logger.error(error_string, true) + } catch (display_e) { + // The error display encountered an error... + // just throw the original so it makes it out + console.error('RunLevelErrorHandler encountered an error:', display_e.message) + throw e + } + } + +} diff --git a/lib/src/external/db.ts b/lib/src/external/db.ts new file mode 100644 index 0000000..14bb523 --- /dev/null +++ b/lib/src/external/db.ts @@ -0,0 +1 @@ +export * from 'https://deno.land/x/postgres/mod.ts' diff --git a/lib/src/external/http.ts b/lib/src/external/http.ts new file mode 100644 index 0000000..527ddb2 --- /dev/null +++ b/lib/src/external/http.ts @@ -0,0 +1,2 @@ +export * from 'https://deno.land/std@0.53.0/http/server.ts' +export * from 'https://deno.land/std@0.53.0/http/cookie.ts' diff --git a/lib/src/external/postgres.ts b/lib/src/external/postgres.ts new file mode 100644 index 0000000..14bb523 --- /dev/null +++ b/lib/src/external/postgres.ts @@ -0,0 +1 @@ +export * from 'https://deno.land/x/postgres/mod.ts' diff --git a/lib/src/external/std.ts b/lib/src/external/std.ts index ed3f852..1cd766c 100644 --- a/lib/src/external/std.ts +++ b/lib/src/external/std.ts @@ -1,3 +1,4 @@ -export { serve } from 'https://deno.land/std/http/server.ts' -export * from 'https://deno.land/std/fmt/colors.ts' +export * from 'https://deno.land/std@0.53.0/fmt/colors.ts' export { config as dotenv } from 'https://deno.land/x/dotenv/mod.ts' +export * as path from 'https://deno.land/std@0.53.0/path/mod.ts' +export * as fs from 'https://deno.land/std@0.53.0/fs/mod.ts' diff --git a/lib/src/http/Controller.ts b/lib/src/http/Controller.ts new file mode 100644 index 0000000..b4612b4 --- /dev/null +++ b/lib/src/http/Controller.ts @@ -0,0 +1,4 @@ + +export default class Controller { + +} diff --git a/lib/src/http/CookieJar.ts b/lib/src/http/CookieJar.ts new file mode 100644 index 0000000..0723849 --- /dev/null +++ b/lib/src/http/CookieJar.ts @@ -0,0 +1,69 @@ +import { Injectable } from '../../../di/src/decorator/Injection.ts' +import { getCookies, setCookie, delCookie, ServerRequest } from '../external/http.ts' +import { InMemCache } from '../support/InMemCache.ts' +import { HTTPRequest } from './type/HTTPRequest.ts' + +export interface Cookie { + key: string, + original_value: string, + value: any, +} + +export type MaybeCookie = Cookie | undefined + +// TODO cookie options (http only, expires, &c.) +@Injectable() +export class CookieJar { + protected _parsed: { [key: string]: string } = {} + protected _cache = new InMemCache() + + constructor( + protected request: HTTPRequest, + ) { + this._parsed = getCookies(this.request.to_native) + } + + public async get(key: string): Promise { + // Try the cache + if ( await this._cache.has(key) ) + return this._cache.fetch(key) + + // If the cache missed, try to parse it and load in cache + if ( key in this._parsed ) { + let value = this._parsed[key] + try { + value = JSON.parse(atob(this._parsed[key])) + } catch(e) {} + + const cookie = { + key, + value, + original_value: this._parsed[key], + } + + await this._cache.put(key, cookie) + return cookie + } + } + + public async set(key: string, value: any): Promise { + const original_value = btoa(JSON.stringify(value)) + const cookie = { + key, + value, + original_value, + } + + await this._cache.put(key, value) + setCookie(this.request.response, { name: key, value: original_value }) + } + + public async has(key: string): Promise { + return (await this._cache.has(key)) || key in this._parsed + } + + public async delete(key: string): Promise { + await this._cache.drop(key) + delCookie(this.request.response, key) + } +} diff --git a/lib/src/http/Middleware.ts b/lib/src/http/Middleware.ts new file mode 100644 index 0000000..019cf51 --- /dev/null +++ b/lib/src/http/Middleware.ts @@ -0,0 +1,3 @@ +export class Middleware { + +} diff --git a/lib/src/http/Request.ts b/lib/src/http/Request.ts new file mode 100644 index 0000000..b2d19ca --- /dev/null +++ b/lib/src/http/Request.ts @@ -0,0 +1,115 @@ +import { ServerRequest } from '../external/http.ts' +import { HTTPProtocol, HTTPRequest, RemoteHost } from './type/HTTPRequest.ts' +import { Response } from './Response.ts' +import { HTTPResponse } from './type/HTTPResponse.ts' +import Utility from '../service/utility/Utility.ts' +import { Injectable } from '../../../di/src/decorator/Injection.ts' + +@Injectable() +export class Request implements HTTPRequest { + public readonly response: HTTPResponse + private readonly _deno_req: ServerRequest + private _body: any + private _query: { [key: string]: any } = {} + + public readonly url: string + public readonly method: string + public readonly protocol: HTTPProtocol + public readonly connection: Deno.Conn + public readonly secure: boolean = false + + public get headers() { + return this._deno_req.headers + } + + get to_native(): ServerRequest { + return this._deno_req + } + + get cookies() { + return this.response.cookies + } + + constructor( + protected utility: Utility, + from: ServerRequest + ) { + this._deno_req = from + this.url = this._deno_req.url + this.method = this._deno_req.method.toLowerCase() + this.protocol = { + string: this._deno_req.proto, + major: this._deno_req.protoMajor, + minor: this._deno_req.protoMinor, + } + this.connection = this._deno_req.conn + this.response = new Response(this) + } + + public async prepare() { + this._body = await Deno.readAll(this._deno_req.body) + + const url_params = new URLSearchParams(this.url.substr(1)) + const param_obj = Object.fromEntries(url_params) + const params: any = {} + for ( const key in param_obj ) { + if ( !param_obj.hasOwnProperty(key) ) continue + if ( param_obj[key] === '' ) params[key] = true + else params[key] = this.utility.infer(param_obj[key]) + } + + this._query = params + } + + respond(res: any) { + return this._deno_req.respond(res) + } + + // public body: RequestBody = {} + // public original_body: RequestBody = {} + + get remote() { + return this.connection.remoteAddr as RemoteHost + } + + get body() { + return this._body + } + + get query() { + return this._query + } + + get hostname() { + return this.headers.get('host')?.split(':')[0] + } + + get path() { + return this.url.split('?')[0] + } + + get xhr() { + return this.headers.get('x-requested-with')?.toLowerCase() === 'xmlhttprequest' + } + + /* + body + fresh/stale - cache + remote ips (proxy) + params + route? + signedCookies + */ + + /* + accepts content type + accepts charsets + accepts encodings + accepts languages + get header + is content type + get param with default value + get input with default value + range header parser + */ +} diff --git a/lib/src/http/Response.ts b/lib/src/http/Response.ts new file mode 100644 index 0000000..630355b --- /dev/null +++ b/lib/src/http/Response.ts @@ -0,0 +1,30 @@ +import { HTTPResponse } from './type/HTTPResponse.ts' +import { HTTPRequest } from './type/HTTPRequest.ts' +import { ServerRequest } from '../external/http.ts' +import {CookieJar} from "./CookieJar.ts"; + +export class Response implements HTTPResponse { + public status = 200 + public headers = new Headers() + public body = '' + public readonly cookies: CookieJar + + private readonly _deno_req: ServerRequest + private readonly _request: HTTPRequest + + private _sent = false + get sent() { + return this._sent + } + + constructor(to: HTTPRequest) { + this._deno_req = to.to_native + this._request = to + this.cookies = new CookieJar(to) + } + + send() { + this._sent = true + return this._deno_req.respond(this) + } +} diff --git a/lib/src/http/SecureRequest.ts b/lib/src/http/SecureRequest.ts new file mode 100644 index 0000000..1752ba6 --- /dev/null +++ b/lib/src/http/SecureRequest.ts @@ -0,0 +1,6 @@ +import { HTTPRequest } from './type/HTTPRequest.ts' +import { Request } from './Request.ts' + +export default class SecureRequest extends Request implements HTTPRequest { + public readonly secure: boolean = true +} diff --git a/lib/src/http/type/HTTPRequest.ts b/lib/src/http/type/HTTPRequest.ts new file mode 100644 index 0000000..3081a96 --- /dev/null +++ b/lib/src/http/type/HTTPRequest.ts @@ -0,0 +1,30 @@ +import { ServerRequest } from '../../external/http.ts' +import {HTTPResponse} from "./HTTPResponse.ts"; + +export interface HTTPProtocol { + string: string, + major: number, + minor: number, +} + +export interface RemoteHost { + hostname: string, + port: number, + transport: string, +} + +export interface HTTPRequest { + url: string + method: string + protocol: HTTPProtocol + headers: Headers + connection: Deno.Conn + response: HTTPResponse + to_native: ServerRequest + + remote: RemoteHost + body: any + query: any + hostname: string | undefined + secure: boolean +} diff --git a/lib/src/http/type/HTTPResponse.ts b/lib/src/http/type/HTTPResponse.ts new file mode 100644 index 0000000..10e7b67 --- /dev/null +++ b/lib/src/http/type/HTTPResponse.ts @@ -0,0 +1,12 @@ +import {CookieJar} from '../CookieJar.ts' + +export interface HTTPResponse { + status: number + headers: Headers + body: Uint8Array | Deno.Reader | string + trailers?: () => Promise | Headers + sent: boolean + cookies: CookieJar, + + send: () => void +} diff --git a/lib/src/lifecycle/Application.ts b/lib/src/lifecycle/Application.ts new file mode 100644 index 0000000..6a27be2 --- /dev/null +++ b/lib/src/lifecycle/Application.ts @@ -0,0 +1,43 @@ +import { Service } from '../../../di/src/decorator/Service.ts' +import { Logging } from '../service/logging/Logging.ts' +import Unit from './Unit.ts' +import { container, make } from '../../../di/src/global.ts' +import { DependencyKey } from '../../../di/src/type/DependencyKey.ts' +import RunLevelErrorHandler from '../error/RunLevelErrorHandler.ts' + +@Service() +export default class Application { + constructor( + protected logger: Logging, + protected rleh: RunLevelErrorHandler, + protected units: Unit[], + ) {} + + make(token: DependencyKey) { + return make(token) + } + + container() { + return container + } + + async up() { + + } + + async down() { + + } + + async run() { + try { + this.logger.info('Starting Daton...') + } catch (e) { + await this.app_error(e) + } + } + + async app_error(e: Error) { + this.rleh.handle(e) + } +} diff --git a/lib/src/lifecycle/Unit.ts b/lib/src/lifecycle/Unit.ts index 850b66d..10abec4 100644 --- a/lib/src/lifecycle/Unit.ts +++ b/lib/src/lifecycle/Unit.ts @@ -1,19 +1,42 @@ -import { STATUS_STOPPED, isStatus } from '../const/status.ts' +import { Status, isStatus } from '../const/status.ts' +import { Unit } from './decorators.ts' +import { Collection } from '../collection/Collection.ts' +import {container, make} from '../../../di/src/global.ts' +import {DependencyKey} from "../../../di/src/type/DependencyKey.ts"; +import Instantiable, {isInstantiable} from "../../../di/src/type/Instantiable.ts"; + +const isLifecycleUnit = (something: any): something is (typeof LifecycleUnit) => { + return isInstantiable(something) && something.prototype instanceof LifecycleUnit +} export default abstract class LifecycleUnit { - private _status = STATUS_STOPPED + private _status = Status.Stopped public get status() { return this._status } public set status(status) { - if ( !isStatus(status) ) - throw new TypeError('Invalid unit status: '+status.description) - this._status = status } public async up(): Promise {}; public async down(): Promise {}; + + public static get_dependencies(): Collection { + if ( isInstantiable(this) ) { + const deps = new Collection() + for ( const dependency of container.get_dependencies(this) ) { + if ( isLifecycleUnit(dependency) ) + deps.push(dependency) + } + + return deps + } + return new Collection() + } + + protected make(target: Instantiable|DependencyKey, ...parameters: any[]) { + return make(target, ...parameters) + } } diff --git a/lib/src/service/logging/Logging.ts b/lib/src/service/logging/Logging.ts index f83a64b..1bdfef7 100644 --- a/lib/src/service/logging/Logging.ts +++ b/lib/src/service/logging/Logging.ts @@ -45,7 +45,11 @@ class Logging { const message = this.build_message(level, output) if ( this._level >= level || force ) { for ( const logger of this._loggers ) { - logger.write(message) + try { + logger.write(message) + } catch (e) { + console.error('logging error', e) + } } } } diff --git a/lib/src/service/logging/global.ts b/lib/src/service/logging/global.ts new file mode 100644 index 0000000..c4bb661 --- /dev/null +++ b/lib/src/service/logging/global.ts @@ -0,0 +1,5 @@ +import { make } from '../../../../di/src/global.ts' +import { Logging } from './Logging.ts' + +const logger = make(Logging) +export { logger } diff --git a/lib/src/service/logging/types.ts b/lib/src/service/logging/types.ts index c6d1f9f..9227b85 100644 --- a/lib/src/service/logging/types.ts +++ b/lib/src/service/logging/types.ts @@ -1,11 +1,11 @@ enum LoggingLevel { Silent = 0, Success = 1, - Error = 1, - Warning = 2, - Info = 3, - Debug = 4, - Verbose = 5, + Error = 2, + Warning = 3, + Info = 4, + Debug = 5, + Verbose = 6, } const isLoggingLevel = (something: any): something is LoggingLevel => { diff --git a/lib/src/service/utility/Utility.ts b/lib/src/service/utility/Utility.ts new file mode 100644 index 0000000..5fb9745 --- /dev/null +++ b/lib/src/service/utility/Utility.ts @@ -0,0 +1,55 @@ +import { Service } from '../../../../di/src/decorator/Service.ts' +import { Logging } from '../logging/Logging.ts' + +@Service() +export default class Utility { + constructor( + protected logger: Logging + ) {} + + deep_copy(target: T): T { + if ( target === null ) + return target + + if ( target instanceof Date ) + return new Date(target.getTime()) as any + + if ( target instanceof Array ) { + const copy = [] as any[] + (target as any[]).forEach(item => { copy.push(item) }) + return copy.map((item: any) => this.deep_copy(item)) as any + } + + if ( typeof target === 'object' && target !== {} ) { + const copy = { ...(target as {[key: string]: any }) } as { [key: string]: any } + Object.keys(copy).forEach(key => { + copy[key] = this.deep_copy(copy[key]) + }) + } + + return target + } + + // TODO deep_merge + + infer(val: string): any { + if ( !val ) return undefined + else if ( val.toLowerCase() === 'true' ) return true + else if ( val.toLowerCase() === 'false' ) return false + else if ( !isNaN(Number(val)) ) return Number(val) + else if ( this.is_json(val) ) return JSON.parse(val) + else if ( val.toLowerCase() === 'null' ) return null + else if ( val.toLowerCase() === 'undefined' ) return undefined + else return val + } + + is_json(val: string): boolean { + try { + JSON.parse(val) + return true + } catch(e) { + this.logger.verbose(`Error encountered while checking is_json. Might be invalid. Error: ${e.message}`) + return false + } + } +} diff --git a/lib/src/support/Cache.ts b/lib/src/support/Cache.ts new file mode 100644 index 0000000..5ee1d40 --- /dev/null +++ b/lib/src/support/Cache.ts @@ -0,0 +1,6 @@ +export default abstract class Cache { + public abstract async fetch(key: string): Promise; + public abstract async put(key: string, value: any): Promise; + public abstract async has(key: string): Promise; + public abstract async drop(key: string): Promise; +} diff --git a/lib/src/support/CacheFactory.ts b/lib/src/support/CacheFactory.ts new file mode 100644 index 0000000..7f79622 --- /dev/null +++ b/lib/src/support/CacheFactory.ts @@ -0,0 +1,25 @@ +import AbstractFactory from '../../../di/src/factory/AbstractFactory.ts' +import Cache from './Cache.ts' +import { InMemCache } from './InMemCache.ts' +import {DependencyRequirement} from '../../../di/src/type/DependencyRequirement.ts' +import {Collection} from '../collection/Collection.ts' + +// TODO add support for configurable Cache backends + +export default class CacheFactory extends AbstractFactory { + constructor() { + super({}) + } + + produce(dependencies: any[], parameters: any[]): Cache { + return new InMemCache() + } + + match(something: any) { + return something === Cache + } + + get_dependency_keys(): Collection { + return new Collection() + } +} diff --git a/lib/src/support/InMemCache.ts b/lib/src/support/InMemCache.ts new file mode 100644 index 0000000..cf8a663 --- /dev/null +++ b/lib/src/support/InMemCache.ts @@ -0,0 +1,30 @@ +import Cache from './Cache.ts' +import { Collection } from '../collection/Collection.ts' + +export interface InMemCacheItem { + key: string, + item: any, +} + +export class InMemCache extends Cache { + protected items: Collection = new Collection() + + public async fetch(key: string) { + const item = this.items.firstWhere('key', '=', key) + if ( item ) return item.item + } + + public async put(key: string, item: any) { + const existing = this.items.firstWhere('key', '=', key) + if ( existing ) existing.item = item + else this.items.push({ key, item }) + } + + public async has(key: string) { + return this.items.where('key', '=', key).length > 0 + } + + public async drop(key: string) { + this.items = this.items.whereNot('key', '=', key) + } +} diff --git a/lib/src/support/mixins.ts b/lib/src/support/mixins.ts new file mode 100644 index 0000000..b2eb24f --- /dev/null +++ b/lib/src/support/mixins.ts @@ -0,0 +1,9 @@ +export function applyMixins(derivedCtor: any, baseCtors: any[]) { + baseCtors.forEach(baseCtor => { + Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => { + const desc = Object.getOwnPropertyDescriptor(baseCtor.prototype, name) + if ( typeof desc !== 'undefined' ) + Object.defineProperty(derivedCtor.prototype, name, desc) + }) + }) +} diff --git a/lib/src/unit/Canon.ts b/lib/src/unit/Canon.ts new file mode 100644 index 0000000..733aff4 --- /dev/null +++ b/lib/src/unit/Canon.ts @@ -0,0 +1,35 @@ +import {Service} from '../../../di/src/decorator/Service.ts' + +export type CanonicalResolver = (key: string) => any + +export class DuplicateResolverKeyError extends Error { + constructor(key: string) { + super(`There is already a canonical resource with the scope ${key} registered.`) + } +} + +@Service() +export class Canon { + protected resources: { [key: string]: any } = {} + + get(key: string): any { + const key_parts = key.split('::') + let desc_value = this.resources + key_parts.forEach(part => { + if ( typeof desc_value === 'function' ) { + desc_value = desc_value(part) + } else { + desc_value = desc_value[part] + } + }) + return desc_value + } + + register_resource(scope: string, resolver: CanonicalResolver) { + if ( this.resources[scope] ) { + throw new DuplicateResolverKeyError(scope) + } + + this.resources[scope] = resolver + } +} diff --git a/lib/src/unit/Canonical.ts b/lib/src/unit/Canonical.ts new file mode 100644 index 0000000..6c32ab1 --- /dev/null +++ b/lib/src/unit/Canonical.ts @@ -0,0 +1,58 @@ +import LifecycleUnit from '../lifecycle/Unit.ts' +import {fs, path} from '../external/std.ts' +import {Canon} from './Canon.ts' + +export interface CanonicalDefinition { + canonical_name: string, + original_name: string, + imported: any, +} + +export class Canonical extends LifecycleUnit { + protected base_path: string = '.' + protected suffix: string = '.ts' + protected canonical_item: string = '' + protected _items: { [key: string]: any } = {} + + public get path(): string { + return path.resolve(this.base_path) + } + + public get canonical_items() { + return `${this.canonical_item}s` + } + + public async up() { + for await ( const entry of fs.walk(this.path) ) { + if ( !entry.isFile || !entry.path.endsWith(this.suffix) ) continue + const def = await this._get_canonical_definition(entry.path) + this._items[def.canonical_name] = await this.init_canonical_item(def) + } + + this.make(Canon).register_resource(this.canonical_items, (key: string) => this.get(key)) + } + + public async init_canonical_item(definition: CanonicalDefinition): Promise { + return definition.imported.default + } + + private async _get_canonical_definition(file_path: string): Promise { + const original_name = file_path.replace(this.path, '').substr(1) + const path_regex = new RegExp(path.SEP, 'g') + const canonical_name = original_name.replace(path_regex, ':') + .split('').reverse().join('') + .substr(this.suffix.length) + .split('').reverse().join('') + const imported = await import(file_path) + return { canonical_name, original_name, imported } + } + + public get(key: string): any { + const key_parts = key.split('.') + let desc_value = this._items + key_parts.forEach(part => { + desc_value = desc_value[part] + }) + return desc_value + } +} diff --git a/lib/src/unit/Config.ts b/lib/src/unit/Config.ts new file mode 100644 index 0000000..f52242c --- /dev/null +++ b/lib/src/unit/Config.ts @@ -0,0 +1,9 @@ +import {Canonical} from './Canonical.ts' +import { Unit } from '../lifecycle/decorators.ts' + +@Unit() +export default class Config extends Canonical { + protected base_path = './app/configs' + protected suffix = '.config.ts' + protected canonical_item = 'config' +} diff --git a/lib/src/unit/Controllers.ts b/lib/src/unit/Controllers.ts new file mode 100644 index 0000000..9bc6bd1 --- /dev/null +++ b/lib/src/unit/Controllers.ts @@ -0,0 +1,20 @@ +import { InstantiableCanonical } from './InstantiableCanonical.ts' +import { CanonicalDefinition } from './Canonical.ts' +import Controller from '../http/Controller.ts' +import { Unit } from '../lifecycle/decorators.ts' + +@Unit() +export default class Controllers extends InstantiableCanonical { + protected base_path = './app/http/controllers' + protected canonical_item = 'controller' + protected suffix = '.controller.ts' + + public async init_canonical_item(def: CanonicalDefinition) { + const item = await super.init_canonical_item(def) + if ( !(item instanceof Controller) ) { + throw new TypeError(`Invalid controller definition: ${def.original_name}. Controllers must extend from Daton's base Controller class.`) + } + + return item + } +} diff --git a/lib/src/unit/InstantiableCanonical.ts b/lib/src/unit/InstantiableCanonical.ts new file mode 100644 index 0000000..b3836ad --- /dev/null +++ b/lib/src/unit/InstantiableCanonical.ts @@ -0,0 +1,18 @@ +import {Canonical, CanonicalDefinition} from './Canonical.ts' +import {isInstantiable} from '../../../di/src/type/Instantiable.ts' + +export class InvalidCanonicalExportError extends Error { + constructor(name: string) { + super(`Unable to import canonical item from "${name}". The default export of this file is invalid.`) + } +} + +export class InstantiableCanonical extends Canonical { + public async init_canonical_item(def: CanonicalDefinition) { + if ( isInstantiable(def.imported.default) ) { + return this.make(def.imported.default) + } + + throw new InvalidCanonicalExportError(def.original_name) + } +} diff --git a/lib/src/unit/Middlewares.ts b/lib/src/unit/Middlewares.ts new file mode 100644 index 0000000..a1e59fa --- /dev/null +++ b/lib/src/unit/Middlewares.ts @@ -0,0 +1,20 @@ +import { InstantiableCanonical } from './InstantiableCanonical.ts' +import { CanonicalDefinition } from './Canonical.ts' +import { Middleware } from '../http/Middleware.ts' +import { Unit } from '../lifecycle/decorators.ts' + +@Unit() +export default class Middlewares extends InstantiableCanonical { + protected base_path = './app/http/middleware' + protected canonical_item = 'middleware' + protected suffix = '.middleware.ts' + + public async init_canonical_item(def: CanonicalDefinition) { + const item = await super.init_canonical_item(def) + if ( !(item instanceof Middleware) ) { + throw new TypeError(`Invalid middleware definition: ${def.original_name}. Middleware must extend from Daton's base Middleware class.`) + } + + return item + } +} diff --git a/lib/src/unit/Scaffolding.ts b/lib/src/unit/Scaffolding.ts index 5186949..36e73a8 100644 --- a/lib/src/unit/Scaffolding.ts +++ b/lib/src/unit/Scaffolding.ts @@ -1,30 +1,55 @@ import LifecycleUnit from '../lifecycle/Unit.ts' import { Unit } from '../lifecycle/decorators.ts' -import { dotenv } from '../external/std.ts' import { Logging } from '../service/logging/Logging.ts' import StandardLogger from '../service/logging/StandardLogger.ts' +import { isLoggingLevel } from '../service/logging/types.ts' +import Utility from '../service/utility/Utility.ts' +import { make } from '../../../di/src/global.ts' +import 'https://deno.land/x/dotenv/load.ts' +import { Container } from '../../../di/src/Container.ts' +import { Inject } from '../../../di/src/decorator/Injection.ts' +import CacheFactory from "../support/CacheFactory.ts"; + +const env = (name: string, fallback: any) => { + const scaffolding = make(Scaffolding) + return scaffolding.env(name) ?? fallback +} + +export { env } @Unit() export default class Scaffolding extends LifecycleUnit { - private config = {} - constructor( - protected logger: Logging - ) { - super() + protected logger: Logging, + protected utility: Utility, + @Inject('injector') protected injector: Container, + ) { super() } + + public env(name: string) { + return this.utility.infer(Deno.env.get(name) ?? '') } - public refresh_env() { - this.config = dotenv() + public async up() { + this.setup_logging() } public setup_logging() { StandardLogger.register() + + try { + this.logger.verbose('Attempting to load logging level from the environment') + const env_level = this.env('DATON_LOGGING_LEVEL') + this.logger.verbose(`Read logging level: ${env_level}`) + if ( isLoggingLevel(env_level) ) { + this.logger.verbose('Logging level is valid.') + this.logger.level = env_level + this.logger.debug(`Set logging level from environment: ${env_level}`) + } + } catch (e) {} + this.logger.info('Logging initialized.', true) - } - public async up() { - this.setup_logging() - this.refresh_env() + this.logger.verbose('Adding the cache production factory to the container...') + this.injector.register_factory(new CacheFactory()) } } diff --git a/orm/src/builder/Builder.ts b/orm/src/builder/Builder.ts new file mode 100644 index 0000000..1b1234d --- /dev/null +++ b/orm/src/builder/Builder.ts @@ -0,0 +1,50 @@ +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"; + +export function raw(value: string) { + return new RawValue(value) +} + +export class IncorrectInterpolationError extends Error { + constructor(expected: number, received: number) { + super(`Unable to interpolate arguments into query. Expected ${expected} argument${expected === 1 ? '' : 's'}, but received ${received}.`) + } +} + +export class Builder { + // create table, insert, delete, alter table, drop table, select + + public select(...fields: FieldSet[]) { + fields = fields.flat() + const select = new Select() + return select.fields(...fields) + } + + public update(target?: QuerySource, alias?: string) { + const update = new Update() + if ( target ) update.to(target, alias) + return update + } + + public insert(target?: QuerySource, alias?: string) { + const insert = new Insert() + if ( target ) insert.into(target, alias) + return insert + } + + public statement(statement: string, ...interpolations: EscapedValue[]) { + return new Statement(statement, interpolations) + } + + public static raw(value: string) { + return new RawValue(value) + } + + public static default() { + return this.raw('DEFAULT') + } +} diff --git a/orm/src/builder/RawValue.ts b/orm/src/builder/RawValue.ts new file mode 100644 index 0000000..24c4424 --- /dev/null +++ b/orm/src/builder/RawValue.ts @@ -0,0 +1,5 @@ +export default class RawValue { + constructor( + public readonly value: string + ) {} +} diff --git a/orm/src/builder/Statement.ts b/orm/src/builder/Statement.ts new file mode 100644 index 0000000..3402d8a --- /dev/null +++ b/orm/src/builder/Statement.ts @@ -0,0 +1,36 @@ +import {EscapedValue, escape} from './types.ts' +import {IncorrectInterpolationError} from './Builder.ts' +import ConnectionExecutable from './type/ConnectionExecutable.ts' + +export class Statement extends ConnectionExecutable { + constructor( + public statement: string, + public interpolations: EscapedValue[] + ) { + super() + } + + sql(): string { + const statement = this.statement + const interpolations = [...this.interpolations].reverse() + const expected_interpolations = (statement.match(/\?/g) || []).length + if ( expected_interpolations !== interpolations.length ) { + throw new IncorrectInterpolationError(expected_interpolations, interpolations.length) + } + + const query_chars = [] + for ( const char of statement.split('') ) { + if ( char === '?' ) { + const val = interpolations.pop() + if ( typeof val !== 'undefined' ) + query_chars.push(escape(val)) + else + throw new TypeError('Got an undefined interpolation value. Unable to continue.') + } else { + query_chars.push(char) + } + } + + return query_chars.join('') + } +} diff --git a/orm/src/builder/type/ConnectionExecutable.ts b/orm/src/builder/type/ConnectionExecutable.ts new file mode 100644 index 0000000..f5ad134 --- /dev/null +++ b/orm/src/builder/type/ConnectionExecutable.ts @@ -0,0 +1,89 @@ +import {QueryResult} from '../../db/types.ts' +import {make} from '../../../../di/src/global.ts' +import Database from '../../service/Database.ts' +import {logger} from '../../../../lib/src/service/logging/global.ts' +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' + +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) { + 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 ${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) { + 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)) + } + + iterator() { + return new ResultIterable(this) + } + + results(chunk_size = 1000) { + return new ResultCollection(this.iterator(), chunk_size) + } + + __target_connection?: Connection + __target_operator: ResultOperator = new ObjectResultOperator() + + target_connection(connection: string | Connection) { + this.__target_connection = typeof connection === 'string' ? make(Database).connection(connection) : connection + return this + } + + target_operator(operator: ResultOperator) { + this.__target_operator = operator + return this + } + + async execute(): Promise { + if ( !(this.__target_connection instanceof Connection) ) { + throw new Error('Unable to execute database item: no target connection.') + } + + return this.execute_in_connection(this.__target_connection) + } + + async count(): Promise { + if ( !(this.__target_connection instanceof Connection) ) { + throw new Error('Unable to execute database item: no target connection.') + } + + const result = await this.__target_connection.query(this.to_count()) + const row = result.rows.first() + if ( row ) return Number(row.to_count) + return 0 + } + + async execute_in_connection(connection: string | Connection): Promise { + const conn = typeof connection === 'string' ? make(Database).connection(connection) : connection + + logger.debug(`Executing statement in connection: ${conn.name}`) + + const sql = this.sql(0) + logger.verbose(sql) + + return conn.query(sql) + } +} diff --git a/orm/src/builder/type/ConnectionMutable.ts b/orm/src/builder/type/ConnectionMutable.ts new file mode 100644 index 0000000..d13476e --- /dev/null +++ b/orm/src/builder/type/ConnectionMutable.ts @@ -0,0 +1,37 @@ +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"; + +export default abstract class ConnectionMutable extends ConnectionExecutable { + __execution_result?: QueryResult + + async get_row(i: number) { + const result = await this.get_execution_result() + const row = result.rows.at(i) + if ( row ) return this.__target_operator.inflate_row(row) + } + + async get_range(start: number, end: number) { + 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)) + } + + async count() { + const result = await this.get_execution_result() + return result.row_count + } + + async get_execution_result(): Promise { + if ( this.__execution_result ) return this.__execution_result + else return this.execute() + } + + async execute_in_connection(connection: string | Connection): Promise { + const result = await super.execute_in_connection(connection) + this.__execution_result = result + return result + } + +} diff --git a/orm/src/builder/type/HavingBuilder.ts b/orm/src/builder/type/HavingBuilder.ts new file mode 100644 index 0000000..2cd3fe0 --- /dev/null +++ b/orm/src/builder/type/HavingBuilder.ts @@ -0,0 +1,107 @@ +import { + EscapedValue, + HavingPreOperator, + HavingStatement, + SQLHavingOperator, + escape, + isHavingClause, + isHavingGroup +} from '../types.ts' +import {HavingBuilderFunction} from './Select.ts' + +export class HavingBuilder { + protected _havings: HavingStatement[] = [] + + get having_items() { + return this._havings + } + + havings_to_sql(havings?: HavingStatement[], level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + let statements = [] + for ( const having of havings || this._havings ) { + if ( isHavingClause(having) ) { + statements.push(`${indent}${statements.length < 1 ? '' : having.preop + ' '}${having.field} ${having.operator} ${having.operand}`) + } else if ( isHavingGroup(having) ) { + statements.push(`${indent}${statements.length < 1 ? '' : having.preop + ' '}(\n${this.havings_to_sql(having.items, level + 1)}\n${indent})`) + } + } + + return statements.filter(Boolean).join('\n') + } + + private _createHaving(preop: HavingPreOperator, field: string | HavingBuilderFunction, operator?: SQLHavingOperator, operand?: any) { + if ( typeof field === 'function' ) { + const having_builder = new HavingBuilder() + field(having_builder) + this._havings.push({ + preop, + items: having_builder.having_items, + }) + } else if ( field && operator && typeof operand !== 'undefined' ) { + this._havings.push({ + field, operator, operand: escape(operand), preop + }) + } + } + + having(field: string | HavingBuilderFunction, operator?: SQLHavingOperator, operand?: any) { + this._createHaving('AND', field, operator, operand) + return this + } + + havingIn(field: string, values: EscapedValue) { + this._havings.push({ + field, + operator: 'IN', + operand: escape(values), + preop: 'AND', + }) + return this + } + + havingNot(field: string | HavingBuilderFunction, operator?: SQLHavingOperator, operand?: EscapedValue) { + this._createHaving('AND NOT', field, operator, operand) + return this + } + + havingNotIn(field: string, values: EscapedValue) { + this._havings.push({ + field, + operator: 'NOT IN', + operand: escape(values), + preop: 'AND' + }) + return this + } + + orHaving(field: string | HavingBuilderFunction, operator?: SQLHavingOperator, operand?: EscapedValue) { + this._createHaving('OR', field, operator, operand) + return this + } + + orHavingNot(field: string | HavingBuilderFunction, operator?: SQLHavingOperator, operand?: EscapedValue) { + this._createHaving('OR NOT', field, operator, operand) + return this + } + + orHavingIn(field: string, values: EscapedValue) { + this._havings.push({ + field, + operator: 'IN', + operand: escape(values), + preop: 'OR', + }) + return this + } + + orHavingNotIn(field: string, values: EscapedValue) { + this._havings.push({ + field, + operator: 'NOT IN', + operand: escape(values), + preop: 'OR', + }) + return this + } +} diff --git a/orm/src/builder/type/Insert.ts b/orm/src/builder/type/Insert.ts new file mode 100644 index 0000000..44c20c8 --- /dev/null +++ b/orm/src/builder/type/Insert.ts @@ -0,0 +1,118 @@ +import ConnectionMutable from './ConnectionMutable.ts' +import {EscapedValue, FieldValueObject, QuerySource} from '../types.ts' +import {MalformedSQLGrammarError} from './Select.ts' +import {TableRefBuilder} from './TableRefBuilder.ts' +import {applyMixins} from '../../../../lib/src/support/mixins.ts' +import {escape, FieldSet} from '../types.ts' +import {raw} from '../Builder.ts' + +// TODO support DEFAULT VALUES +// TODO support ON CONFLICT +export class Insert extends ConnectionMutable { + protected _target?: QuerySource = undefined + protected _columns: string[] = [] + protected _rows: string[] = [] + protected _fields: string[] = [] + protected _return_all = 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.') + + if ( this._rows.length < 1 ) + throw new MalformedSQLGrammarError('There are no rows to insert.') + + const table_ref = this.source_alias_to_table_ref(this._target) + const returning = this._return_all ? this._columns.join(', ') : this._fields.join(', ') + const fields = escape(this._columns.map(x => raw(x))) + + return [ + `INSERT INTO ${this.serialize_table_ref(table_ref)}`, + ` ${fields}`, + 'VALUES', + ` ${this._rows.join(',\n ')}`, + ...(returning.trim() ? [`RETURNING ${returning}`] : []) + ].filter(x => String(x).trim()).join(`\n${indent}`) + } + + into(source: QuerySource, alias?: string) { + if ( !alias ) this._target = source + else this._target = { ref: source, alias } + return this + } + + columns(...columns: string[]) { + this._columns = columns + return this + } + + row_raw(...row: EscapedValue[]) { + if ( row.length !== this._columns.length ) + throw new MalformedSQLGrammarError(`Cannot insert row with ${row.length} values using a query that has ${this._columns.length} columns specified.`) + + this._rows.push(escape(row)) + return this + } + + row(row: FieldValueObject) { + const columns = [] + const row_raw = [] + + for ( const field in row ) { + if ( !row.hasOwnProperty(field) ) continue + columns.push(field) + row_raw.push(row[field]) + } + + this.columns(...columns) + this.row_raw(...row_raw) + return this + } + + rows(rows: FieldValueObject[]) { + const [initial, ...rest] = rows + + const columns = [] + const initial_raw = [] + + for ( const field in initial ) { + if ( !initial.hasOwnProperty(field) ) continue + columns.push(field) + initial_raw.push(initial[field]) + } + + this.columns(...columns) + this.row_raw(...initial_raw) + + for ( const row of rest ) { + const values = [] + for ( const col of columns ) { + values.push(row[col]) + } + this.row_raw(...values) + } + + 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) + } + } + } + + this._return_all = this._fields.length === 0 + return this + } +} + +export interface Insert extends TableRefBuilder {} +applyMixins(Insert, [TableRefBuilder]) diff --git a/orm/src/builder/type/Select.ts b/orm/src/builder/type/Select.ts new file mode 100644 index 0000000..6b016f0 --- /dev/null +++ b/orm/src/builder/type/Select.ts @@ -0,0 +1,204 @@ +import { + FieldSet, + QuerySource, + WhereStatement, + TableRef, + OrderStatement, + OrderDirection, HavingStatement +} from '../types.ts' +import {WhereBuilder} from './WhereBuilder.ts' +import {applyMixins} from '../../../../lib/src/support/mixins.ts' +import {TableRefBuilder} from './TableRefBuilder.ts' +import {Join} from './join/Join.ts' +import {LeftJoin} from './join/LeftJoin.ts' +import {LeftOuterJoin} from './join/LeftOuterJoin.ts' +import {CrossJoin} from './join/CrossJoin.ts' +import {InnerJoin} from './join/InnerJoin.ts' +import {RightJoin} from './join/RightJoin.ts' +import {RightOuterJoin} from './join/RightOuterJoin.ts' +import {FullOuterJoin} from './join/FullOuterJoin.ts' +import {HavingBuilder} from './HavingBuilder.ts' +import ConnectionExecutable from './ConnectionExecutable.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 { + protected _fields: string[] = [] + protected _source?: QuerySource = undefined + protected _wheres: WhereStatement[] = [] + protected _havings: HavingStatement[] = [] + protected _limit?: number + protected _offset?: number + protected _joins: Join[] = [] + protected _distinct = false + protected _group_by: string[] = [] + protected _order: OrderStatement[] = [] + + distinct() { + this._distinct = true + return this + } + + sql(level = 0): string { + const indent = Array(level * 2).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 order = '' + if ( this._order.length > 0 ) { + order = this._order.map(x => `${x.field} ${x.direction}`).join(', ') + } + + const wheres = this.wheres_to_sql(this._wheres, level + 1) + const havings = this.havings_to_sql(this._havings, level + 1) + + return [ + `SELECT ${this._distinct ? 'DISTINCT ' : ''}${this._fields.join(', ')}`, + `FROM ${this.serialize_table_ref(table_ref)}`, + ...this._joins.map(join => join.sql(level + 1)), + ...(wheres.trim() ? ['WHERE', wheres] : []), + ...[typeof this._limit !== 'undefined' ? [`LIMIT ${this._limit}`] : []], + ...[typeof this._offset !== 'undefined' ? [`OFFSET ${this._offset}`] : []], + ...[this._group_by.length > 0 ? 'GROUP BY ' + this._group_by.join(', ') : []], + ...[order ? [`ORDER BY ${order}`] : []], + ...(havings.trim() ? ['HAVING', havings] : []), + ].filter(x => String(x).trim()).join(`\n${indent}`) + } + + field(field: string) { + if ( !this._fields.includes(field) ) { + this._fields.push(field) + } + return this + } + + group_by(...groupings: string[]) { + this._group_by = groupings + return this + } + + fields(...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 + } + + from(source: QuerySource, alias?: string) { + if ( !alias ) this._source = source + else this._source = { ref: source, alias } + return this + } + + limit(num: number) { + this._limit = Number(num) + return this + } + + offset(num: number) { + this._offset = Number(num) + return this + } + + skip(num: number) { + this._offset = Number(num) + return this + } + + take(num: number) { + this._limit = Number(num) + return this + } + + join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(Join, source, alias_or_func, func) + return this + } + + left_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(LeftJoin, source, alias_or_func, func) + return this + } + + left_outer_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(LeftOuterJoin, source, alias_or_func, func) + return this + } + + cross_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(CrossJoin, source, alias_or_func, func) + return this + } + + inner_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(InnerJoin, source, alias_or_func, func) + return this + } + + right_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(RightJoin, source, alias_or_func, func) + return this + } + + right_outer_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(RightOuterJoin, source, alias_or_func, func) + return this + } + + full_outer_join(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + this._createJoin(FullOuterJoin, source, alias_or_func, func) + return this + } + + private _createJoin(Class: typeof Join, source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction) { + const [table_ref, join_func] = this.join_ref_to_join_args(source, alias_or_func, func) + const join = new Class(table_ref) + this._joins.push(join) + join_func(join) + } + + join_ref_to_join_args(source: QuerySource, alias_or_func: string | JoinFunction, func?: JoinFunction): [TableRef, JoinFunction] { + let alias = undefined + if ( typeof alias_or_func === 'string' ) alias = alias_or_func + + let join_func = undefined + if ( func ) join_func = func + else if ( typeof alias_or_func === 'function' ) join_func = alias_or_func + else { + throw new TypeError('Missing required join function handler!') + } + + return [this.source_alias_to_table_ref(source, alias), join_func] + } + + order_by(field: string, direction: OrderDirection = 'ASC') { + this._order.push({ field, direction }) + return this + } + + order_asc(field: string) { + return this.order_by(field, 'ASC') + } + + order_desc(field: string) { + return this.order_by(field, 'DESC') + } + + // TODO subquery support - https://www.sqlservertutorial.net/sql-server-basics/sql-server-subquery/ + // TODO raw() +} + +export interface Select extends WhereBuilder, TableRefBuilder, HavingBuilder {} +applyMixins(Select, [WhereBuilder, TableRefBuilder, HavingBuilder]) diff --git a/orm/src/builder/type/TableRefBuilder.ts b/orm/src/builder/type/TableRefBuilder.ts new file mode 100644 index 0000000..2c5eb2b --- /dev/null +++ b/orm/src/builder/type/TableRefBuilder.ts @@ -0,0 +1,41 @@ +import {TableRef, QuerySource} from '../types.ts' + +export class TableRefBuilder { + resolve_table_name(from: string): TableRef { + const parts = from.split('.') + const ref: any = {} + if ( parts.length > 1 ) { + ref.database = parts[0] + ref.table = parts[1] + } else { + ref.table = parts[0] + } + + const alias_parts = ref.table.split(/\s+/) + if ( alias_parts.length > 1 ) { + ref.table = alias_parts[0] + ref.alias = alias_parts[1] + } + + return ref as TableRef + } + + serialize_table_ref(ref: TableRef): string { + return `${ref.database ? ref.database+'.' : ''}${ref.table}${ref.alias ? ' '+ref.alias : ''}` + } + + source_alias_to_table_ref(source: QuerySource, alias?: string) { + let string = '' + if ( typeof source === 'string' ) { + string = source + if ( typeof alias === 'string' ) string += ` ${alias}` + } + else if ( typeof source === 'object' ) { + string = `${source.ref}` + if ( source.alias ) string += ` ${source.alias}` + else if ( typeof alias === 'string' ) string += ` ${alias}` + } + + return this.resolve_table_name(string) + } +} diff --git a/orm/src/builder/type/Update.ts b/orm/src/builder/type/Update.ts new file mode 100644 index 0000000..885117f --- /dev/null +++ b/orm/src/builder/type/Update.ts @@ -0,0 +1,88 @@ +import ConnectionExecutable from './ConnectionExecutable.ts' +import {escape, EscapedValue, FieldValue, FieldValueObject, QuerySource, WhereStatement, FieldSet} from '../types.ts' +import {Collection} from '../../../../lib/src/collection/Collection.ts' +import {WhereBuilder} from './WhereBuilder.ts' +import {applyMixins} from '../../../../lib/src/support/mixins.ts' +import {TableRefBuilder} from './TableRefBuilder.ts' +import {MalformedSQLGrammarError} from './Select.ts' +import ConnectionMutable from './ConnectionMutable.ts' + +// TODO FROM +// TODO WHERE CURRENT OF +export class Update extends ConnectionMutable { + protected _target?: QuerySource = undefined + protected _only = false + protected _sets: Collection = new Collection() + protected _wheres: WhereStatement[] = [] + protected _fields: string[] = [] + + 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 [ + `UPDATE ${this._only ? 'ONLY ' : ''}${this.serialize_table_ref(table_ref)}`, + `SET`, + this.serialize_sets(this._sets, level + 1), + ...(wheres.trim() ? ['WHERE', wheres] : []), + ...(returning_fields.trim() ? [`RETURNING ${returning_fields}`] : []), + ].filter(x => String(x).trim()).join(`\n${indent}`) + } + + protected serialize_sets(sets: Collection, level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + return indent + sets.map(field_value => `${field_value.field} = ${escape(field_value.value)}`).join(`,\n${indent}`) + } + + to(source: QuerySource, alias?: string) { + if ( !alias ) this._target = source + else this._target = { ref: source, alias } + return this + } + + only() { + this._only = true + return this + } + + set(field: string, value: EscapedValue) { + const existing = this._sets.firstWhere('field', '=', field) + if ( existing ) { + existing.value = value + } else { + this._sets.push({ field, value }) + } + return this + } + + data(values: FieldValueObject) { + for ( const field in values ) { + if ( !values.hasOwnProperty(field) ) continue + this.set(field, values[field]) + } + 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 Update extends WhereBuilder, TableRefBuilder {} +applyMixins(Update, [WhereBuilder, TableRefBuilder]) diff --git a/orm/src/builder/type/WhereBuilder.ts b/orm/src/builder/type/WhereBuilder.ts new file mode 100644 index 0000000..050038f --- /dev/null +++ b/orm/src/builder/type/WhereBuilder.ts @@ -0,0 +1,100 @@ +import {EscapedValue, isWhereClause, isWhereGroup, WhereStatement} from '../types.ts' +import {escape, SQLWhereOperator, WherePreOperator} from '../types.ts' +import {WhereBuilderFunction} from "./Select.ts"; + +export class WhereBuilder { + protected _wheres: WhereStatement[] = [] + + get where_items() { + return this._wheres + } + + wheres_to_sql(wheres?: WhereStatement[], level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + let statements = [] + for ( const where of wheres || this._wheres ) { + if ( isWhereClause(where) ) { + statements.push(`${indent}${statements.length < 1 ? '' : where.preop + ' '}${where.field} ${where.operator} ${where.operand}`) + } else if ( isWhereGroup(where) ) { + statements.push(`${indent}${statements.length < 1 ? '' : where.preop + ' '}(\n${this.wheres_to_sql(where.items, level + 1)}\n${indent})`) + } + } + + return statements.filter(Boolean).join('\n') + } + + private _createWhere(preop: WherePreOperator, field: string | WhereBuilderFunction, operator?: SQLWhereOperator, operand?: any) { + if ( typeof field === 'function' ) { + const where_builder = new WhereBuilder() + field(where_builder) + this._wheres.push({ + preop, + items: where_builder.where_items, + }) + } else if ( field && operator && typeof operand !== 'undefined' ) { + this._wheres.push({ + field, operator, operand: escape(operand), preop + }) + } + } + + where(field: string | WhereBuilderFunction, operator?: SQLWhereOperator, operand?: any) { + this._createWhere('AND', field, operator, operand) + return this + } + + whereIn(field: string, values: EscapedValue) { + this._wheres.push({ + field, + operator: 'IN', + operand: escape(values), + preop: 'AND', + }) + return this + } + + whereNot(field: string | WhereBuilderFunction, operator?: SQLWhereOperator, operand?: EscapedValue) { + this._createWhere('AND NOT', field, operator, operand) + return this + } + + whereNotIn(field: string, values: EscapedValue) { + this._wheres.push({ + field, + operator: 'NOT IN', + operand: escape(values), + preop: 'AND' + }) + return this + } + + orWhere(field: string | WhereBuilderFunction, operator?: SQLWhereOperator, operand?: EscapedValue) { + this._createWhere('OR', field, operator, operand) + return this + } + + orWhereNot(field: string | WhereBuilderFunction, operator?: SQLWhereOperator, operand?: EscapedValue) { + this._createWhere('OR NOT', field, operator, operand) + return this + } + + orWhereIn(field: string, values: EscapedValue) { + this._wheres.push({ + field, + operator: 'IN', + operand: escape(values), + preop: 'OR', + }) + return this + } + + orWhereNotIn(field: string, values: EscapedValue) { + this._wheres.push({ + field, + operator: 'NOT IN', + operand: escape(values), + preop: 'OR', + }) + return this + } +} diff --git a/orm/src/builder/type/join/CrossJoin.ts b/orm/src/builder/type/join/CrossJoin.ts new file mode 100644 index 0000000..65dcbf9 --- /dev/null +++ b/orm/src/builder/type/join/CrossJoin.ts @@ -0,0 +1,13 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class CrossJoin extends Join { + public readonly operator: JoinOperator = 'CROSS JOIN' + + sql(level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + return [ + `${this.operator} ${this.serialize_table_ref(this.table_ref)}`, + ].filter(Boolean).join(`\n${indent}`) + } +} diff --git a/orm/src/builder/type/join/FullOuterJoin.ts b/orm/src/builder/type/join/FullOuterJoin.ts new file mode 100644 index 0000000..9b4cafe --- /dev/null +++ b/orm/src/builder/type/join/FullOuterJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class FullOuterJoin extends Join { + public readonly operator: JoinOperator = 'FULL OUTER JOIN' +} diff --git a/orm/src/builder/type/join/InnerJoin.ts b/orm/src/builder/type/join/InnerJoin.ts new file mode 100644 index 0000000..c9edc8f --- /dev/null +++ b/orm/src/builder/type/join/InnerJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class InnerJoin extends Join { + public readonly operator: JoinOperator = 'INNER JOIN' +} diff --git a/orm/src/builder/type/join/Join.ts b/orm/src/builder/type/join/Join.ts new file mode 100644 index 0000000..7fcc377 --- /dev/null +++ b/orm/src/builder/type/join/Join.ts @@ -0,0 +1,25 @@ +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' + +export class Join { + public readonly operator: JoinOperator = 'JOIN' + protected _wheres: WhereStatement[] = [] + + constructor( + public readonly table_ref: TableRef + ) {} + + sql(level = 0): string { + const indent = Array(level * 2).fill(' ').join('') + return [ + `${this.operator} ${this.serialize_table_ref(this.table_ref)}`, + ...[this._wheres.length > 0 ? ['ON'] : []], + this.wheres_to_sql(this._wheres, level), + ].filter(Boolean).join(`\n${indent}`) + } +} + +export interface Join extends TableRefBuilder, WhereBuilder {} +applyMixins(Join, [TableRefBuilder, WhereBuilder]) diff --git a/orm/src/builder/type/join/LeftJoin.ts b/orm/src/builder/type/join/LeftJoin.ts new file mode 100644 index 0000000..f65f0d0 --- /dev/null +++ b/orm/src/builder/type/join/LeftJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class LeftJoin extends Join { + public readonly operator: JoinOperator = 'LEFT JOIN' +} diff --git a/orm/src/builder/type/join/LeftOuterJoin.ts b/orm/src/builder/type/join/LeftOuterJoin.ts new file mode 100644 index 0000000..65d6fbd --- /dev/null +++ b/orm/src/builder/type/join/LeftOuterJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class LeftOuterJoin extends Join { + public readonly operator: JoinOperator = 'LEFT OUTER JOIN' +} diff --git a/orm/src/builder/type/join/RightJoin.ts b/orm/src/builder/type/join/RightJoin.ts new file mode 100644 index 0000000..9845db7 --- /dev/null +++ b/orm/src/builder/type/join/RightJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class RightJoin extends Join { + public readonly operator: JoinOperator = 'RIGHT JOIN' +} diff --git a/orm/src/builder/type/join/RightOuterJoin.ts b/orm/src/builder/type/join/RightOuterJoin.ts new file mode 100644 index 0000000..fa51c9c --- /dev/null +++ b/orm/src/builder/type/join/RightOuterJoin.ts @@ -0,0 +1,6 @@ +import {Join} from './Join.ts' +import {JoinOperator} from '../../types.ts' + +export class RightOuterJoin extends Join { + public readonly operator: JoinOperator = 'RIGHT OUTER JOIN' +} diff --git a/orm/src/builder/type/result/ObjectResultOperator.ts b/orm/src/builder/type/result/ObjectResultOperator.ts new file mode 100644 index 0000000..e8b2ae4 --- /dev/null +++ b/orm/src/builder/type/result/ObjectResultOperator.ts @@ -0,0 +1,14 @@ +import ResultOperator from './ResultOperator.ts' +import {QueryRow} from '../../../db/types.ts' + +export default class ObjectResultOperator extends ResultOperator { + + inflate_row(row: QueryRow): QueryRow { + return row + } + + deflate_row(item: QueryRow): QueryRow { + return item + } + +} diff --git a/orm/src/builder/type/result/ResultCollection.ts b/orm/src/builder/type/result/ResultCollection.ts new file mode 100644 index 0000000..bffb8d8 --- /dev/null +++ b/orm/src/builder/type/result/ResultCollection.ts @@ -0,0 +1,22 @@ +import {AsyncCollection} from '../../../../../lib/src/collection/AsyncCollection.ts' +import {ResultIterable} from './ResultIterable.ts' +import {Collection} from "../../../../../lib/src/collection/Collection.ts"; + +export class ResultCollection extends AsyncCollection { + constructor( + executable: ResultIterable, + chunk_size: number = 1000 + ) { + super(executable, chunk_size) + } + + then(func?: (items: Collection) => any) { + if ( func ) { + this.collect().then((items: Collection) => func(items)) + } else { + return new Promise(res => [ + 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 new file mode 100644 index 0000000..4b03121 --- /dev/null +++ b/orm/src/builder/type/result/ResultIterable.ts @@ -0,0 +1,26 @@ +import {Iterable} from '../../../../../lib/src/collection/Iterable.ts' +import ConnectionExecutable from '../ConnectionExecutable.ts' +import {Collection} from '../../../../../lib/src/collection/Collection.ts' + +export class ResultIterable extends Iterable { + + constructor( + protected executable: ConnectionExecutable + ) { super() } + + async at_index(i: number): Promise { + return this.executable.get_row(i) + } + + async from_range(start: number, end: number): Promise> { + return this.executable.get_range(start, end) + } + + async count() { + return this.executable.count() + } + + clone() { + return new ResultIterable(this.executable) + } +} diff --git a/orm/src/builder/type/result/ResultOperator.ts b/orm/src/builder/type/result/ResultOperator.ts new file mode 100644 index 0000000..9ad3666 --- /dev/null +++ b/orm/src/builder/type/result/ResultOperator.ts @@ -0,0 +1,8 @@ +import {QueryRow} from '../../../db/types.ts' + +export default abstract class ResultOperator { + + abstract inflate_row(row: QueryRow): T + abstract deflate_row(item: T): QueryRow + +} diff --git a/orm/src/builder/type/result/ResultSet.ts b/orm/src/builder/type/result/ResultSet.ts new file mode 100644 index 0000000..da8017c --- /dev/null +++ b/orm/src/builder/type/result/ResultSet.ts @@ -0,0 +1,33 @@ +import { Iterable } from '../../../../../lib/src/collection/Iterable.ts' +import ConnectionExecutable from "../ConnectionExecutable.ts"; +import {QueryRow} from "../../../db/types.ts"; +import {Collection} from "../../../../../lib/src/collection/Collection.ts"; + +export abstract class ResultSet extends Iterable { + + protected constructor( + protected executeable: ConnectionExecutable, + ) { + super() + } + + abstract async process_row(row: QueryRow): Promise + + async at_index(i: number) { + return this.process_row(await this.executeable.get_row(i)) + } + + async from_range(start: number, end: number) { + const results = await this.executeable.get_range(start, end) + const returns = new Collection() + for ( const row of results ) { + returns.push(await this.process_row(row)) + } + + return returns + } + + async count() { + return this.executeable.count() + } +} diff --git a/orm/src/builder/types.ts b/orm/src/builder/types.ts new file mode 100644 index 0000000..22fe144 --- /dev/null +++ b/orm/src/builder/types.ts @@ -0,0 +1,102 @@ +import {WhereOperator} from '../../../lib/src/collection/Where.ts' +import RawValue from './RawValue.ts' + +export type FieldSet = string | string[] +export type QuerySource = string | { ref: QuerySource, alias: string } + +export type JoinOperator = 'JOIN' | 'LEFT JOIN' | 'LEFT OUTER JOIN' | 'RIGHT JOIN' | 'RIGHT OUTER JOIN' | 'FULL OUTER JOIN' | 'INNER JOIN' | 'CROSS JOIN' + +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 OrderDirection = 'ASC' | 'DESC' +export type OrderStatement = { direction: OrderDirection, field: string } + +export type HavingPreOperator = WherePreOperator +export type HavingClause = WhereClause +export type HavingGroup = WhereGroup +export type HavingStatement = HavingClause | HavingGroup +export type SQLHavingOperator = SQLWhereOperator + +export type EscapedValue = string | number | boolean | Date | RawValue | EscapedValue[] + +export type FieldValue = { field: string, value: EscapedValue } +export type FieldValueObject = { [field: string]: EscapedValue } + +export type TableRef = { + table: string, + database?: string, + alias?: string +} + +export function isTableRef(something: any): something is TableRef { + let is = true + is = is && typeof something?.table === 'string' + + if ( typeof something?.database !== 'undefined' ) { + is = typeof something?.database === 'string' + } + + if ( typeof something?.alias !== 'undefined' ) { + is = typeof something?.alias === 'string' + } + + return is +} + +export function isWherePreOperator(something: any): something is WherePreOperator { + return ['AND', 'OR', 'AND NOT', 'OR NOT'].includes(something) +} + +export function isHavingClause(something: any): something is HavingClause { + return isWhereClause(something) +} + +export function isWhereClause(something: any): something is WhereClause { + return typeof something?.field === 'string' + && typeof something?.operator === 'string' // TODO check this better + && typeof something?.operand === 'string' + && isWherePreOperator(something?.preop) +} + +export function isHavingGroup(something: any): something is HavingGroup { + return isWhereGroup(something) +} + +export function isWhereGroup(something: any): something is WhereGroup { + return Array.isArray(something?.items) + && something.items.every((item: any) => isWhereStatement(item)) + && isWherePreOperator(something?.preop) +} + +export function isWhereStatement(something: any): something is WhereStatement { + return isWhereClause(something) || isWhereGroup(something) +} + +export function escape(value: EscapedValue): string { + if ( value instanceof RawValue ) { + return value.value + } else if ( Array.isArray(value) ) { + return `(${value.map(escape).join(',')})` + } else if ( String(value).toLowerCase() === 'true' ) { + return 'TRUE' + } else if ( String(value).toLowerCase() === 'false' ) { + return 'FALSE' + } else if ( typeof value === 'number' ) { + return `${value}` + } else if ( value === true ) { + return 'TRUE' + } else if ( value === false ) { + return 'FALSE' + } else if ( value instanceof Date ) { // TODO custom formattig + const pad = (val: number) => val < 10 ? `0${val}` : `${val}` + return `'${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}'` + } else if ( !isNaN(Number(value)) ) { + return String(Number(value)) + } else { + const escaped = value.replace(/"/g, '\\"').replace(/'/g, '\\\'').replace(/`/g, '\\`') + return `'${escaped}'` + } +} diff --git a/orm/src/db/Connection.ts b/orm/src/db/Connection.ts new file mode 100644 index 0000000..05db17f --- /dev/null +++ b/orm/src/db/Connection.ts @@ -0,0 +1,20 @@ +import {QueryResult} from './types.ts' + +export class ConnectionNotReadyError extends Error { + constructor(name = '') { + super(`The connection ${name} is not ready and cannot execute queries.`) + } +} + +export abstract class Connection { + + constructor( + public readonly name: string, + public readonly config: any = {}, + ) {} + + public abstract async init(): Promise + public abstract async query(query: string): Promise // TODO query result + public abstract async close(): Promise + +} diff --git a/orm/src/db/PostgresConnection.ts b/orm/src/db/PostgresConnection.ts new file mode 100644 index 0000000..3a1fab2 --- /dev/null +++ b/orm/src/db/PostgresConnection.ts @@ -0,0 +1,50 @@ +import { Connection, ConnectionNotReadyError } from './Connection.ts' +import { Client } from '../../../lib/src/external/db.ts' +import {collect, Collection} from '../../../lib/src/collection/Collection.ts' +import { QueryResult, QueryRow } from './types.ts' +import { logger } from '../../../lib/src/service/logging/global.ts' + +export default class PostgresConnection extends Connection { + private _client?: Client + + public async init() { + this._client = new Client(this.config) + + logger.info(`Opening PostgreSQL database for connection: ${this.name}`) + await this._client.connect() + } + + public async query(query: string) { + if ( !this._client ) throw new ConnectionNotReadyError(this.name) + const result = await this._client.query(query) + + let base_i = 0 + const cols = collect(result?.rowDescription?.columns || []).sortBy('index').map(col => { + col.index = base_i + base_i += 1 + return col + }) + + const rows = new Collection() + for ( const row of result.rows ) { + const row_obj: { [key: string]: any } = {} + for ( const col of cols ) { + // @ts-ignore + row_obj[col.name] = row[col.index] + } + rows.push(row_obj) + } + + logger.verbose(`Query result returned ${result.rowCount} row(s).`) + return { + rows, + row_count: result.rowCount, + } as QueryResult + } + + public async close() { + if ( this._client ) + await this._client.end() + } + +} diff --git a/orm/src/db/types.ts b/orm/src/db/types.ts new file mode 100644 index 0000000..cfa6c44 --- /dev/null +++ b/orm/src/db/types.ts @@ -0,0 +1,63 @@ +import { Collection } from '../../../lib/src/collection/Collection.ts' + +export type QueryRow = { [key: string]: any } + +export interface QueryResult { + rows: Collection, + row_count: number, +} + +export enum Type { + 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', +} diff --git a/orm/src/model/Field.ts b/orm/src/model/Field.ts new file mode 100644 index 0000000..31afc77 --- /dev/null +++ b/orm/src/model/Field.ts @@ -0,0 +1,52 @@ +import { Reflect } from '../../../lib/src/external/reflect.ts' +import { Collection } from '../../../lib/src/collection/Collection.ts' +import { logger } from '../../../lib/src/service/logging/global.ts' +import {Type} from '../db/types.ts' +import {Model} from "./Model.ts"; + +export const DATON_ORM_MODEL_FIELDS_METADATA_KEY = 'daton:orm:modelFields.ts' + +export function get_fields_meta(model: any): Collection { + const fields = Reflect.getMetadata(DATON_ORM_MODEL_FIELDS_METADATA_KEY, model.constructor) + if ( !(fields instanceof Collection) ) { + return new Collection() + } + + return fields as Collection +} + +export function set_model_fields_meta(model: any, fields: Collection) { + Reflect.defineMetadata(DATON_ORM_MODEL_FIELDS_METADATA_KEY, fields, model.constructor) +} + +export interface ModelField { + database_key: string, + model_key: string | symbol, + type: any, +} + +export function Field(type: Type, database_key?: string): PropertyDecorator { + return (target, model_key) => { + if ( !database_key ) database_key = String(model_key) + const fields = get_fields_meta(target) + + logger.debug(`Registering field mapping ${database_key} => ${String(model_key)} as ${type} for model.`) + logger.verbose(target) + + const existing_field = fields.firstWhere('model_key', '=', model_key) + if ( existing_field ) { + existing_field.database_key = database_key + existing_field.type = type + + return set_model_fields_meta(target, fields) + } + + fields.push({ + database_key, + model_key, + type, + }) + + set_model_fields_meta(target, fields) + } +} diff --git a/orm/src/model/Model.ts b/orm/src/model/Model.ts new file mode 100644 index 0000000..23a03c4 --- /dev/null +++ b/orm/src/model/Model.ts @@ -0,0 +1,356 @@ +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 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' + +// TODO separate read/write connections +// TODO manual dirty flags +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 _original?: QueryRow + + public static table_name() { + return this.table + } + + public static connection_name() { + return this.connection + } + + public static get_connection() { + return make(Database).connection(this.connection_name()) + } + + public static select(...fields: FieldSet[]) { + return this.prototype.select(...fields) + } + + public static update() { + return this.prototype.update() + } + + public static insert() { + return this.prototype.insert() + } + + public update(target?: QuerySource, alias?: string) { + const constructor = (this.constructor as typeof Model) + return super.update() + .to(constructor.table_name()) + .target_connection(constructor.get_connection()) + .target_operator(make(ModelResultOperator, constructor)) + } + + public select(...fields: FieldSet[]) { + const constructor = (this.constructor as typeof Model) + return super.select(...fields) + .from(constructor.table_name()) + .target_connection(constructor.get_connection()) + .target_operator(make(ModelResultOperator, constructor)) + } + + public insert(target?: QuerySource, alias?: string) { + const constructor = (this.constructor as typeof Model) + return super.insert() + .into(constructor.table_name()) + .target_connection(constructor.get_connection()) + .target_operator(make(ModelResultOperator, constructor)) + } + + constructor( + values?: any + ) { + super() + this.boot(values) + } + + public boot(values?: any) { + if ( values ) { + get_fields_meta(this).each(field_def => { + // TODO special type casting + // @ts-ignore + this[field_def.model_key] = values[field_def.model_key] + }) + } + } + + public assume_from_source(row: QueryRow) { + this._original = row + get_fields_meta(this).each(field_def => { + // TODO special type casting + // @ts-ignore + this[field_def.model_key] = row[field_def.database_key] + }) + return this + } + + // changes + // date format + // appends + // caching + // relations + // timestamps + // hidden + // fillable + // guarded + // key type + // with + // per page + // exists + + protected get _is_dirty() { + return (field_def: ModelField) => { + // @ts-ignore + return this[field_def.model_key] !== this._original[field_def.database_key] + } + } + + // to_row + public to_row(): QueryRow { + const data = {} + const meta = (this.constructor as typeof Model).fields() + meta.each(field => { + + }) + return {} + } + + // relations_to_row + // dirty_to_row + public dirty_to_row(): QueryRow { + const row = {} + this.field_defs() + .filter(this._is_dirty) + .each(field_def => { + // TODO additional casting and serializing logic here + // @ts-ignore + row[field_def.database_key] = this[field_def.model_key] + }) + return row + } + + // attributes + + /** + * Get a collection of field definitions that contains information + * on which database fields correspond to which model fields, and + * their types. + * @return Collection + */ + public static fields(): Collection { + return get_fields_meta(this.prototype) + } + + public field_defs(): Collection { + return (this.constructor as typeof Model).fields() + } + + /** + * Sets the model field metadata to the specified collection of + * model field definitions. You should rarely need to use this. + * @param Collection fields + */ + public static set_fields(fields: Collection) { + set_model_fields_meta(this.prototype, fields) + } + + /** + * Get the original values of the model as they were retrieved from + * the database. These are never updated when the model is modified. + * @return QueryRow + */ + public get_original_values() { + return this._original + } + + /** + * Get an object with only the fields specified as arguments. + * Note that this is NOT a QueryRow. + * @param {...string} 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 defined fields have been modified from + * the values that were originally fetched from the database. + * @return boolean + */ + public is_dirty() { + return this.field_defs().some(this._is_dirty) + } + + /** + * Get an array of model field names that have been modified from + * the values that were originally fetched from the database. + * @return string[] + */ + public dirty_fields() { + return this.field_defs() + .filter(this._is_dirty) + .pluck('model_key') + .toArray() + } + + /** + * Returns true if the model has an ID from, and therefore exists in, + * the database backend. + * @return boolean + */ + public exists(): boolean { + return !!this._original && !!this.key() + } + + /** + * Returns true if none of the defined fields have been modified from + * the values that were originally fetched from the database. + * @return boolean + */ + public is_clean() { + 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 + + // has one + // morph one + // belongs to + // morph to + // has many + // has many through + // belongs to many + // 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 + + // update - bulk + // push - update single + + // save - update or create instance + public async save(): Promise { + const constructor = (this.constructor as typeof Model) + + // TODO timestamps + if ( this.exists() && this.is_dirty() ) { // We're updating an existing record + const mutable = this.update() + .data(this.dirty_to_row()) + .where(constructor.qualified_key_name(), '=', this.key()) + .returning(...this._loaded_database_fields()) + .target_operator(make(ObjectResultOperator)) + .results() + + const result = await mutable + + const modified_rows = await mutable.count() + if ( modified_rows !== 1 ) { + logger.warn(`Model update modified ${modified_rows} rows! (Key: ${constructor.qualified_key_name()})`) + } + + this.assume_from_source(result.firstWhere(this.key_name(), '=', this.key())) + } else if ( !this.exists() ) { // We're inserting a new record + const insert_object: FieldValueObject = this._build_insert_field_object() + const mutable = this.insert() + .row(insert_object) + .returning(this.key_name(), ...Object.keys(insert_object)) + .target_operator(make(ObjectResultOperator)) + .results() + + const result = await mutable + + const inserted_rows = await mutable.count() + if ( inserted_rows !== 1 ) { + logger.warn(`Model insert created ${inserted_rows} rows! (Key: ${constructor.qualified_key_name()})`) + } + + this.assume_from_source(result.first()) + } + + return this + } + + protected _build_insert_field_object(): FieldValueObject { + const fields = this.field_defs() + const values = {} + fields.each(field_def => { + // @ts-ignore + values[field_def.database_key] = this[field_def.model_key] + }) + return values + } + + protected _loaded_database_fields(): string[] { + if ( typeof this._original === 'undefined' ) return [] + 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 + + /** + * Returns the field name of the primary key for this model. + * @return string + */ + public key_name() { + return (this.constructor as typeof Model).key + } + + /** + * If defined, returns the value of the primary key for this model. + */ + public key() { + return this?._original?.[(this.constructor as typeof Model).key] + } + + /** + * Returns the table-qualified field name of the primary key for this model. + */ + public static qualified_key_name() { + return `${this.table_name()}.${this.key}` + } +} diff --git a/orm/src/model/ModelResultOperator.ts b/orm/src/model/ModelResultOperator.ts new file mode 100644 index 0000000..7072002 --- /dev/null +++ b/orm/src/model/ModelResultOperator.ts @@ -0,0 +1,26 @@ +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' + +@Injectable() +export default class ModelResultOperator extends ResultOperator { + + constructor( + protected injector: Container, + protected ModelClass: Instantiable, + ) { + super(); + } + + inflate_row(row: QueryRow): Model { + return this.injector.make(this.ModelClass).assume_from_source(row) + } + + deflate_row(item: Model): QueryRow { + return item.to_row() + } + +} diff --git a/orm/src/schema/Schema.ts b/orm/src/schema/Schema.ts new file mode 100644 index 0000000..e69de29 diff --git a/orm/src/service/Database.ts b/orm/src/service/Database.ts new file mode 100644 index 0000000..0d42468 --- /dev/null +++ b/orm/src/service/Database.ts @@ -0,0 +1,37 @@ +import { Service } from '../../../di/src/decorator/Service.ts' +import { Connection } from '../db/Connection.ts' +import PostgresConnection from '../db/PostgresConnection.ts' + +export class DuplicateConnectionNameError extends Error { + constructor(connection_name: string) { + super(`A database connection with the name "${connection_name}" already exists.`) + } +} + +export class NoSuchDatabaseConnectionError extends Error { + constructor(connection_name: string) { + super(`No database connection exists with the name: "${connection_name}"`) + } +} + +@Service() +export default class Database { + private connections: { [name: string]: Connection } = {} + + async postgres(name: string, config: { [key: string]: any }): Promise { + if ( this.connections[name] ) + throw new DuplicateConnectionNameError(name) + + const conn = new PostgresConnection(name, config) + this.connections[name] = conn + await conn.init() + return conn + } + + connection(name: string): Connection { + if ( !this.connections[name] ) + throw new NoSuchDatabaseConnectionError(name) + + return this.connections[name] + } +}