From c7b9e6ac515492fc3c13e7ef3d7ebf63ce49d68c Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sun, 4 Jun 2023 13:04:56 +0800 Subject: [PATCH] feat: infer belongs to association field in view collection (#1756) support belongs to field in view collection --- .../src/__tests__/view/view-inference.test.ts | 1 + packages/core/database/src/database.ts | 5 +- packages/core/database/src/view-collection.ts | 1 - .../core/database/src/view/view-inference.ts | 108 ++++++++++++------ .../http-api/view-collection.test.ts | 87 ++++++++++++++ .../__tests__/view/view-collection.test.ts | 68 ++++++++++- .../hooks/afterCreateForForeignKeyField.ts | 13 ++- .../plugins/collection-manager/src/server.ts | 1 + 8 files changed, 240 insertions(+), 44 deletions(-) diff --git a/packages/core/database/src/__tests__/view/view-inference.test.ts b/packages/core/database/src/__tests__/view/view-inference.test.ts index d8de7ac24..12d97bc3d 100644 --- a/packages/core/database/src/__tests__/view/view-inference.test.ts +++ b/packages/core/database/src/__tests__/view/view-inference.test.ts @@ -1,5 +1,6 @@ import { Database, mockDatabase } from '@nocobase/database'; import { ViewFieldInference } from '../../view/view-inference'; +import { uid } from '@nocobase/utils'; describe('view inference', function () { let db: Database; diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 71697e7fa..31f2aca70 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -454,6 +454,7 @@ export class Database extends EventEmitter implements AsyncEmitter { })(); const collection = new collectionKlass(options, { database: this }); + this.collections.set(collection.name, collection); this.emit('afterDefineCollection', collection); @@ -630,7 +631,9 @@ export class Database extends EventEmitter implements AsyncEmitter { throw Error(`unsupported field type ${type}`); } - if (options.field && context.collection.options.underscored) { + const { collection } = context; + + if (options.field && collection.options.underscored && !collection.isView()) { options.field = snakeCase(options.field); } diff --git a/packages/core/database/src/view-collection.ts b/packages/core/database/src/view-collection.ts index 363317290..ff8f9eafd 100644 --- a/packages/core/database/src/view-collection.ts +++ b/packages/core/database/src/view-collection.ts @@ -4,7 +4,6 @@ export class ViewCollection extends Collection { constructor(options: CollectionOptions, context: CollectionContext) { options.autoGenId = false; options.timestamps = false; - options.underscored = false; super(options, context); } diff --git a/packages/core/database/src/view/view-inference.ts b/packages/core/database/src/view/view-inference.ts index ae497c555..f7258ff0c 100644 --- a/packages/core/database/src/view/view-inference.ts +++ b/packages/core/database/src/view/view-inference.ts @@ -30,52 +30,84 @@ export class ViewFieldInference { schema: options.viewSchema, }); - // @ts-ignore - return Object.fromEntries( - Object.entries(columns).map(([name, column]) => { - const usage = columnUsage[name]; + const rawFields = []; + for (const [name, column] of Object.entries(columns)) { + const inferResult: any = { name }; - if (usage) { - const collectionField = (() => { - const tableName = `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`; - const collection = db.tableNameCollectionMap.get(tableName); - if (!collection) return false; + const usage = columnUsage[name]; - const fieldValue = Object.values(collection.model.rawAttributes).find( - (field) => field.field === usage.column_name, - ); + if (usage) { + const collection = db.tableNameCollectionMap.get( + `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`, + ); - if (!fieldValue) { - return false; - } + const collectionField = (() => { + if (!collection) return false; - // @ts-ignore - const fieldName = fieldValue?.fieldName; + const fieldAttribute = Object.values(collection.model.rawAttributes).find( + (field) => field.field === usage.column_name, + ); - return collection.getField(fieldName); - })(); - - if (collectionField && collectionField.options.interface) { - return [ - name, - { - name, - type: collectionField.type, - source: `${collectionField.collection.name}.${collectionField.name}`, - }, - ]; + if (!fieldAttribute) { + return false; } + + // @ts-ignore + const fieldName = fieldAttribute.fieldName; + + return collection.getField(fieldName); + })(); + + const belongsToAssociationField = (() => { + if (!collection) return false; + + const field = Object.values(collection.model.rawAttributes).find( + (field) => field.field === usage.column_name, + ); + + if (!field) { + return false; + } + + const association = Object.values(collection.model.associations).find( + (association) => + association.associationType === 'BelongsTo' && association.foreignKey === (field as any).fieldName, + ); + + if (!association) { + return false; + } + + return collection.getField(association.as); + })(); + + if (belongsToAssociationField) { + rawFields.push([ + belongsToAssociationField.name, + { + name: belongsToAssociationField.name, + type: belongsToAssociationField.type, + source: `${belongsToAssociationField.collection.name}.${belongsToAssociationField.name}`, + }, + ]); } - return [ - name, - { - name, - ...this.inferToFieldType({ db, name, type: column.type }), - }, - ]; - }), - ); + if (collectionField) { + if (collectionField.options.interface) { + inferResult.type = collectionField.type; + inferResult.source = `${collectionField.collection.name}.${collectionField.name}`; + } + } + } + + if (!inferResult.type) { + Object.assign(inferResult, this.inferToFieldType({ db, name, type: column.type })); + } + + rawFields.push([name, inferResult]); + } + + return Object.fromEntries(rawFields); } static inferToFieldType(options: { db: Database; name: string; type: string }) { diff --git a/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts index 8e0ed5ca7..559f0be5e 100644 --- a/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/http-api/view-collection.test.ts @@ -1,11 +1,16 @@ import { MockServer } from '@nocobase/test'; import { createApp } from '../index'; import { uid } from '@nocobase/utils'; +import { Database, Repository } from '@nocobase/database'; describe('view collection', () => { let app: MockServer; + let db: Database; let agent; let testViewName; + let collectionRepository: Repository; + + let fieldsRepository: Repository; beforeEach(async () => { app = await createApp({ @@ -13,6 +18,12 @@ describe('view collection', () => { tablePrefix: '', }, }); + + db = app.db; + + collectionRepository = app.db.getCollection('collections').repository; + fieldsRepository = app.db.getCollection('fields').repository; + agent = app.agent(); testViewName = `view_${uid(6)}`; const dropSQL = `DROP VIEW IF EXISTS ${testViewName}`; @@ -408,4 +419,80 @@ SELECT * FROM numbers; const nameField = viewFieldsResponse.body.data.find((item) => item.name === 'name'); expect(nameField.uiSchema.title).toEqual('bars'); }); + + it('should create view collection with belongs to field', async () => { + // not support sqlite + if (db.inDialect('sqlite')) { + return; + } + await collectionRepository.create({ + values: { + name: 'groups', + fields: [{ name: 'name', type: 'string' }], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'users', + fields: [ + { name: 'name', type: 'string' }, + { type: 'belongsTo', name: 'group', foreignKey: 'groupId', interface: 'test-interface' }, + ], + }, + context: {}, + }); + + const User = db.getCollection('users'); + + const assoc = User.model.associations.group; + const foreignKey = assoc.foreignKey; + const foreignField = User.model.rawAttributes[foreignKey].field; + + const viewName = `test_view_${uid(6)}`; + await db.sequelize.query(`DROP VIEW IF EXISTS ${viewName}`); + const queryInterface = db.sequelize.getQueryInterface(); + + const createSQL = `CREATE VIEW ${queryInterface.quoteIdentifier( + viewName, + )} AS SELECT id, ${queryInterface.quoteIdentifier(foreignField)}, name FROM ${db + .getCollection('users') + .quotedTableName()}`; + + await db.sequelize.query(createSQL); + + const response = await agent.resource('dbViews').get({ + filterByTk: viewName, + schema: db.inDialect('postgres') ? 'public' : undefined, + pageSize: 20, + }); + + expect(response.status).toEqual(200); + const fields = response.body.data.fields; + + await collectionRepository.create({ + values: { + name: viewName, + view: true, + fields: Object.values(fields), + schema: db.inDialect('postgres') ? 'public' : undefined, + }, + context: {}, + }); + + const viewFieldsResponse = await agent.resource('collections.fields', viewName).list({}); + expect(viewFieldsResponse.status).toEqual(200); + const viewFields = viewFieldsResponse.body.data; + const groupField = viewFields.find((item) => item.name === 'group'); + + expect(groupField.type).toEqual('belongsTo'); + expect(groupField.interface).toEqual('test-interface'); + + const listResponse1 = await agent.resource(viewName).list({ + appends: ['group'], + }); + + expect(listResponse1.status).toEqual(200); + }); }); diff --git a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts index 574155e1d..786b316ba 100644 --- a/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts +++ b/packages/plugins/collection-manager/src/__tests__/view/view-collection.test.ts @@ -1,4 +1,4 @@ -import Database, { Repository, ViewCollection } from '@nocobase/database'; +import Database, { Repository, ViewCollection, ViewFieldInference } from '@nocobase/database'; import Application from '@nocobase/server'; import { createApp } from '../index'; import { uid } from '@nocobase/utils'; @@ -28,6 +28,68 @@ describe('view collection', function () { await app.destroy(); }); + it('should create view collection with belongs to association', async () => { + await collectionRepository.create({ + values: { + name: 'groups', + fields: [{ name: 'name', type: 'string' }], + }, + context: {}, + }); + + await collectionRepository.create({ + values: { + name: 'users', + fields: [ + { name: 'name', type: 'string' }, + { type: 'belongsTo', name: 'group', foreignKey: 'group_id' }, + ], + }, + context: {}, + }); + + const User = db.getCollection('users'); + + const assoc = User.model.associations.group; + const foreignKey = assoc.foreignKey; + const foreignField = User.model.rawAttributes[foreignKey].field; + + const viewName = `test_view_${uid(6)}`; + await db.sequelize.query(`DROP VIEW IF EXISTS ${viewName}`); + + const createSQL = `CREATE VIEW ${viewName} AS SELECT id, ${foreignField}, name FROM ${db + .getCollection('users') + .quotedTableName()}`; + + await db.sequelize.query(createSQL); + + const inferredFields = await ViewFieldInference.inferFields({ + db, + viewName, + viewSchema: 'public', + }); + + if (!db.inDialect('sqlite')) { + expect(inferredFields['group_id'].type).toBe('bigInt'); + + expect(inferredFields['group'].type).toBe('belongsTo'); + + await collectionRepository.create({ + values: { + name: viewName, + view: true, + fields: Object.values(inferredFields), + schema: db.inDialect('postgres') ? 'public' : undefined, + }, + context: {}, + }); + + const viewCollection = db.getCollection(viewName); + const group = viewCollection.getField('group'); + expect(group.foreignKey).toEqual('group_id'); + } + }); + it('should use view collection as through collection', async () => { const User = await collectionRepository.create({ values: { @@ -235,7 +297,7 @@ describe('view collection', function () { name: 'view_collection', viewName: 'test_view', isView: true, - fields: [{ type: 'string', name: 'Uppercase', field: 'Uppercase' }], + fields: [{ type: 'string', name: 'upper_case', field: 'Uppercase' }], schema: db.inDialect('postgres') ? 'public' : undefined, }, context: {}, @@ -243,7 +305,7 @@ describe('view collection', function () { const viewCollection = db.getCollection('view_collection'); - expect(viewCollection.model.rawAttributes['Uppercase'].field).toEqual('Uppercase'); + expect(viewCollection.model.rawAttributes['upper_case'].field).toEqual('Uppercase'); const results = await viewCollection.repository.find(); expect(results.length).toBe(1); }); diff --git a/packages/plugins/collection-manager/src/hooks/afterCreateForForeignKeyField.ts b/packages/plugins/collection-manager/src/hooks/afterCreateForForeignKeyField.ts index 7b354b39b..c2c69ce0b 100644 --- a/packages/plugins/collection-manager/src/hooks/afterCreateForForeignKeyField.ts +++ b/packages/plugins/collection-manager/src/hooks/afterCreateForForeignKeyField.ts @@ -105,7 +105,18 @@ export function afterCreateForForeignKeyField(db: Database) { return; } - const { type, interface: interfaceType, collectionName, target, through, foreignKey, otherKey } = model.get(); + const { + type, + interface: interfaceType, + collectionName, + target, + through, + foreignKey, + otherKey, + source, + } = model.get(); + + if (source) return; // foreign key in target collection if (['oho', 'o2m'].includes(interfaceType)) { diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index 58e7e1e2f..338918ab3 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -100,6 +100,7 @@ export class CollectionManagerPlugin extends Plugin { }); this.app.db.on('fields.beforeCreate', async (model, options) => { + if (model.get('source')) return; const type = model.get('type'); const fn = beforeInitOptions[type]; if (fn) {