From d330c8d88806b0b065c18cb6d80d39c7eb116dba Mon Sep 17 00:00:00 2001 From: Harminder virk Date: Mon, 23 Sep 2019 11:55:44 +0530 Subject: [PATCH] feat: implement basic functionality for the base model The model is capable of issuing direct requests to the database using the adapter. --- adonis-typings/orm.ts | 173 ++++++++++++++++++++++------------ example/index.ts | 10 ++ package.json | 9 +- providers/DatabaseProvider.ts | 12 ++- src/Orm/Adapter/index.ts | 114 ++++++++++++---------- src/Orm/BaseModel/index.ts | 87 +++++++++++++++++ src/Orm/Decorators/index.ts | 10 ++ test-helpers/index.ts | 12 ++- test/adapter.spec.ts | 172 +++++++++++++++++++++++++++++++++ test/base-model.spec.ts | 70 ++++++++++++++ 10 files changed, 556 insertions(+), 113 deletions(-) create mode 100644 example/index.ts create mode 100644 src/Orm/BaseModel/index.ts create mode 100644 src/Orm/Decorators/index.ts create mode 100644 test/adapter.spec.ts create mode 100644 test/base-model.spec.ts diff --git a/adonis-typings/orm.ts b/adonis-typings/orm.ts index c7a64419..0d3135f6 100644 --- a/adonis-typings/orm.ts +++ b/adonis-typings/orm.ts @@ -1,60 +1,113 @@ -// /* -// * @adonisjs/lucid -// * -// * (c) Harminder Virk -// * -// * For the full copyright and license information, please view the LICENSE -// * file that was distributed with this source code. -// */ - -// declare module '@ioc:Adonis/Lucid/Orm' { -// import { -// InsertQueryBuilderContract, -// DatabaseQueryBuilderContract, -// } from '@ioc:Adonis/Lucid/DatabaseQueryBuilder' - -// import { -// ModelConstructorContract as BaseModelConstructorContract, -// ModelContract as BaseModelContract, -// AdapterContract as BaseAdapterContract, -// } from '@poppinss/data-models' - -// export interface OrmQueryBuilder< -// Model extends ModelConstructorContract, -// Result extends any, -// > extends DatabaseQueryBuilderContract, ExcutableQueryBuilderContract { -// } - -// export interface ModelConstructorContract extends BaseModelConstructorContract { -// $connection?: string, -// $increments: boolean, -// $table: string, - -// refs: any, - -// $getSaveQuery ( -// client: QueryClientContract, -// action: 'insert', -// ): InsertQueryBuilderContract, - -// $getSaveQuery ( -// client: QueryClientContract, -// action: 'update', -// ): OrmQueryBuilder, - -// $getSaveQuery ( -// client: QueryClientContract, -// action: 'insert' | 'update', -// ): InsertQueryBuilderContract | OrmQueryBuilder, - -// query (): OrmQueryBuilder this>, -// } - -// export interface ModelContract extends BaseModelContract { -// $getConstructor (): ModelConstructorContract, -// } - -// export interface AdapterContract extends BaseAdapterContract { -// insert (instance: ModelContract, attributes: any): Promise -// } -// } +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +declare module '@ioc:Adonis/Lucid/Orm' { + import knex from 'knex' + import { QueryClientContract, ExcutableQueryBuilderContract } from '@ioc:Adonis/Lucid/Database' + + import { + InsertQueryBuilderContract, + DatabaseQueryBuilderContract, + } from '@ioc:Adonis/Lucid/DatabaseQueryBuilder' + + import { + BaseModel as DataModelBaseModel, + AdapterContract as DataModelAdapterContract, + ModelConstructorContract as DataModelConstructorContract, + ModelContract as DataModelContract, + column as baseColumn, + } from '@poppinss/data-models' + + /** + * Orm query builder will have extras methods on top of Database query builder + */ + export interface OrmQueryBuilder< + Record extends any, + Result extends any, + > extends DatabaseQueryBuilderContract, ExcutableQueryBuilderContract { + } + + /** + * The shape of query adapter + */ + export interface AdapterContract extends DataModelAdapterContract { + insert (instance: ModelContract, attributes: any): Promise + update (instance: ModelContract, dirty: any): Promise + delete (instance: ModelContract): Promise + find (model: ModelConstructorContract, key: string, value: any): Promise + findAll (model: ModelConstructorContract): Promise + } + + /** + * Shape of base model + */ + export interface ModelContract extends DataModelContract { + /** + * Gives an option to the end user to define constraints for update, insert + * and delete queries. Since the query builder for these queries aren't + * exposed to the end user, this method opens up the API to build + * custom queries. + */ + $getQueryFor ( + action: 'insert', + client: QueryClientContract, + ): ReturnType + $getQueryFor ( + action: 'update' | 'delete', + client: QueryClientContract, + ): ReturnType + $getQueryFor ( + action: 'insert' | 'delete' | 'update', + client: QueryClientContract, + ): ReturnType | ReturnType + } + + /** + * Shape of base model static properties + */ + export interface ModelConstructorContract extends DataModelConstructorContract { + /** + * The database connection to use + */ + $connection?: string + + /** + * Whether primary key is auto incrementing or not. If not, then + * end user must provide the value for the primary key + */ + $increments: boolean + + /** + * Database table to use + */ + $table: string + + /** + * Refs are named value pair of model + */ + refs: any + + /** + * Returns the query for fetching a model instance + */ + query< + Model extends ModelConstructorContract, + Instance extends ModelContract, + > (this: new () => Instance): OrmQueryBuilder + + $createFromAdapterResult (result?: any, sideloadAttributes?: string[]): null | ModelContract + $createMultipleFromAdapterResult (results: any[], sideloadAttributes?: string[]): ModelContract[] + } + + export const BaseModel: ModelConstructorContract & { + new (): ModelContract, + } + + export const column: typeof baseColumn +} diff --git a/example/index.ts b/example/index.ts new file mode 100644 index 00000000..ad58f8b0 --- /dev/null +++ b/example/index.ts @@ -0,0 +1,10 @@ +import { BaseModel } from '@ioc:Adonis/Lucid/Orm' + +class User extends BaseModel { + public username: string +} + +const user = User.query().then((a) => { + a.username.toLocaleLowerCase() +}) +console.log(user) diff --git a/package.json b/package.json index 8865a444..5d63cf7c 100644 --- a/package.json +++ b/package.json @@ -38,17 +38,20 @@ }, "homepage": "https://github.com/adonisjs/adonis-lucid#readme", "dependencies": { + "@poppinss/data-models": "^1.0.2", "@poppinss/traits": "^1.0.0", "@poppinss/utils": "^1.0.5", "knex": "^0.19.3", "knex-dynamic-connection": "^1.0.0", + "pluralize": "^8.0.0", + "snake-case": "^2.1.0", "ts-essentials": "^3.0.2" }, "peerDependencies": { "@adonisjs/core": "2.x.x" }, "devDependencies": { - "@adonisjs/fold": "^6.1.5", + "@adonisjs/fold": "^6.2.0", "@adonisjs/mrm-preset": "^2.1.0", "@adonisjs/sink": "^2.1.5", "@poppinss/application": "^1.0.10", @@ -56,7 +59,8 @@ "@poppinss/logger": "^1.1.3", "@poppinss/profiler": "^1.1.1", "@types/dotenv": "^6.1.1", - "@types/node": "^12.7.3", + "@types/node": "^12.7.4", + "@types/pluralize": "0.0.29", "clone": "^2.1.2", "commitizen": "^4.0.3", "copyfiles": "^2.1.1", @@ -70,6 +74,7 @@ "mysql": "^2.17.1", "np": "^5.0.3", "pg": "^7.12.1", + "reflect-metadata": "^0.1.13", "sqlite3": "^4.1.0", "ts-node": "^8.3.0", "tslint": "^5.19.0", diff --git a/providers/DatabaseProvider.ts b/providers/DatabaseProvider.ts index f10461c3..baaf86c9 100644 --- a/providers/DatabaseProvider.ts +++ b/providers/DatabaseProvider.ts @@ -7,10 +7,13 @@ * file that was distributed with this source code. */ +import { IocContract } from '@adonisjs/fold' import { Database } from '../src/Database' +import { BaseModel } from '../src/Orm/BaseModel' +import { column } from '../src/Orm/Decorators' export default class DatabaseServiceProvider { - constructor (protected $container: any) { + constructor (protected $container: IocContract) { } /** @@ -23,5 +26,12 @@ export default class DatabaseServiceProvider { const Profiler = this.$container.use('Adonis/Core/Profiler') return new Database(config, Logger, Profiler) }) + + this.$container.singleton('Adonis/Lucid/Orm', () => { + return { + BaseModel, + column, + } + }) } } diff --git a/src/Orm/Adapter/index.ts b/src/Orm/Adapter/index.ts index a977b7fe..f492e7ec 100644 --- a/src/Orm/Adapter/index.ts +++ b/src/Orm/Adapter/index.ts @@ -10,63 +10,79 @@ /// /// -// import { DatabaseContract } from '@ioc:Adonis/Lucid/Database' -// import { AdapterContract, ModelContract } from '@ioc:Adonis/Lucid/Orm' +import { DatabaseContract } from '@ioc:Adonis/Lucid/Database' +import { AdapterContract, ModelConstructorContract, ModelContract } from '@ioc:Adonis/Lucid/Orm' -// /** -// * Adapter to execute queries for a given model. Please note that adapters -// * are stateless and only one instance of adapter is used across the -// * app, so make sure not to store any state. -// */ -// export class Adapter implements AdapterContract { -// constructor (private _db: DatabaseContract) {} +/** + * Adapter exposes the API to make database queries and constructor + * model instances from it. + */ +export class Adapter implements AdapterContract { + constructor (private _db: DatabaseContract) { + } -// public async insert (model: ModelContract, attributes: any): Promise { -// const modelConstructor = model.$getConstructor() + /** + * Find a given row and construct model instance from it + */ + public async find ( + modelConstructor: ModelConstructorContract, + key: string, + value: any, + ): Promise { + const client = this._db.connection(modelConstructor.$connection) -// /** -// * Pulling the query client for a given connection. -// */ -// const client = this._db.connection(modelConstructor.$connection) + const result = await client + .query() + .select('*') + .from(modelConstructor.$table) + .where(key, value) + .limit(1) -// * -// * Letting model give us the insert query. This enables the end user -// * to add some constraints to the query builder before returning -// * it back to us + return modelConstructor.$createFromAdapterResult(result[0]) + } -// const query = modelConstructor.$getSaveQuery(client, 'insert') + /** + * Returns an array of models by making a select query + */ + public async findAll (modelConstructor: ModelConstructorContract): Promise { + const client = this._db.connection(modelConstructor.$connection) -// /** -// * Execute the query -// */ -// const result = await query.insert(attributes) + const results = await client.query().select('*').from(modelConstructor.$table) + return modelConstructor.$createMultipleFromAdapterResult(results) + } -// /** -// * Set id when increments is true -// */ -// if (modelConstructor.$increments) { -// model.$consumeAdapterResult({ [modelConstructor.$primaryKey]: result[0] }) -// } -// } + /** + * Perform insert query on a given model instance + */ + public async insert (instance: ModelContract, attributes: any) { + const modelConstructor = instance.constructor as unknown as ModelConstructorContract + const client = this._db.connection(modelConstructor.$connection) + const query = instance.$getQueryFor('insert', client) -// public async update (model: ModelContract, dirty: any): Promise { -// const modelConstructor = model.$getConstructor() + const result = await query.insert(attributes) + if (modelConstructor.$increments) { + instance.$consumeAdapterResult({ [modelConstructor.$primaryKey]: result[0] }) + } + } -// /** -// * Pulling the query client for a given connection. -// */ -// const client = this._db.connection(modelConstructor.$connection) + /** + * Perform update query on a given model instance + */ + public async update (instance: ModelContract, dirty: any) { + const modelConstructor = instance.constructor as unknown as ModelConstructorContract + const client = this._db.connection(modelConstructor.$connection) + const query = instance.$getQueryFor('update', client) -// /** -// * Letting model give us the insert query. This enables the end user -// * to add some constraints to the query builder before returning -// * it back to us -// */ -// const query = modelConstructor.$getSaveQuery(client, 'update') + await query.update(dirty) + } -// /** -// * Execute the query -// */ -// await query.update(dirty) -// } -// } + /** + * Perform delete query on a given model instance + */ + public async delete (instance: ModelContract) { + const modelConstructor = instance.constructor as unknown as ModelConstructorContract + const client = this._db.connection(modelConstructor.$connection) + const query = instance.$getQueryFor('delete', client) + await query.del() + } +} diff --git a/src/Orm/BaseModel/index.ts b/src/Orm/BaseModel/index.ts new file mode 100644 index 00000000..ed1b2d44 --- /dev/null +++ b/src/Orm/BaseModel/index.ts @@ -0,0 +1,87 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +/// + +import pluralize from 'pluralize' +import snakeCase from 'snake-case' +import { BaseModel as BaseDataModel, StaticImplements } from '@poppinss/data-models' + +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' +import { ModelConstructorContract, ModelContract } from '@ioc:Adonis/Lucid/Orm' + +@StaticImplements() +export abstract class BaseModel extends BaseDataModel implements ModelContract { + /** + * Whether or not to rely on database to return the primaryKey + * value. If this is set to false, then the user must provide + * the `$primaryKeyValue` themselves. + */ + public static $increments: boolean + + /** + * The name of database table. It is auto generated from the model name, unless + * specified + */ + public static $table: string + + /** + * Refs are helpful of autocompleting the model props + */ + public static refs: any + + /** + * A custom connection to use for queries + */ + public static $connection?: string + + public static query (): any { + } + + /** + * Boot the model + */ + public static $boot () { + super.$boot() + this.$increments = this.$increments === undefined ? true : this.$increments + this.$table = this.$table === undefined ? pluralize(snakeCase(this.name)) : this.$table + } + + /** + * Returns the query for `insert`, `update` or `delete` actions. + * Since the query builder for these actions are not exposed to + * the end user, this method gives a way to compose queries. + */ + public $getQueryFor ( + action: 'insert' | 'update' | 'delete', + client: QueryClientContract, + ): any { + const modelConstructor = this.constructor as typeof BaseModel + + /** + * Returning insert query for the inserts + */ + if (action === 'insert') { + const insertQuery = client.insertQuery().table(modelConstructor.$table) + + if (modelConstructor.$increments) { + insertQuery.returning(modelConstructor.$primaryKey) + } + return insertQuery + } + + /** + * Returning generic query builder for rest of the queries + */ + return client + .query() + .from(modelConstructor.$table) + .where(modelConstructor.$primaryKey, this.$primaryKeyValue) + } +} diff --git a/src/Orm/Decorators/index.ts b/src/Orm/Decorators/index.ts new file mode 100644 index 00000000..de96394c --- /dev/null +++ b/src/Orm/Decorators/index.ts @@ -0,0 +1,10 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +export { column } from '@poppinss/data-models' diff --git a/test-helpers/index.ts b/test-helpers/index.ts index c28f5d4b..3516a6d6 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -9,9 +9,9 @@ /// -import { join } from 'path' import knex from 'knex' import dotenv from 'dotenv' +import { join } from 'path' import { FakeLogger } from '@poppinss/logger' import { Profiler } from '@poppinss/profiler' import { Filesystem } from '@poppinss/dev-utils' @@ -30,6 +30,7 @@ import { import { RawQueryBuilder } from '../src/Database/QueryBuilder/Raw' import { InsertQueryBuilder } from '../src/Database/QueryBuilder/Insert' import { DatabaseQueryBuilder } from '../src/Database/QueryBuilder/Database' +import { Database } from '../src/Database/index' export const fs = new Filesystem(join(__dirname, 'tmp')) dotenv.config() @@ -168,3 +169,12 @@ export function getLogger () { export function getProfiler () { return new Profiler({ enabled: false }) } + +export function getDb () { + const config = { + connection: 'primary', + connections: { primary: getConfig() }, + } + + return new Database(config, getLogger(), getProfiler()) +} diff --git a/test/adapter.spec.ts b/test/adapter.spec.ts new file mode 100644 index 00000000..e1fcc6b6 --- /dev/null +++ b/test/adapter.spec.ts @@ -0,0 +1,172 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import test from 'japa' +import { BaseModel } from '../src/Orm/BaseModel' +import { column } from '../src/Orm/Decorators' +import { setup, cleanup, getDb, resetTables } from '../test-helpers' +import { Adapter } from '../src/Orm/Adapter' + +test.group('BaseModel', (group) => { + group.before(async () => { + await setup() + }) + + group.after(async () => { + await cleanup() + }) + + group.afterEach(async () => { + await resetTables() + }) + + test('make insert call using a model', async (assert) => { + const adapter = new Adapter(getDb()) + + class User extends BaseModel { + public static $table = 'users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + User.$boot() + User.$adapter = adapter + + const user = new User() + user.username = 'virk' + await user.save() + + assert.exists(user.id) + assert.deepEqual(user.$attributes, { username: 'virk', id: user.id }) + assert.isFalse(user.$isDirty) + assert.isTrue(user.$persisted) + }) + + test('make update call using a model', async (assert) => { + const adapter = new Adapter(getDb()) + + class User extends BaseModel { + public static $table = 'users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + User.$boot() + User.$adapter = adapter + + const user = new User() + user.username = 'virk' + await user.save() + + assert.exists(user.id) + assert.deepEqual(user.$attributes, { username: 'virk', id: user.id }) + assert.isFalse(user.$isDirty) + assert.isTrue(user.$persisted) + + user.username = 'nikk' + assert.isTrue(user.$isDirty) + assert.deepEqual(user.$dirty, { username: 'nikk' }) + + await user.save() + }) + + test('make delete call using a model', async (assert) => { + const db = getDb() + const adapter = new Adapter(db) + + class User extends BaseModel { + public static $table = 'users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + User.$boot() + User.$adapter = adapter + + const user = new User() + user.username = 'virk' + await user.save() + + assert.exists(user.id) + assert.deepEqual(user.$attributes, { username: 'virk', id: user.id }) + assert.isFalse(user.$isDirty) + assert.isTrue(user.$persisted) + + await user.delete() + assert.isTrue(user.$isDeleted) + + const users = await db.from('users').select('*') + assert.lengthOf(users, 0) + }) + + test('get model instance using the find call', async (assert) => { + const db = getDb() + const adapter = new Adapter(db) + + class User extends BaseModel { + public static $table = 'users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + User.$boot() + User.$adapter = adapter + + const [id] = await db.table('users').returning('id').insert({ username: 'virk' }) + + const user = await User.findBy('username', 'virk') + assert.instanceOf(user, User) + assert.isFalse(user!.$isDirty) + assert.deepEqual(user!.$attributes, { id: id, username: 'virk' }) + }) + + test('get array of model instances using the findAll call', async (assert) => { + const db = getDb() + const adapter = new Adapter(db) + + class User extends BaseModel { + public static $table = 'users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + User.$boot() + User.$adapter = adapter + + const [id, id1] = await db.table('users').returning('id').multiInsert( + [{ username: 'virk' }, { username: 'nikk' }], + ) + + const users = await User.findAll() + assert.lengthOf(users, 2) + assert.instanceOf(users[0], User) + assert.instanceOf(users[1], User) + + assert.isFalse(users[0].$isDirty) + assert.isFalse(users[1].$isDirty) + + assert.deepEqual(users[0].$attributes, { id: id, username: 'virk' }) + assert.deepEqual(users[1].$attributes, { id: id1, username: 'nikk' }) + }) +}) diff --git a/test/base-model.spec.ts b/test/base-model.spec.ts new file mode 100644 index 00000000..2655fc1d --- /dev/null +++ b/test/base-model.spec.ts @@ -0,0 +1,70 @@ +/* +* @adonisjs/lucid +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import test from 'japa' +import { BaseModel } from '../src/Orm/BaseModel' +import { column } from '../src/Orm/Decorators' + +test.group('BaseModel', () => { + test('compute table name from model name', async (assert) => { + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public username: string + } + + User.$boot() + assert.equal(User.$table, 'users') + }) + + test('allow overriding table name', async (assert) => { + class User extends BaseModel { + public static $table = 'my_users' + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + + User.$boot() + assert.equal(User.$table, 'my_users') + }) + + test('set increments to true by default', async (assert) => { + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public username: string + } + + User.$boot() + assert.isTrue(User.$increments) + }) + + test('allow overriding increments', async (assert) => { + class User extends BaseModel { + public static $increments = false + + @column({ primary: true }) + public id: number + + @column() + public username: string + } + + User.$boot() + assert.isFalse(User.$increments) + }) +})