feat: infer belongs to association field in view collection (#1756)

support belongs to field in view collection
This commit is contained in:
ChengLei Shao 2023-06-04 13:04:56 +08:00 committed by GitHub
parent 60c8d531ef
commit c7b9e6ac51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 240 additions and 44 deletions

View File

@ -1,5 +1,6 @@
import { Database, mockDatabase } from '@nocobase/database'; import { Database, mockDatabase } from '@nocobase/database';
import { ViewFieldInference } from '../../view/view-inference'; import { ViewFieldInference } from '../../view/view-inference';
import { uid } from '@nocobase/utils';
describe('view inference', function () { describe('view inference', function () {
let db: Database; let db: Database;

View File

@ -454,6 +454,7 @@ export class Database extends EventEmitter implements AsyncEmitter {
})(); })();
const collection = new collectionKlass(options, { database: this }); const collection = new collectionKlass(options, { database: this });
this.collections.set(collection.name, collection); this.collections.set(collection.name, collection);
this.emit('afterDefineCollection', collection); this.emit('afterDefineCollection', collection);
@ -630,7 +631,9 @@ export class Database extends EventEmitter implements AsyncEmitter {
throw Error(`unsupported field type ${type}`); 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); options.field = snakeCase(options.field);
} }

View File

@ -4,7 +4,6 @@ export class ViewCollection extends Collection {
constructor(options: CollectionOptions, context: CollectionContext) { constructor(options: CollectionOptions, context: CollectionContext) {
options.autoGenId = false; options.autoGenId = false;
options.timestamps = false; options.timestamps = false;
options.underscored = false;
super(options, context); super(options, context);
} }

View File

@ -30,52 +30,84 @@ export class ViewFieldInference {
schema: options.viewSchema, schema: options.viewSchema,
}); });
// @ts-ignore const rawFields = [];
return Object.fromEntries( for (const [name, column] of Object.entries(columns)) {
Object.entries(columns).map(([name, column]) => { const inferResult: any = { name };
const usage = columnUsage[name];
if (usage) { const usage = columnUsage[name];
const collectionField = (() => {
const tableName = `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`;
const collection = db.tableNameCollectionMap.get(tableName);
if (!collection) return false;
const fieldValue = Object.values(collection.model.rawAttributes).find( if (usage) {
(field) => field.field === usage.column_name, const collection = db.tableNameCollectionMap.get(
); `${usage.table_schema ? `${usage.table_schema}.` : ''}${usage.table_name}`,
);
if (!fieldValue) { const collectionField = (() => {
return false; if (!collection) return false;
}
// @ts-ignore const fieldAttribute = Object.values(collection.model.rawAttributes).find(
const fieldName = fieldValue?.fieldName; (field) => field.field === usage.column_name,
);
return collection.getField(fieldName); if (!fieldAttribute) {
})(); return false;
if (collectionField && collectionField.options.interface) {
return [
name,
{
name,
type: collectionField.type,
source: `${collectionField.collection.name}.${collectionField.name}`,
},
];
} }
// @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 [ if (collectionField) {
name, if (collectionField.options.interface) {
{ inferResult.type = collectionField.type;
name, inferResult.source = `${collectionField.collection.name}.${collectionField.name}`;
...this.inferToFieldType({ db, name, type: column.type }), }
}, }
]; }
}),
); 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 }) { static inferToFieldType(options: { db: Database; name: string; type: string }) {

View File

@ -1,11 +1,16 @@
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { createApp } from '../index'; import { createApp } from '../index';
import { uid } from '@nocobase/utils'; import { uid } from '@nocobase/utils';
import { Database, Repository } from '@nocobase/database';
describe('view collection', () => { describe('view collection', () => {
let app: MockServer; let app: MockServer;
let db: Database;
let agent; let agent;
let testViewName; let testViewName;
let collectionRepository: Repository;
let fieldsRepository: Repository;
beforeEach(async () => { beforeEach(async () => {
app = await createApp({ app = await createApp({
@ -13,6 +18,12 @@ describe('view collection', () => {
tablePrefix: '', tablePrefix: '',
}, },
}); });
db = app.db;
collectionRepository = app.db.getCollection('collections').repository;
fieldsRepository = app.db.getCollection('fields').repository;
agent = app.agent(); agent = app.agent();
testViewName = `view_${uid(6)}`; testViewName = `view_${uid(6)}`;
const dropSQL = `DROP VIEW IF EXISTS ${testViewName}`; const dropSQL = `DROP VIEW IF EXISTS ${testViewName}`;
@ -408,4 +419,80 @@ SELECT * FROM numbers;
const nameField = viewFieldsResponse.body.data.find((item) => item.name === 'name'); const nameField = viewFieldsResponse.body.data.find((item) => item.name === 'name');
expect(nameField.uiSchema.title).toEqual('bars'); 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);
});
}); });

View File

@ -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 Application from '@nocobase/server';
import { createApp } from '../index'; import { createApp } from '../index';
import { uid } from '@nocobase/utils'; import { uid } from '@nocobase/utils';
@ -28,6 +28,68 @@ describe('view collection', function () {
await app.destroy(); 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 () => { it('should use view collection as through collection', async () => {
const User = await collectionRepository.create({ const User = await collectionRepository.create({
values: { values: {
@ -235,7 +297,7 @@ describe('view collection', function () {
name: 'view_collection', name: 'view_collection',
viewName: 'test_view', viewName: 'test_view',
isView: true, isView: true,
fields: [{ type: 'string', name: 'Uppercase', field: 'Uppercase' }], fields: [{ type: 'string', name: 'upper_case', field: 'Uppercase' }],
schema: db.inDialect('postgres') ? 'public' : undefined, schema: db.inDialect('postgres') ? 'public' : undefined,
}, },
context: {}, context: {},
@ -243,7 +305,7 @@ describe('view collection', function () {
const viewCollection = db.getCollection('view_collection'); 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(); const results = await viewCollection.repository.find();
expect(results.length).toBe(1); expect(results.length).toBe(1);
}); });

View File

@ -105,7 +105,18 @@ export function afterCreateForForeignKeyField(db: Database) {
return; 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 // foreign key in target collection
if (['oho', 'o2m'].includes(interfaceType)) { if (['oho', 'o2m'].includes(interfaceType)) {

View File

@ -100,6 +100,7 @@ export class CollectionManagerPlugin extends Plugin {
}); });
this.app.db.on('fields.beforeCreate', async (model, options) => { this.app.db.on('fields.beforeCreate', async (model, options) => {
if (model.get('source')) return;
const type = model.get('type'); const type = model.get('type');
const fn = beforeInitOptions[type]; const fn = beforeInitOptions[type];
if (fn) { if (fn) {