From 3be12644edf381923822c235a2cf0a21140b5ac9 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sat, 26 Feb 2022 15:12:18 +0800 Subject: [PATCH] feat: sort in collection fields (#207) * feat: sort in collection fields * fix: sort should call before hidden * mov: test file * refactor: toJSON with traverseJSON * fix: toJSON test * fix: sortBy with hidden field --- .../__tests__/field-options/sort-by.test.ts | 160 ++++++++++++++++++ .../src/fields/belongs-to-many-field.ts | 4 +- .../database/src/fields/has-many-field.ts | 7 +- .../database/src/fields/relation-field.ts | 4 + packages/database/src/model.ts | 136 ++++++++++----- 5 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 packages/database/src/__tests__/field-options/sort-by.test.ts diff --git a/packages/database/src/__tests__/field-options/sort-by.test.ts b/packages/database/src/__tests__/field-options/sort-by.test.ts new file mode 100644 index 000000000..d52d937a4 --- /dev/null +++ b/packages/database/src/__tests__/field-options/sort-by.test.ts @@ -0,0 +1,160 @@ +import { mockDatabase } from '@nocobase/test'; +import { Database } from '../../index'; + +describe('associated field order', () => { + let db: Database; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + + db.collection({ + name: 'users', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'hasMany', + name: 'posts', + sortBy: 'title', + }, + { + type: 'hasMany', + name: 'records', + sortBy: 'count', + }, + ], + }); + + db.collection({ + name: 'records', + fields: [ + { + type: 'integer', + name: 'count', + hidden: true, + }, + { + type: 'string', + name: 'name', + }, + ], + }); + + db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'belongsToMany', + name: 'tags', + sortBy: 'name', + }, + ], + }); + + db.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsToMany', + name: 'posts', + }, + ], + }); + await db.sync(); + }); + + it('should sort hasMany association', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + posts: [{ title: 'c' }, { title: 'b' }, { title: 'a' }], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['posts'], + }); + + const u1Json = u1.toJSON(); + + const u1Posts = u1Json['posts']; + expect(u1Posts.map((p) => p['title'])).toEqual(['a', 'b', 'c']); + }); + + it('should sort belongsToMany association', async () => { + await db.getRepository('posts').create({ + values: { + title: 'p1', + tags: [{ name: 'c' }, { name: 'b' }, { name: 'a' }], + }, + }); + + const p1 = await db.getRepository('posts').findOne({ + appends: ['tags'], + }); + + const p1JSON = p1.toJSON(); + + const p1Tags = p1JSON['tags']; + expect(p1Tags.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); + + it('should sort nested associations', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + posts: [{ title: 'c', tags: [{ name: 'c' }, { name: 'b' }, { name: 'a' }] }, { title: 'b' }, { title: 'a' }], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['posts.tags'], + }); + + const u1Json = u1.toJSON(); + const u1Posts = u1Json['posts']; + expect(u1Posts.map((p) => p['title'])).toEqual(['a', 'b', 'c']); + + const postCTags = u1Posts[2]['tags']; + expect(postCTags.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); + + it('should sortBy hidden field', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + records: [ + { count: 3, name: 'c' }, + { count: 2, name: 'b' }, + { count: 1, name: 'a' }, + ], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['records'], + }); + + const u1Json = u1.toJSON(); + + const u1Records = u1Json['records']; + expect(u1Records[0].count).toBeUndefined(); + expect(u1Records.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/database/src/fields/belongs-to-many-field.ts b/packages/database/src/fields/belongs-to-many-field.ts index 47a95255e..cb8de0725 100644 --- a/packages/database/src/fields/belongs-to-many-field.ts +++ b/packages/database/src/fields/belongs-to-many-field.ts @@ -1,7 +1,7 @@ import { omit } from 'lodash'; import { BelongsToManyOptions as SequelizeBelongsToManyOptions } from 'sequelize'; import { Collection } from '../collection'; -import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { BaseRelationFieldOptions, MultipleRelationFieldOptions, RelationField } from './relation-field'; export class BelongsToManyField extends RelationField { get through() { @@ -62,7 +62,7 @@ export class BelongsToManyField extends RelationField { } export interface BelongsToManyFieldOptions - extends BaseRelationFieldOptions, + extends MultipleRelationFieldOptions, Omit { type: 'belongsToMany'; through?: string; diff --git a/packages/database/src/fields/has-many-field.ts b/packages/database/src/fields/has-many-field.ts index 93ab7abe8..eddc624b4 100644 --- a/packages/database/src/fields/has-many-field.ts +++ b/packages/database/src/fields/has-many-field.ts @@ -5,9 +5,10 @@ import { ForeignKeyOptions, HasManyOptions, HasManyOptions as SequelizeHasManyOptions, - Utils + Utils, } from 'sequelize'; -import { BaseRelationFieldOptions, RelationField } from './relation-field'; + +import { BaseRelationFieldOptions, MultipleRelationFieldOptions, RelationField } from './relation-field'; export interface HasManyFieldOptions extends HasManyOptions { /** @@ -136,7 +137,7 @@ export class HasManyField extends RelationField { } } -export interface HasManyFieldOptions extends BaseRelationFieldOptions, SequelizeHasManyOptions { +export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions { type: 'hasMany'; target?: string; } diff --git a/packages/database/src/fields/relation-field.ts b/packages/database/src/fields/relation-field.ts index 52d72d010..80e822d35 100644 --- a/packages/database/src/fields/relation-field.ts +++ b/packages/database/src/fields/relation-field.ts @@ -2,6 +2,10 @@ import { BaseFieldOptions, Field } from './field'; export interface BaseRelationFieldOptions extends BaseFieldOptions {} +export interface MultipleRelationFieldOptions extends BaseRelationFieldOptions { + sortBy?: string | string[]; +} + export abstract class RelationField extends Field { /** * target relation name diff --git a/packages/database/src/model.ts b/packages/database/src/model.ts index 0c8f2fb5e..edb6ed86e 100644 --- a/packages/database/src/model.ts +++ b/packages/database/src/model.ts @@ -1,62 +1,112 @@ -import { Model as SequelizeModel } from 'sequelize'; +import { Model as SequelizeModel, ModelCtor } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; +import lodash from 'lodash'; +import { Field } from './fields'; interface IModel { [key: string]: any; } +interface JSONTransformerOptions { + model: ModelCtor; + collection: Collection; + db: Database; + key?: string; + field?: Field; +} + export class Model extends SequelizeModel implements IModel { public static database: Database; public static collection: Collection; - // [key: string]: any; - - private toJsonWithoutHiddenFields(data, { model, collection }): any { - if (!data) { - return data; - } - if (typeof data.toJSON === 'function') { - data = data.toJSON(); - } - const db = (this.constructor as any).database as Database; - const hidden = []; - collection.forEachField((field) => { - if (field.options.hidden) { - hidden.push(field.options.name); - } - }); - const json = {}; - Object.keys(data).forEach((key) => { - if (hidden.includes(key)) { - return; - } - if (model.hasAlias(key)) { - const association = model.associations[key]; - const opts = { - model: association.target, - collection: db.getCollection(association.target.name), - }; - if (['HasMany', 'BelongsToMany'].includes(association.associationType)) { - if (Array.isArray(data[key])) { - json[key] = data[key].map((item) => this.toJsonWithoutHiddenFields(item, opts)); - } - } else { - json[key] = this.toJsonWithoutHiddenFields(data[key], opts); - } - } else { - json[key] = data[key]; - } - }); - return json; - } public toJSON(): T { - return this.toJsonWithoutHiddenFields(super.toJSON(), { - model: this.constructor, + const handleObj = (obj, options: JSONTransformerOptions) => { + const handles = [ + (data) => { + if (data instanceof Model) { + return data.toJSON(); + } + + return data; + }, + this.hiddenObjKey, + ]; + return handles.reduce((carry, fn) => fn.apply(this, [carry, options]), obj); + }; + + const handleArray = (arrayOfObj, options: JSONTransformerOptions) => { + const handles = [this.sortAssociations]; + return handles.reduce((carry, fn) => fn.apply(this, [carry, options]), arrayOfObj); + }; + + const opts = { + model: this.constructor as ModelCtor, collection: (this.constructor as any).collection, + db: (this.constructor as any).database as Database, + }; + + const traverseJSON = (data: T, options: JSONTransformerOptions): T => { + const { model, db, collection } = options; + // handle Object + data = handleObj(data, options); + + const result = {}; + for (const key of Object.keys(data)) { + // @ts-ignore + if (model.hasAlias(key)) { + const association = model.associations[key]; + const opts = { + model: association.target, + collection: db.getCollection(association.target.name), + db, + key, + field: collection.getField(key), + }; + + if (['HasMany', 'BelongsToMany'].includes(association.associationType)) { + result[key] = handleArray(data[key], opts).map((item) => traverseJSON(item, opts)); + } else { + result[key] = traverseJSON(data[key], opts); + } + } else { + result[key] = data[key]; + } + } + + return result as T; + }; + + return traverseJSON(super.toJSON(), opts); + } + + private hiddenObjKey(obj, options: JSONTransformerOptions) { + const hiddenFields = Array.from(options.collection.fields.values()) + .filter((field) => field.options.hidden) + .map((field) => field.options.name); + + return lodash.omit(obj, hiddenFields); + } + + private sortAssociations(data, { field }: JSONTransformerOptions): any { + const sortBy = field.options.sortBy; + return sortBy ? this.sortArray(data, sortBy) : data; + } + + private sortArray(data, sortBy: string | string[]) { + if (!lodash.isArray(sortBy)) { + sortBy = [sortBy]; + } + + const orders = sortBy.map((sortItem) => { + const direction = sortItem.startsWith('-') ? 'desc' : 'asc'; + sortItem.replace('-', ''); + return [sortItem, direction]; }); + + return lodash.sortBy(data, ...orders); } }