feat: infer belongs to association field in view collection (#1756)
support belongs to field in view collection
This commit is contained in:
parent
60c8d531ef
commit
c7b9e6ac51
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 }) {
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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)) {
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user