From fec90f0bb0cd1f7f29a2cc950ac818984713fadb Mon Sep 17 00:00:00 2001 From: kiddyu <58631254@qq.com> Date: Wed, 3 Apr 2024 03:16:30 +0800 Subject: [PATCH 1/2] feat: support for creating models using `sutando.model()` --- src/model.js | 2 +- src/sutando.js | 37 ++++++++++++ test/index.test.js | 140 ++++++++++++++++++++++++++++----------------- 3 files changed, 126 insertions(+), 53 deletions(-) diff --git a/src/model.js b/src/model.js index 8e36563..d81e9c5 100644 --- a/src/model.js +++ b/src/model.js @@ -5,7 +5,6 @@ const collect = require('collect.js'); const pluralize = require('pluralize'); const Builder = require('./builder'); const Collection = require('./collection'); -const sutando = require('./sutando'); const HasAttributes = require('./concerns/has-attributes'); const HasRelations = require('./concerns/has-relations'); const HasTimestamps = require('./concerns/has-timestamps'); @@ -193,6 +192,7 @@ class Model extends BaseModel { } getConnection() { + const sutando = require('./sutando'); return sutando.connection(this.connection); } diff --git a/src/sutando.js b/src/sutando.js index 76a6b02..0891d90 100644 --- a/src/sutando.js +++ b/src/sutando.js @@ -1,8 +1,10 @@ const QueryBuilder = require('./query-builder'); +const { getRelationMethod, getScopeMethod } = require('./utils'); class sutando { static manager = {}; static connections = {}; + static models = {}; static connection(connection = null) { return this.getConnection(connection); @@ -64,6 +66,41 @@ class sutando { return connection?.destroy(); })); } + + static model(name, options) { + const Model = require('./model'); + sutando.models = { + ...sutando.models, + [name]: class extends Model { + table = options?.table || null; + connection = options?.connection || null; + timestamps = options?.timestamps || true; + primaryKey = options?.primaryKey || 'id'; + keyType = options?.keyType || 'int'; + incrementing = options?.incrementing || true; + with = options?.with || []; + casts = options?.casts || {}; + + static CREATED_AT = options?.CREATED_AT || 'created_at'; + static UPDATED_AT = options?.UPDATED_AT || 'updated_at'; + static DELETED_AT = options?.DELETED_AT || 'deleted_at'; + } + } + + if ('relations' in options) { + for (const relation in options.relations) { + sutando.models[name].prototype[getRelationMethod(relation)] = options.relations[relation]; + } + } + + if ('scopes' in options) { + for (const scope in options.scopes) { + sutando.models[name].prototype[getScopeMethod(scope)] = options.scopes[scope]; + } + } + + return sutando.models[name]; + } } module.exports = sutando; \ No newline at end of file diff --git a/test/index.test.js b/test/index.test.js index 38fdecc..32ec67b 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -49,19 +49,27 @@ describe('Model', () => { } } - class Post extends Model { - relationAuthor() { - return this.belongsTo(User); + // class Post extends Model { + // relationAuthor() { + // return this.belongsTo(User); + // } + + // relationTags() { + // return this.belongsToMany(Tag, 'post_tag'); + // } + + // relationThumbnail() { + // return this.belongsTo(Thumbnail, 'thumbnail_id'); + // } + // } + + const Post = sutando.model('Post', { + relations: { + author: () => this.belongsTo(User), + tags: () => this.belongsToMany(Tag, 'post_tag'), + thumbnail: () => this.belongsTo(Thumbnail, 'thumbnail_id'), } - - relationTags() { - return this.belongsToMany(Tag, 'post_tag'); - } - - relationThumbnail() { - return this.belongsTo(Thumbnail, 'thumbnail_id'); - } - } + }); class Tag extends Model { relationPosts() { @@ -325,47 +333,71 @@ describe('Integration test', () => { } } - class Post extends Base { - scopeIdOf(query, id) { - return query.where('id', id); - } - - scopePublish(query) { - return query.where('status', 1); - } - - relationAuthor() { - return this.belongsTo(User); - } - - relationDefaultAuthor() { - return this.belongsTo(User).withDefault({ + // class Post extends Base { + // scopeIdOf(query, id) { + // return query.where('id', id); + // } + + // scopePublish(query) { + // return query.where('status', 1); + // } + + // relationAuthor() { + // return this.belongsTo(User); + // } + + // relationDefaultAuthor() { + // return this.belongsTo(User).withDefault({ + // name: 'Default Author' + // }); + // } + + // relationDefaultPostAuthor() { + // return this.belongsTo(User).withDefault((user, post) => { + // user.name = post.name + ' - Default Author'; + // }); + // } + + // relationThumbnail() { + // return this.belongsTo(Media, 'thumbnail_id'); + // } + + // relationMedia() { + // return this.belongsToMany(Media); + // } + + // relationTags() { + // return this.belongsToMany(Tag); + // } + + // relationComments() { + // return this.hasMany(Comment); + // } + // } + + const Post = sutando.model('Post', { + connection: config.client, + scopes: { + idOf: function (query, id) { + return query.where('id', id); + }, + publish: function (query) { + return query.where('status', 1); + } + }, + relations: { + author: function() { return this.belongsTo(User)}, + default_author: function() { return this.belongsTo(User).withDefault({ name: 'Default Author' - }); - } - - relationDefaultPostAuthor() { - return this.belongsTo(User).withDefault((user, post) => { + })}, + default_post_author: function() { return this.belongsTo(User).withDefault((user, post) => { user.name = post.name + ' - Default Author'; - }); - } - - relationThumbnail() { - return this.belongsTo(Media, 'thumbnail_id'); - } - - relationMedia() { - return this.belongsToMany(Media); - } - - relationTags() { - return this.belongsToMany(Tag); + })}, + thumbnail: function() { return this.belongsTo(Media, 'thumbnail_id')}, + media: function() { return this.belongsToMany(Media)}, + tags: function() { return this.belongsToMany(Tag)}, } - - relationComments() { - return this.hasMany(Comment); - } - } + }); class Tag extends Base { relationPosts() { @@ -1016,10 +1048,15 @@ describe('Integration test', () => { describe('#save()', () => { it('saves a new object', async () => { + const count = await Post.query().count(); + expect(count).toBe(6); + const post = new Post; post.user_id = 0; post.name = 'Fourth post'; - await post.save(); + const a = await post.save(); + const count1 = await Post.query().count(); + expect(count1).toBe(7); expect(Number(post.id)).toBe(7); @@ -1049,7 +1086,6 @@ describe('Integration test', () => { name: 'A Cool Blog', }); post.user_id = 1; - await post.save(); expect(post.toData()).toHaveProperty('name', 'A Cool Blog'); expect(post.toData()).toHaveProperty('user_id', 1); From 873c2352cd4e4c31bb634cfbc3d96e2d76fb4ab6 Mon Sep 17 00:00:00 2001 From: kiddyu <58631254@qq.com> Date: Tue, 6 Aug 2024 02:45:58 +0800 Subject: [PATCH 2/2] feat: add model creation support --- src/model.js | 11 ++- src/sutando.js | 133 +++++++++++++++++++++--------- test/index.test.js | 201 ++++++++++++++++++++++++++++----------------- types/index.d.ts | 27 +++++- 4 files changed, 254 insertions(+), 118 deletions(-) diff --git a/src/model.js b/src/model.js index d81e9c5..82f1c5e 100644 --- a/src/model.js +++ b/src/model.js @@ -5,6 +5,7 @@ const collect = require('collect.js'); const pluralize = require('pluralize'); const Builder = require('./builder'); const Collection = require('./collection'); +const sutando = require('./sutando'); const HasAttributes = require('./concerns/has-attributes'); const HasRelations = require('./concerns/has-relations'); const HasTimestamps = require('./concerns/has-timestamps'); @@ -43,6 +44,7 @@ class Model extends BaseModel { static globalScopes = {}; static pluginInitializers = {}; static _booted = {}; + static resolver = null; static query(trx = null) { const instance = new this(); @@ -99,6 +101,10 @@ class Model extends BaseModel { } + static setConnectionResolver(resolver) { + this.resolver = resolver; + } + initialize() { } @@ -192,7 +198,10 @@ class Model extends BaseModel { } getConnection() { - const sutando = require('./sutando'); + if (this.constructor.resolver) { + return this.constructor.resolver.getConnection(this.connection); + } + return sutando.connection(this.connection); } diff --git a/src/sutando.js b/src/sutando.js index ab429eb..120fd33 100644 --- a/src/sutando.js +++ b/src/sutando.js @@ -1,15 +1,28 @@ const Knex = require('knex'); const QueryBuilder = require('./query-builder'); -const { getRelationMethod, getScopeMethod } = require('./utils'); +const { getRelationMethod, getScopeMethod, compose, getAttrMethod } = require('./utils'); +const Attribute = require('./casts/attribute'); class sutando { - static manager = {}; - static connections = {}; - static models = {}; static connectorFactory = null; + static instance = null; + + constructor() { + this.manager = {}; + this.connections = {}; + this.models = {}; + } + + static getInstance() { + if (this.instance === null) { + this.instance = new sutando(); + } + + return this.instance; + } static connection(connection = null) { - return this.getConnection(connection); + return this.getInstance().getConnection(connection); } static setConnectorFactory(connectorFactory) { @@ -20,12 +33,44 @@ class sutando { return this.connectorFactory || Knex; } - static getConnection(name = null) { + static addConnection(config, name = 'default') { + return this.getInstance().addConnection(config, name); + } + + static beginTransaction(connection = null) { + return this.getInstance().beginTransaction(connection); + } + + static transaction(callback, connection = null) { + return this.getInstance().transaction(callback, connection); + } + + static table(name, connection = null) { + return this.getInstance().table(name, connection); + } + + static schema(connection = null) { + return this.getInstance().schema(connection); + } + + static async destroyAll() { + await this.getInstance().destroyAll(); + } + + static createModel(name, options) { + return this.getInstance().createModel(name, options); + } + + connection(connection = null) { + return this.getConnection(connection); + } + + getConnection(name = null) { name = name || 'default'; if (this.manager[name] === undefined) { const queryBuilder = new QueryBuilder( this.connections[name], - this.getConnectorFactory() + this.constructor.getConnectorFactory() ); this.manager[name] = queryBuilder; @@ -34,7 +79,7 @@ class sutando { return this.manager[name]; } - static addConnection(config, name = 'default') { + addConnection(config, name = 'default') { this.connections[name] = { ...config, connection: { @@ -50,69 +95,79 @@ class sutando { }; } - static beginTransaction(connection = null) { + beginTransaction(connection = null) { return this.connection(connection).transaction(); } - static transaction(callback, connection = null) { + transaction(callback, connection = null) { return this.connection(connection).transaction(callback); } - static commit(connection = null) { - - } - - static rollback(connection = null) { - - } - - static table(name, connection = null) { + table(name, connection = null) { return this.connection(connection).table(name); } - static schema(connection = null) { + schema(connection = null) { return this.connection(connection).schema; } - static async destroyAll() { + async destroyAll() { await Promise.all(Object.values(this.manager).map((connection) => { return connection?.destroy(); })); } - static createModel(name, options) { + createModel(name, options = {}) { const Model = require('./model'); - sutando.models = { - ...sutando.models, - [name]: class extends Model { - table = options?.table || null; - connection = options?.connection || null; - timestamps = options?.timestamps || true; - primaryKey = options?.primaryKey || 'id'; - keyType = options?.keyType || 'int'; - incrementing = options?.incrementing || true; - with = options?.with || []; - casts = options?.casts || {}; + let BaseModel = Model; + if ('plugins' in options) { + BaseModel = compose(BaseModel, ...options.plugins); + } + + this.models = { + ...this.models, + [name]: class extends BaseModel { + table = options?.table ?? null; + connection = options?.connection ?? null; + timestamps = options?.timestamps ?? true; + primaryKey = options?.primaryKey ?? 'id'; + keyType = options?.keyType ?? 'int'; + incrementing = options?.incrementing ?? true; + with = options?.with ?? []; + casts = options?.casts ?? {}; - static CREATED_AT = options?.CREATED_AT || 'created_at'; - static UPDATED_AT = options?.UPDATED_AT || 'updated_at'; - static DELETED_AT = options?.DELETED_AT || 'deleted_at'; + static CREATED_AT = options?.CREATED_AT ?? 'created_at'; + static UPDATED_AT = options?.UPDATED_AT ?? 'updated_at'; + static DELETED_AT = options?.DELETED_AT ?? 'deleted_at'; } } + if ('attributes' in options) { + for (const attribute in options.attributes) { + if (options.attributes[attribute] instanceof Attribute === false) { + throw new Error(`Attribute must be an instance of "Attribute"`); + } + + this.models[name].prototype[getAttrMethod(attribute)] = () => options.attributes[attribute]; + } + } + if ('relations' in options) { for (const relation in options.relations) { - sutando.models[name].prototype[getRelationMethod(relation)] = options.relations[relation]; + this.models[name].prototype[getRelationMethod(relation)] = function () { + return options.relations[relation](this); + }; } } if ('scopes' in options) { for (const scope in options.scopes) { - sutando.models[name].prototype[getScopeMethod(scope)] = options.scopes[scope]; + this.models[name].prototype[getScopeMethod(scope)] = options.scopes[scope]; } } - return sutando.models[name]; + this.models[name].setConnectionResolver(this); + return this.models[name]; } } diff --git a/test/index.test.js b/test/index.test.js index 32ec67b..b7816fa 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,5 +1,6 @@ const unset = require('lodash/unset'); const filter = require('lodash/filter'); +const kebabCase = require('lodash/kebabCase'); const { sutando, Model, Collection, Builder, Paginator, compose, SoftDeletes, Attribute, HasUniqueIds, CastsAttributes, ModelNotFoundError } = require('../src'); const config = require(process.env.SUTANDO_CONFIG || './config'); const dayjs = require('dayjs'); @@ -49,27 +50,19 @@ describe('Model', () => { } } - // class Post extends Model { - // relationAuthor() { - // return this.belongsTo(User); - // } - - // relationTags() { - // return this.belongsToMany(Tag, 'post_tag'); - // } + class Post extends Model { + relationAuthor() { + return this.belongsTo(User); + } - // relationThumbnail() { - // return this.belongsTo(Thumbnail, 'thumbnail_id'); - // } - // } + relationTags() { + return this.belongsToMany(Tag, 'post_tag'); + } - const Post = sutando.model('Post', { - relations: { - author: () => this.belongsTo(User), - tags: () => this.belongsToMany(Tag, 'post_tag'), - thumbnail: () => this.belongsTo(Thumbnail, 'thumbnail_id'), + relationThumbnail() { + return this.belongsTo(Thumbnail, 'thumbnail_id'); } - }); + } class Tag extends Model { relationPosts() { @@ -80,23 +73,50 @@ describe('Model', () => { class Thumbnail extends Model {} class Media extends Model {} + const manager = new sutando; + + manager.createModel('User', { + plugins: [SomePlugin], + relations: { + post: (model) => model.hasMany(manager.models.Post), + } + }); + + manager.createModel('Post', { + relations: { + author: (model) => model.belongsTo(manager.models.User), + tags: (model) => model.belongsToMany(Tag, 'post_tag'), + thumbnail: (model) => model.belongsTo(Thumbnail, 'thumbnail_id'), + } + }); + it('return the table name of the plural model name', () => { const user = new User; const media = new Media; expect(user.getTable()).toBe('users'); expect(media.getTable()).toBe('media'); + + const anotherUser = new manager.models.User; + expect(anotherUser.getTable()).toBe('users'); }); describe('#compose', () => { it('should return a Model instance', () => { const user = new User; expect(user).toBeInstanceOf(Model); + + const anotherUser = new manager.models.User; + expect(anotherUser).toBeInstanceOf(Model); }); it('has mixin\'s attributes and methods', () => { const user = new User; expect(user.pluginAttribtue).toBe('plugin'); expect(user.pluginMethod()).toBe('plugin'); + + const anotherUser = new manager.models.User; + expect(anotherUser.pluginAttribtue).toBe('plugin'); + expect(anotherUser.pluginMethod()).toBe('plugin'); }) }) @@ -333,69 +353,70 @@ describe('Integration test', () => { } } - // class Post extends Base { - // scopeIdOf(query, id) { - // return query.where('id', id); - // } - - // scopePublish(query) { - // return query.where('status', 1); - // } - - // relationAuthor() { - // return this.belongsTo(User); - // } - - // relationDefaultAuthor() { - // return this.belongsTo(User).withDefault({ - // name: 'Default Author' - // }); - // } - - // relationDefaultPostAuthor() { - // return this.belongsTo(User).withDefault((user, post) => { - // user.name = post.name + ' - Default Author'; - // }); - // } - - // relationThumbnail() { - // return this.belongsTo(Media, 'thumbnail_id'); - // } - - // relationMedia() { - // return this.belongsToMany(Media); - // } - - // relationTags() { - // return this.belongsToMany(Tag); - // } - - // relationComments() { - // return this.hasMany(Comment); - // } - // } - - const Post = sutando.model('Post', { + class Post extends Base { + scopeIdOf(query, id) { + return query.where('id', id); + } + + scopePublish(query) { + return query.where('status', 1); + } + + relationAuthor() { + return this.belongsTo(User); + } + + relationDefaultAuthor() { + return this.belongsTo(User).withDefault({ + name: 'Default Author' + }); + } + + relationDefaultPostAuthor() { + return this.belongsTo(User).withDefault((user, post) => { + user.name = post.name + ' - Default Author'; + }); + } + + relationThumbnail() { + return this.belongsTo(Media, 'thumbnail_id'); + } + + relationMedia() { + return this.belongsToMany(Media); + } + + relationTags() { + return this.belongsToMany(Tag); + } + + relationComments() { + return this.hasMany(Comment); + } + } + + sutando.createModel('Post', { connection: config.client, + attributes: { + slug: Attribute.make({ + get: (value, attributes) => kebabCase(attributes.name) + }) + }, scopes: { - idOf: function (query, id) { - return query.where('id', id); - }, - publish: function (query) { - return query.where('status', 1); - } + idOf: (query, id) => query.where('id', id), + publish: (query) => query.where('status', 1) }, relations: { - author: function() { return this.belongsTo(User)}, - default_author: function() { return this.belongsTo(User).withDefault({ + author: (model) => model.belongsTo(User), + default_author: (model) => model.belongsTo(User).withDefault({ name: 'Default Author' - })}, - default_post_author: function() { return this.belongsTo(User).withDefault((user, post) => { + }), + default_post_author: (model) => model.belongsTo(User).withDefault((user, post) => { user.name = post.name + ' - Default Author'; - })}, - thumbnail: function() { return this.belongsTo(Media, 'thumbnail_id')}, - media: function() { return this.belongsToMany(Media)}, - tags: function() { return this.belongsToMany(Tag)}, + }), + thumbnail: (model) => model.belongsTo(Media, 'thumbnail_id'), + media: (model) => model.belongsToMany(Media), + tags: (model) => model.belongsToMany(Tag), } }); @@ -1427,6 +1448,36 @@ describe('Integration test', () => { expect(post.attributes.custom_cast).toBe('{"a":"bar","b":"foo"}'); }); }); + + describe('#createModel', () => { + it('scopes/attributes/relations', async () => { + const { Post } = sutando.instance.models; + let posts = await Post.query().idOf(3).get(); + expect(posts.modelKeys()).toEqual([3]); + + posts = await Post.query().idOf(3).orWhere(q => { + q.idOf(4); + }).get(); + expect(posts.modelKeys()).toEqual([3, 4]); + + let post = await Post.query().with('default_author').find(4); + let xpost = post.toData(); + unset(xpost, 'created_at'); + unset(xpost, 'updated_at'); + + expect(post.default_author).toBeInstanceOf(User); + expect(xpost).toEqual({ + id: 4, + user_id: 30, + name: 'This is a new Title 4!', + content: 'Lorem ipsum Anim sed eu sint aute.', + default_author: { + name: 'Default Author' + } + }); + expect(post.slug).toBe('this-is-a-new-title-4'); + }); + }); }); describe('Relation', () => { diff --git a/types/index.d.ts b/types/index.d.ts index 576485d..c5de087 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -216,10 +216,11 @@ declare module 'sutando' { interface OrderByRawMethod extends RawInterface {} export class sutando { - static connections: { - [key: string]: AnyQueryBuilder - }; + static connectorFactory: any | null; + static instance: sutando | null; static connection(connection?: string | null): AnyQueryBuilder; + static setConnectorFactory(connectorFactory: any): void; + static getConnectorFactory(): any; static getConnection(connection?: string | null): AnyQueryBuilder; static addConnection(config: object, name?: string): void; static beginTransaction(name?: string | null): Promise; @@ -227,6 +228,26 @@ declare module 'sutando' { static schema(name?: string | null): SchemaBuilder; static table(table: string, connection?: string): QueryBuilder; static destroyAll(): Promise; + static createModel(name: string, options: any): typeof Model; + manager: { + [key: string]: AnyQueryBuilder + }; + connections: { + [key: string]: any + }; + models: { + [key: string]: typeof Model; + }; + connection(connection?: string | null): AnyQueryBuilder; + setConnectorFactory(connectorFactory: any): void; + getConnectorFactory(): any; + addConnection(config: object, name?: string): void; + beginTransaction(name?: string | null): Promise; + transaction(callback: (trx: Trx) => Promise, name?: string | null): any; + schema(name?: string | null): SchemaBuilder; + table(table: string, connection?: string): QueryBuilder; + destroyAll(): Promise; + createModel(name: string, options: any): typeof Model; } export class Attribute {