Add support for jobs & queueables, migrations
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing

- Create migration directives & migrators
- Modify Cache classes to support array manipulation
- Create Redis unit and RedisCache implementation
- Create Queueable base class and Queue class that uses Cache backend
This commit is contained in:
2021-08-23 23:51:53 -05:00
parent 26e0444e40
commit 074a3187eb
28 changed files with 962 additions and 56 deletions

View File

@@ -1,4 +1,4 @@
import {ErrorWithContext} from '../../util'
import {Awaitable, ErrorWithContext} from '../../util'
import {QueryResult} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
@@ -68,6 +68,13 @@ export abstract class Connection extends AppClass {
*/
public abstract schema(name?: string): Schema
/**
* Execute all queries logged to this connection during the closure
* as a transaction in the database.
* @param closure
*/
public abstract asTransaction<T>(closure: () => Awaitable<T>): Awaitable<T>
/**
* Fire a QueryExecutedEvent for the given query string.
* @param query

View File

@@ -2,7 +2,7 @@ import {Connection, ConnectionNotReadyError} from './Connection'
import {Client} from 'pg'
import {Inject} from '../../di'
import {QueryResult} from '../types'
import {collect} from '../../util'
import {Awaitable, collect} from '../../util'
import {SQLDialect} from '../dialect/SQLDialect'
import {PostgreSQLDialect} from '../dialect/PostgreSQLDialect'
import {Logging} from '../../service/Logging'
@@ -70,6 +70,17 @@ export class PostgresConnection extends Connection {
}
}
public async asTransaction<T>(closure: () => Awaitable<T>): Promise<T> {
if ( !this.client ) {
throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
}
await this.client.query('BEGIN')
const result = await closure()
await this.client.query('COMMIT')
return result
}
public schema(name?: string): Schema {
return new PostgresSchema(this, name)
}

View File

@@ -3,8 +3,8 @@ import {Container, Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Migrator} from '../migrations/Migrator'
import {Migrations} from '../services/Migrations'
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
// import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
// import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
@@ -100,13 +100,13 @@ export class MigrateDirective extends Directive {
* @protected
*/
protected async registerListeners(): Promise<void> {
this.subscriptions.push(await this.bus.subscribe(ApplyingMigrationEvent, event => {
this.info(`Applying migration ${event.migration.identifier}...`)
}))
this.subscriptions.push(await this.bus.subscribe(AppliedMigrationEvent, event => {
this.success(`Applied migration: ${event.migration.identifier}`)
}))
// this.subscriptions.push(await this.bus.subscribe(ApplyingMigrationEvent, event => {
// this.info(`Applying migration ${event.migration.identifier}...`)
// }))
//
// this.subscriptions.push(await this.bus.subscribe(AppliedMigrationEvent, event => {
// this.success(`Applied migration: ${event.migration.identifier}`)
// }))
}
/** Remove event bus listeners before finish. */

View File

@@ -3,8 +3,8 @@ import {Container, Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Migrator} from '../migrations/Migrator'
import {Migrations} from '../services/Migrations'
import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent'
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
// import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent'
// import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
@@ -85,13 +85,13 @@ export class RollbackDirective extends Directive {
* @protected
*/
protected async registerListeners(): Promise<void> {
this.subscriptions.push(await this.bus.subscribe(RollingBackMigrationEvent, event => {
this.info(`Rolling-back migration ${event.migration.identifier}...`)
}))
this.subscriptions.push(await this.bus.subscribe(RolledBackMigrationEvent, event => {
this.success(`Rolled-back migration: ${event.migration.identifier}`)
}))
// this.subscriptions.push(await this.bus.subscribe(RollingBackMigrationEvent, event => {
// this.info(`Rolling-back migration ${event.migration.identifier}...`)
// }))
//
// this.subscriptions.push(await this.bus.subscribe(RolledBackMigrationEvent, event => {
// this.success(`Rolled-back migration: ${event.migration.identifier}`)
// }))
}
/** Remove event bus listeners before finish. */

View File

@@ -31,6 +31,7 @@ export * from './schema/Schema'
export * from './schema/PostgresSchema'
export * from './migrations/NothingToMigrateError'
export * from './migrations/events/MigrationEvent'
export * from './migrations/events/ApplyingMigrationEvent'
export * from './migrations/events/AppliedMigrationEvent'
export * from './migrations/events/RollingBackMigrationEvent'

View File

@@ -3,10 +3,10 @@ import {Awaitable, collect, ErrorWithContext} from '../../util'
import {Migration} from './Migration'
import {Migrations} from '../services/Migrations'
import {EventBus} from '../../event/EventBus'
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
// import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
// import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
// import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
// import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
import {NothingToMigrateError} from './NothingToMigrateError'
/**
@@ -259,8 +259,8 @@ export abstract class Migrator {
* @protected
*/
protected async applying(migration: Migration): Promise<void> {
const event = <ApplyingMigrationEvent> this.injector.make(ApplyingMigrationEvent, migration)
await this.bus.dispatch(event)
// const event = <ApplyingMigrationEvent> this.injector.make(ApplyingMigrationEvent, migration)
// await this.bus.dispatch(event)
}
/**
@@ -269,8 +269,8 @@ export abstract class Migrator {
* @protected
*/
protected async applied(migration: Migration): Promise<void> {
const event = <AppliedMigrationEvent> this.injector.make(AppliedMigrationEvent, migration)
await this.bus.dispatch(event)
// const event = <AppliedMigrationEvent> this.injector.make(AppliedMigrationEvent, migration)
// await this.bus.dispatch(event)
}
/**
@@ -279,8 +279,8 @@ export abstract class Migrator {
* @protected
*/
protected async rollingBack(migration: Migration): Promise<void> {
const event = <RollingBackMigrationEvent> this.injector.make(RollingBackMigrationEvent, migration)
await this.bus.dispatch(event)
// const event = <RollingBackMigrationEvent> this.injector.make(RollingBackMigrationEvent, migration)
// await this.bus.dispatch(event)
}
/**
@@ -289,7 +289,7 @@ export abstract class Migrator {
* @protected
*/
protected async rolledBack(migration: Migration): Promise<void> {
const event = <RolledBackMigrationEvent> this.injector.make(RolledBackMigrationEvent, migration)
await this.bus.dispatch(event)
// const event = <RolledBackMigrationEvent> this.injector.make(RolledBackMigrationEvent, migration)
// await this.bus.dispatch(event)
}
}

View File

@@ -635,6 +635,30 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
return this
}
/**
* Delete the current model from the database, if it exists.
*/
async delete(): Promise<void> {
if ( !this.exists() ) {
return
}
await this.query()
.where(this.qualifyKey(), '=', this.key())
.delete()
const ctor = this.constructor as typeof Model
const field = getFieldsMeta(this)
.firstWhere('databaseKey', '=', ctor.key)
if ( field ) {
delete (this as any)[field.modelKey]
return
}
delete (this as any)[ctor.key]
}
/**
* Cast this model to a simple object mapping model fields to their values.
*

View File

@@ -1,8 +1,11 @@
import {Model} from './Model'
import {AbstractBuilder} from '../builder/AbstractBuilder'
import {AbstractResultIterable} from '../builder/result/AbstractResultIterable'
import {Instantiable} from '../../di'
import {Instantiable, StaticClass} from '../../di'
import {ModelResultIterable} from './ModelResultIterable'
import {Collection} from '../../util'
import {ConstraintOperator, ModelKey, ModelKeys} from '../types'
import {EscapeValue} from '../dialect/SQLDialect'
/**
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
@@ -10,7 +13,7 @@ import {ModelResultIterable} from './ModelResultIterable'
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
constructor(
/** The model class that is created for results of this query. */
protected readonly ModelClass: Instantiable<T>,
protected readonly ModelClass: StaticClass<T, typeof Model> & Instantiable<T>,
) {
super()
}
@@ -22,4 +25,45 @@ export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
public getResultIterable(): AbstractResultIterable<T> {
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this.registeredConnection, this.ModelClass)
}
/**
* Apply a WHERE...IN... constraint on the primary key of the model.
* @param keys
*/
public whereKey(keys: ModelKeys): this {
return this.whereIn(
this.ModelClass.qualifyKey(),
this.normalizeModelKeys(keys),
)
}
/**
* Apply a where constraint on the column corresponding the the specified
* property on the model.
* @param propertyName
* @param operator
* @param operand
*/
public whereProperty(propertyName: string, operator: ConstraintOperator, operand?: EscapeValue): this {
return this.where(
this.ModelClass.propertyToColumn(propertyName),
operator,
operand,
)
}
/**
* Given some format of keys of the model, try to normalize them to a flat array.
* @param keys
* @protected
*/
protected normalizeModelKeys(keys: ModelKeys): ModelKey[] {
if ( Array.isArray(keys) ) {
return keys
} else if ( keys instanceof Collection ) {
return keys.all()
}
return [keys]
}
}

View File

@@ -9,14 +9,14 @@ import {CommandLine} from '../../cli'
import {MigrateDirective} from '../directive/MigrateDirective'
import {RollbackDirective} from '../directive/RollbackDirective'
import {CreateMigrationDirective} from '../directive/CreateMigrationDirective'
import {MigratorFactory} from '../migrations/MigratorFactory'
/**
* Service unit that loads and instantiates migration classes.
*/
@Singleton()
export class Migrations extends CanonicalInstantiable<Migration> {
@Inject()
protected readonly migrator!: Migrator
protected migrator!: Migrator
@Inject()
protected readonly cli!: CommandLine
@@ -34,6 +34,13 @@ export class Migrations extends CanonicalInstantiable<Migration> {
this.logging.debug(`Base migration path does not exist, or has no files: ${this.path}`)
}
// Register the migrator factory
this.container().registerFactory(
this.container().make<MigratorFactory>(MigratorFactory),
)
this.migrator = this.container().make(Migrator)
// Register the migrations for @extollo/lib
const basePath = lib().concat('migrations')
const resolver = await this.buildMigrationNamespaceResolver('@extollo', basePath)

View File

@@ -1,6 +1,8 @@
import {Model} from '../model/Model'
import {Field} from '../model/Field'
import {FieldType} from '../types'
import {Maybe} from '../../util'
import {ModelBuilder} from '../model/ModelBuilder'
/**
* A model instance which stores records from the ORMCache driver.
@@ -18,4 +20,15 @@ export class CacheModel extends Model<CacheModel> {
@Field(FieldType.timestamp, 'cache_expires')
public cacheExpires?: Date;
public static withCacheKey(key: string): ModelBuilder<CacheModel> {
return this.query<CacheModel>()
.whereKey(key)
.whereProperty('cacheExpires', '>', new Date())
}
public static getCacheKey(key: string): Promise<Maybe<CacheModel>> {
return this.withCacheKey(key)
.first()
}
}

View File

@@ -1,5 +1,5 @@
import {Container} from '../../di'
import {Cache} from '../../util'
import {Awaitable, Cache, ErrorWithContext, Maybe} from '../../util'
import {CacheModel} from './CacheModel'
/**
@@ -7,14 +7,7 @@ import {CacheModel} from './CacheModel'
*/
export class ORMCache extends Cache {
public async fetch(key: string): Promise<string | undefined> {
const model = await CacheModel.query<CacheModel>()
.where(CacheModel.qualifyKey(), '=', key)
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
.first()
if ( model ) {
return model.cacheValue
}
return (await CacheModel.getCacheKey(key))?.cacheValue
}
public async put(key: string, value: string, expires?: Date): Promise<void> {
@@ -31,15 +24,103 @@ export class ORMCache extends Cache {
}
public async has(key: string): Promise<boolean> {
return CacheModel.query()
.where(CacheModel.qualifyKey(), '=', key)
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
return CacheModel.withCacheKey(key)
.exists()
}
public async drop(key: string): Promise<void> {
await CacheModel.query()
.where(CacheModel.qualifyKey(), '=', key)
.whereKey(key)
.delete()
}
public async pop(key: string): Promise<string> {
return CacheModel.getConnection()
.asTransaction<string>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
throw new ErrorWithContext('Cannot pop cache value: key does not exist.', {
key,
})
}
await model.delete()
return model.cacheValue
})
}
public increment(key: string, amount = 1): Awaitable<number> {
return CacheModel.getConnection()
.asTransaction<number>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, String(amount))
return amount
}
model.cacheValue = String(parseInt(model.cacheValue, 10) + amount)
await model.save()
return parseInt(model.cacheValue, 10)
})
}
public decrement(key: string, amount = 1): Awaitable<number> {
return CacheModel.getConnection()
.asTransaction<number>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, String(-amount))
return amount
}
model.cacheValue = String(parseInt(model.cacheValue, 10) - amount)
await model.save()
return parseInt(model.cacheValue, 10)
})
}
public async arrayPush(key: string, value: string): Promise<void> {
await CacheModel.getConnection()
.asTransaction<void>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
await this.put(key, JSON.stringify([value]))
return
}
const cacheValue = JSON.parse(model.cacheValue)
if ( !Array.isArray(cacheValue) ) {
throw new ErrorWithContext('Cannot push value to non-array.', {
key,
})
}
cacheValue.push(value)
model.cacheValue = JSON.stringify(cacheValue)
})
throw new Error('Method not implemented.')
}
public async arrayPop(key: string): Promise<Maybe<string>> {
return CacheModel.getConnection()
.asTransaction<Maybe<string>>(async () => {
const model = await CacheModel.getCacheKey(key)
if ( !model ) {
return
}
const cacheValue = JSON.parse(model.cacheValue)
if ( !Array.isArray(cacheValue) ) {
throw new ErrorWithContext('Cannot pop value from non-array.', {
key,
})
}
const value = cacheValue.pop()
model.cacheValue = JSON.stringify(cacheValue)
await model.save()
return value
})
}
}

View File

@@ -11,6 +11,11 @@ export type QueryRow = { [key: string]: any }
*/
export type ModelKey = string | number
/**
* Collection of keys of a set of models.
*/
export type ModelKeys = ModelKey | ModelKey[] | Collection<ModelKey>
/**
* Interface for the result of a query execution.
*/