mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
feat(database): new field type many to many (array) (#4708)
* feat: recordSet field * fix: record set field * test: add tests * fix: tests * fix: build * feat: front end * refactor: belongs to array field * fix: tests * fix: version * fix: version * fix: build * chore: update * chore: add error * chore: remove only * feat: add locales * fix: version * fix: e2e * fix: fix T-4661 * fix: fix T-4663 * fix: fix T-4665 * fix: fix T-4670 * fix: fix T-4666 * fix: fix T-4664 * fix: fix T-4668 * fix: test * fix: fix T-4669 * fix: fix T-4667 * fix: bug * fix: fix T-4670 * chore: add transaction * feat: beforeAddDataSource hook * feat: support external database sources, fix T-4717 * fix: bug * fix: fix T-4671 * fix: fix T-4769 * fix: version * fix: fix T-4762 * fix: array type interface * fix: fix T-4742 * fix: fix T-4661 * fix: bug * fix: bug * feat: check association keys in backend * fix: bug * fix: bug * fix: bug * fix: test * fix: bug * fix: e2e --------- Co-authored-by: Chareice <chareice@live.com>
This commit is contained in:
parent
36f70c8aba
commit
e0b5128c9d
@ -1402,7 +1402,8 @@ export const useAssociationNames = (dataSource?: string) => {
|
||||
const collectionField = s['x-collection-field'] && getCollectionJoinField(s['x-collection-field'], dataSource);
|
||||
const isAssociationSubfield = s.name.includes('.');
|
||||
const isAssociationField =
|
||||
collectionField && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionField.type);
|
||||
collectionField &&
|
||||
['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(collectionField.type);
|
||||
|
||||
// 根据联动规则中条件的字段获取一些 appends
|
||||
if (s['x-linkage-rules']) {
|
||||
|
@ -33,7 +33,7 @@ export class JsonFieldInterface extends CollectionFieldInterface {
|
||||
group = 'advanced';
|
||||
order = 4;
|
||||
title = '{{t("JSON")}}';
|
||||
sortable = true;
|
||||
sortable = false;
|
||||
default = {
|
||||
type: 'json',
|
||||
// name,
|
||||
@ -50,7 +50,7 @@ export class JsonFieldInterface extends CollectionFieldInterface {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
availableTypes = ['json', 'array', 'jsonb', 'text', 'circle', 'lineString', 'point', 'polygon'];
|
||||
availableTypes = ['json', 'array', 'set', 'jsonb', 'text', 'circle', 'lineString', 'point', 'polygon'];
|
||||
hasDefaultValue = true;
|
||||
properties = {
|
||||
...defaultProps,
|
||||
@ -68,7 +68,7 @@ export class JsonFieldInterface extends CollectionFieldInterface {
|
||||
'x-disabled': `{{ disabledJSONB }}`,
|
||||
},
|
||||
};
|
||||
filterable = {
|
||||
operators: operators.string,
|
||||
};
|
||||
// filterable = {
|
||||
// operators: operators.string,
|
||||
// };
|
||||
}
|
||||
|
@ -169,7 +169,7 @@ export const useAssociatedFields = () => {
|
||||
};
|
||||
|
||||
export const isAssocField = (field?: FieldOptions) => {
|
||||
return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo', 'chinaRegion'].includes(
|
||||
return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo', 'chinaRegion', 'mbm'].includes(
|
||||
field?.interface,
|
||||
);
|
||||
};
|
||||
|
@ -71,7 +71,7 @@ const allowMultiple: any = {
|
||||
useVisible() {
|
||||
const isFieldReadPretty = useIsFieldReadPretty();
|
||||
const collectionField = useCollectionField();
|
||||
return !isFieldReadPretty && ['hasMany', 'belongsToMany'].includes(collectionField?.type);
|
||||
return !isFieldReadPretty && ['hasMany', 'belongsToMany', 'belongsToArray'].includes(collectionField?.type);
|
||||
},
|
||||
useComponentProps() {
|
||||
const { t } = useTranslation();
|
||||
|
@ -72,7 +72,10 @@ export const AssociationFieldProvider = observer(
|
||||
if (field.value !== null && field.value !== undefined) {
|
||||
// Nester 子表单时,如果没数据初始化一个 [{}] 的占位
|
||||
if (['Nester', 'PopoverNester'].includes(currentMode) && Array.isArray(field.value)) {
|
||||
if (field.value.length === 0 && ['belongsToMany', 'hasMany'].includes(collectionField.type)) {
|
||||
if (
|
||||
field.value.length === 0 &&
|
||||
['belongsToMany', 'hasMany', 'belongsToArray'].includes(collectionField.type)
|
||||
) {
|
||||
field.value = [markRecordAsNew({})];
|
||||
}
|
||||
}
|
||||
@ -82,7 +85,7 @@ export const AssociationFieldProvider = observer(
|
||||
if (['Nester'].includes(currentMode)) {
|
||||
if (['belongsTo', 'hasOne'].includes(collectionField.type)) {
|
||||
field.value = {};
|
||||
} else if (['belongsToMany', 'hasMany'].includes(collectionField.type)) {
|
||||
} else if (['belongsToMany', 'hasMany', 'belongsToArray'].includes(collectionField.type)) {
|
||||
field.value = [markRecordAsNew({})];
|
||||
}
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ export const InternalPicker = observer(
|
||||
const pickerProps = {
|
||||
size: 'small',
|
||||
fieldNames,
|
||||
multiple: multiple !== false && ['o2m', 'm2m'].includes(collectionField?.interface),
|
||||
multiple: multiple !== false && ['o2m', 'm2m', 'mbm'].includes(collectionField?.interface),
|
||||
association: {
|
||||
target: collectionField?.target,
|
||||
},
|
||||
@ -142,7 +142,7 @@ export const InternalPicker = observer(
|
||||
setVisible(false);
|
||||
},
|
||||
style: {
|
||||
display: multiple !== false && ['o2m', 'm2m'].includes(collectionField?.interface) ? 'block' : 'none',
|
||||
display: multiple !== false && ['o2m', 'm2m', 'mbm'].includes(collectionField?.interface) ? 'block' : 'none',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -43,7 +43,7 @@ export const Nester = (props) => {
|
||||
</FlagProvider>
|
||||
);
|
||||
}
|
||||
if (['hasMany', 'belongsToMany'].includes(options.type)) {
|
||||
if (['hasMany', 'belongsToMany', 'belongsToArray'].includes(options.type)) {
|
||||
return (
|
||||
<FlagProvider isInSubForm>
|
||||
<ToManyNester {...props} />
|
||||
|
@ -982,7 +982,7 @@ function useFormItemCollectionField() {
|
||||
|
||||
export function useIsAssociationField() {
|
||||
const collectionField = useFormItemCollectionField();
|
||||
const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o', 'updatedBy', 'createdBy'].includes(
|
||||
const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o', 'updatedBy', 'createdBy', 'mbm'].includes(
|
||||
collectionField?.interface,
|
||||
);
|
||||
return isAssociationField;
|
||||
|
@ -32,7 +32,7 @@ export const useFieldModeOptions = (props?) => {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy'].includes(
|
||||
!['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy', 'mbm'].includes(
|
||||
collectionField.interface,
|
||||
)
|
||||
)
|
||||
@ -86,6 +86,7 @@ export const useFieldModeOptions = (props?) => {
|
||||
!isTableField && { label: t('Sub-table'), value: 'SubTable' },
|
||||
];
|
||||
case 'm2m':
|
||||
case 'mbm':
|
||||
return isReadPretty
|
||||
? [
|
||||
{ label: t('Title'), value: 'Select' },
|
||||
|
@ -306,7 +306,7 @@ export const useFormItemInitializerFields = (options?: any) => {
|
||||
const targetCollection = getCollection(field.target);
|
||||
const isFileCollection = field?.target && getCollection(field?.target)?.template === 'file';
|
||||
const isAssociationField = targetCollection;
|
||||
const fieldNames = field?.uiSchema['x-component-props']?.['fieldNames'];
|
||||
const fieldNames = field?.uiSchema?.['x-component-props']?.['fieldNames'];
|
||||
const schema = {
|
||||
type: 'string',
|
||||
name: field.name,
|
||||
|
@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import lodash from 'lodash';
|
||||
import lodash, { flatten } from 'lodash';
|
||||
import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op, Transaction } from 'sequelize';
|
||||
import Database from '../database';
|
||||
import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find';
|
||||
@ -225,6 +225,16 @@ export class EagerLoadingTree {
|
||||
throw new Error(`Model ${node.model.name} does not have primary key`);
|
||||
}
|
||||
|
||||
includeForFilter.forEach((include: { association: string }, index: number) => {
|
||||
const association = node.model.associations[include.association];
|
||||
if (association?.associationType == 'BelongsToArray') {
|
||||
includeForFilter[index] = {
|
||||
...include,
|
||||
...association.generateInclude(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// find all ids
|
||||
const ids = (
|
||||
await node.model.findAll({
|
||||
@ -256,10 +266,13 @@ export class EagerLoadingTree {
|
||||
|
||||
// clear filter association value
|
||||
const associations = node.model.associations;
|
||||
for (const association of Object.keys(associations)) {
|
||||
for (const [name, association] of Object.entries(associations)) {
|
||||
// if ((association as any).associationType === 'belongsToArray') {
|
||||
// continue;
|
||||
// }
|
||||
for (const instance of instances) {
|
||||
delete instance[association];
|
||||
delete instance.dataValues[association];
|
||||
delete instance[name];
|
||||
delete instance.dataValues[name];
|
||||
}
|
||||
}
|
||||
} else if (ids.length > 0) {
|
||||
@ -302,6 +315,30 @@ export class EagerLoadingTree {
|
||||
instances = await node.model.findAll(findOptions);
|
||||
}
|
||||
|
||||
if (associationType === 'BelongsToArray') {
|
||||
const targetKey = association.targetKey;
|
||||
const targetKeyValues = node.parent.instances.map((instance) => {
|
||||
return instance.get(association.foreignKey);
|
||||
});
|
||||
|
||||
let where: any = { [targetKey]: Array.from(new Set(flatten(targetKeyValues))) };
|
||||
|
||||
if (node.where) {
|
||||
where = {
|
||||
[Op.and]: [where, node.where],
|
||||
};
|
||||
}
|
||||
|
||||
const findOptions = {
|
||||
where,
|
||||
attributes: node.attributes,
|
||||
order: params.order || orderOption(association),
|
||||
transaction,
|
||||
};
|
||||
|
||||
instances = await node.model.findAll(findOptions);
|
||||
}
|
||||
|
||||
if (associationType == 'BelongsTo') {
|
||||
const foreignKey = association.foreignKey;
|
||||
const parentInstancesForeignKeyValues = node.parent.instances.map((instance) => instance.get(foreignKey));
|
||||
@ -405,6 +442,10 @@ export class EagerLoadingTree {
|
||||
const setParentAccessor = (parentInstance) => {
|
||||
const key = association.as;
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = parentInstance.getDataValue(association.as);
|
||||
|
||||
if (association.isSingleAssociation) {
|
||||
@ -443,6 +484,23 @@ export class EagerLoadingTree {
|
||||
}
|
||||
}
|
||||
|
||||
if (associationType === 'BelongsToArray') {
|
||||
const { foreignKey, targetKey } = association;
|
||||
|
||||
const instanceMap = node.instances.reduce((mp: { [targetKey: string]: Model }, instance: Model) => {
|
||||
mp[instance.get(targetKey)] = instance;
|
||||
return mp;
|
||||
}, {});
|
||||
|
||||
node.parent.instances.forEach((parentInstance: Model) => {
|
||||
const targetKeys = parentInstance.getDataValue(foreignKey);
|
||||
parentInstance.setDataValue(
|
||||
association.as,
|
||||
targetKeys?.map((targetKey: any) => instanceMap[targetKey]).filter(Boolean),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (associationType == 'BelongsTo') {
|
||||
const foreignKey = association.foreignKey;
|
||||
const targetKey = association.targetKey;
|
||||
|
@ -12,7 +12,11 @@ import { BaseColumnFieldOptions, Field } from './field';
|
||||
|
||||
export class ArrayField extends Field {
|
||||
get dataType() {
|
||||
const { dataType, elementType = '' } = this.options;
|
||||
if (this.database.sequelize.getDialect() === 'postgres') {
|
||||
if (dataType === 'array') {
|
||||
return new DataTypes.ARRAY(DataTypes[elementType.toUpperCase()]);
|
||||
}
|
||||
return DataTypes.JSONB;
|
||||
}
|
||||
|
||||
@ -44,4 +48,6 @@ export class ArrayField extends Field {
|
||||
|
||||
export interface ArrayFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'array';
|
||||
dataType?: 'array' | 'json';
|
||||
elementType: DataTypes.DataType;
|
||||
}
|
||||
|
@ -7,10 +7,9 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ArrayField } from './array-field';
|
||||
import { BaseColumnFieldOptions } from './field';
|
||||
import { ArrayField, ArrayFieldOptions } from './array-field';
|
||||
|
||||
export interface SetFieldOptions extends BaseColumnFieldOptions {
|
||||
export interface SetFieldOptions extends Omit<ArrayFieldOptions, 'type'> {
|
||||
type: 'set';
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import { ModelStatic } from 'sequelize';
|
||||
import { Collection } from './collection';
|
||||
import { Database } from './database';
|
||||
import { Model } from './model';
|
||||
import { BelongsToArrayAssociation } from './relation-repository/belongs-to-array-repository';
|
||||
|
||||
const debug = require('debug')('noco-database');
|
||||
|
||||
@ -93,7 +94,9 @@ export default class FilterParser {
|
||||
|
||||
debug('associations %O', associations);
|
||||
|
||||
for (let [key, value] of Object.entries(flattenedFilter)) {
|
||||
for (const entry of Object.entries(flattenedFilter)) {
|
||||
const key = entry[0];
|
||||
let value = entry[1];
|
||||
// 处理 filter 条件
|
||||
if (skipPrefix && key.startsWith(skipPrefix)) {
|
||||
continue;
|
||||
@ -167,6 +170,7 @@ export default class FilterParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
const association = associations[firstKey];
|
||||
const associationKeys = [];
|
||||
|
||||
associationKeys.push(firstKey);
|
||||
@ -177,10 +181,17 @@ export default class FilterParser {
|
||||
|
||||
if (!existInclude) {
|
||||
// set sequelize include option
|
||||
_.set(include, firstKey, {
|
||||
let includeOptions = {
|
||||
association: firstKey,
|
||||
attributes: [], // out put empty fields by default
|
||||
});
|
||||
};
|
||||
if (association.associationType === 'BelongsToArray') {
|
||||
includeOptions = {
|
||||
...includeOptions,
|
||||
...(association as any as BelongsToArrayAssociation).generateInclude(),
|
||||
};
|
||||
}
|
||||
_.set(include, firstKey, includeOptions);
|
||||
}
|
||||
|
||||
// association target model
|
||||
|
@ -44,6 +44,7 @@ export * from './relation-repository/belongs-to-repository';
|
||||
export * from './relation-repository/hasmany-repository';
|
||||
export * from './relation-repository/multiple-relation-repository';
|
||||
export * from './relation-repository/single-relation-repository';
|
||||
export * from './relation-repository/belongs-to-array-repository';
|
||||
export * from './repository';
|
||||
export * from './update-associations';
|
||||
export { snakeCase } from './utils';
|
||||
|
@ -127,6 +127,13 @@ export class Model<TModelAttributes extends {} = any, TCreationAttributes extend
|
||||
|
||||
if (['HasMany', 'BelongsToMany'].includes(association.associationType)) {
|
||||
result[key] = handleArray(data[key], opts).map((item) => traverseJSON(item, opts));
|
||||
} else if (association.associationType === 'BelongsToArray') {
|
||||
const value = data[key];
|
||||
if (!value || value.some((v) => typeof v !== 'object')) {
|
||||
result[key] = value;
|
||||
} else {
|
||||
result[key] = handleArray(data[key], opts).map((item) => traverseJSON(item, opts));
|
||||
}
|
||||
} else {
|
||||
result[key] = data[key] ? traverseJSON(data[key], opts) : null;
|
||||
}
|
||||
|
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { Transactionable } from 'sequelize/types';
|
||||
import { Collection } from '../collection';
|
||||
import { transactionWrapperBuilder } from '../decorators/transaction-decorator';
|
||||
import { FindOptions } from '../repository';
|
||||
import { MultipleRelationRepository } from './multiple-relation-repository';
|
||||
import Database from '../database';
|
||||
import { Model } from '../model';
|
||||
|
||||
const transaction = transactionWrapperBuilder(function () {
|
||||
return this.collection.model.sequelize.transaction();
|
||||
});
|
||||
|
||||
export class BelongsToArrayAssociation {
|
||||
db: Database;
|
||||
associationType: string;
|
||||
source: Model;
|
||||
foreignKey: string;
|
||||
targetName: string;
|
||||
targetKey: string;
|
||||
identifierField: string;
|
||||
as: string;
|
||||
|
||||
constructor(options: {
|
||||
db: Database;
|
||||
source: Model;
|
||||
as: string;
|
||||
foreignKey: string;
|
||||
target: string;
|
||||
targetKey: string;
|
||||
}) {
|
||||
const { db, source, as, foreignKey, target, targetKey } = options;
|
||||
this.associationType = 'BelongsToArray';
|
||||
this.db = db;
|
||||
this.source = source;
|
||||
this.foreignKey = foreignKey;
|
||||
this.targetName = target;
|
||||
this.targetKey = targetKey;
|
||||
this.identifierField = 'undefined';
|
||||
this.as = as;
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this.db.getModel(this.targetName);
|
||||
}
|
||||
|
||||
generateInclude() {
|
||||
if (this.db.sequelize.getDialect() !== 'postgres') {
|
||||
throw new Error('Filtering by many to many (array) associations is only supported on postgres');
|
||||
}
|
||||
const targetCollection = this.db.getCollection(this.targetName);
|
||||
const targetField = targetCollection.getField(this.targetKey);
|
||||
const sourceCollection = this.db.getCollection(this.source.name);
|
||||
const foreignField = sourceCollection.getField(this.foreignKey);
|
||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||
const left = queryInterface.quoteIdentifiers(`${this.as}.${targetField.columnName()}`);
|
||||
const right = queryInterface.quoteIdentifiers(`${this.source.collection.name}.${foreignField.columnName()}`);
|
||||
return {
|
||||
on: this.db.sequelize.literal(`${left}=any(${right})`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BelongsToArrayRepository extends MultipleRelationRepository {
|
||||
private belongsToArrayAssociation: BelongsToArrayAssociation;
|
||||
|
||||
constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) {
|
||||
super(sourceCollection, association, sourceKeyValue);
|
||||
|
||||
this.belongsToArrayAssociation = this.association as any as BelongsToArrayAssociation;
|
||||
}
|
||||
|
||||
protected getInstance(options: Transactionable) {
|
||||
return this.sourceCollection.repository.findOne({
|
||||
filterByTk: this.sourceKeyValue,
|
||||
});
|
||||
}
|
||||
|
||||
@transaction()
|
||||
async find(options?: FindOptions): Promise<any> {
|
||||
const targetRepository = this.targetCollection.repository;
|
||||
const instance = await this.getInstance(options);
|
||||
const tks = instance.get(this.belongsToArrayAssociation.foreignKey);
|
||||
const targetKey = this.belongsToArrayAssociation.targetKey;
|
||||
|
||||
const addFilter = {
|
||||
[targetKey]: tks,
|
||||
};
|
||||
|
||||
if (options?.filterByTk) {
|
||||
addFilter[targetKey] = options.filterByTk;
|
||||
}
|
||||
|
||||
const findOptions = {
|
||||
...omit(options, ['filterByTk', 'where', 'values', 'attributes']),
|
||||
filter: {
|
||||
$and: [options.filter || {}, addFilter],
|
||||
},
|
||||
};
|
||||
|
||||
return await targetRepository.find(findOptions);
|
||||
}
|
||||
}
|
@ -45,6 +45,7 @@ import { HasOneRepository } from './relation-repository/hasone-repository';
|
||||
import { RelationRepository } from './relation-repository/relation-repository';
|
||||
import { updateAssociations, updateModelByValues } from './update-associations';
|
||||
import { UpdateGuard } from './update-guard';
|
||||
import { BelongsToArrayRepository } from './relation-repository/belongs-to-array-repository';
|
||||
|
||||
const debug = require('debug')('noco-database');
|
||||
|
||||
@ -188,6 +189,7 @@ class RelationRepositoryBuilder<R extends RelationRepository> {
|
||||
BelongsToMany: BelongsToManyRepository,
|
||||
HasMany: HasManyRepository,
|
||||
ArrayField: ArrayFieldRepository,
|
||||
BelongsToArray: BelongsToArrayRepository,
|
||||
};
|
||||
|
||||
constructor(collection: Collection, associationName: string) {
|
||||
@ -195,13 +197,17 @@ class RelationRepositoryBuilder<R extends RelationRepository> {
|
||||
this.associationName = associationName;
|
||||
this.association = this.collection.model.associations[this.associationName];
|
||||
|
||||
if (!this.association) {
|
||||
const field = collection.getField(associationName);
|
||||
if (field && field instanceof ArrayField) {
|
||||
this.association = {
|
||||
associationType: 'ArrayField',
|
||||
};
|
||||
}
|
||||
if (this.association) {
|
||||
return;
|
||||
}
|
||||
const field = collection.getField(associationName);
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
if (field instanceof ArrayField) {
|
||||
this.association = {
|
||||
associationType: 'ArrayField',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -40,6 +40,8 @@ const postgres = {
|
||||
polygon: 'json',
|
||||
circle: 'json',
|
||||
uuid: 'uuid',
|
||||
set: 'set',
|
||||
array: 'array',
|
||||
};
|
||||
|
||||
const mysql = {
|
||||
|
@ -10,7 +10,7 @@
|
||||
import { describe } from 'vitest';
|
||||
import ws from 'ws';
|
||||
|
||||
export { mockDatabase } from '@nocobase/database';
|
||||
export { mockDatabase, MockDatabase } from '@nocobase/database';
|
||||
export { default as supertest } from 'supertest';
|
||||
export * from './mockServer';
|
||||
|
||||
|
@ -250,7 +250,7 @@ test.describe('association constraints support selecting non-primary key fields
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByLabel(`action-Action.Link-Configure fields-collections-${collectionName}`).click();
|
||||
await page.getByRole('button', { name: 'plus Add field' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Many to many' }).click();
|
||||
await page.getByRole('menuitem', { name: /^Many to many$/ }).click();
|
||||
|
||||
await page.getByLabel('block-item-SourceKey-fields-').click();
|
||||
// sourceKey 选项符合预期
|
||||
|
@ -90,7 +90,7 @@ test.describe('table column & table', () => {
|
||||
await createColumnItem(page, 'JSON');
|
||||
await showSettingsMenu(page, 'JSON');
|
||||
},
|
||||
supportedOptions: ['Custom column title', 'Column width', 'Sortable', 'Delete'],
|
||||
supportedOptions: ['Custom column title', 'Column width', 'Delete'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -10,7 +10,21 @@
|
||||
/* istanbul ignore file -- @preserve */
|
||||
|
||||
const typeInterfaceMap = {
|
||||
array: '',
|
||||
array: () => {
|
||||
return {
|
||||
interface: 'json',
|
||||
uiSchema: {
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: {
|
||||
minRows: 5,
|
||||
// maxRows: 20,
|
||||
},
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
belongsTo: '',
|
||||
belongsToMany: '',
|
||||
boolean: () => {
|
||||
|
@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { Field, FilterParser } from '@nocobase/database';
|
||||
import { BelongsToArrayAssociation, Field, FilterParser } from '@nocobase/database';
|
||||
import { formatter } from './formatter';
|
||||
import compose from 'koa-compose';
|
||||
import { Cache } from '@nocobase/cache';
|
||||
@ -190,6 +190,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
|
||||
const db = getDB(ctx, dataSource) || ctx.db;
|
||||
const collection = db.getCollection(collectionName);
|
||||
const fields = collection.fields;
|
||||
const associations = collection.model.associations;
|
||||
const models: {
|
||||
[target: string]: {
|
||||
type: string;
|
||||
@ -234,11 +235,25 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
|
||||
const parsedMeasures = measures?.map(parseField) || [];
|
||||
const parsedDimensions = dimensions?.map(parseField) || [];
|
||||
const parsedOrders = orders?.map(parseField) || [];
|
||||
const include = Object.entries(models).map(([target, { type }]) => ({
|
||||
association: target,
|
||||
attributes: [],
|
||||
...(type === 'belongsToMany' ? { through: { attributes: [] } } : {}),
|
||||
}));
|
||||
const include = Object.entries(models).map(([target, { type }]) => {
|
||||
let options = {
|
||||
association: target,
|
||||
attributes: [],
|
||||
};
|
||||
if (type === 'belongsToMany') {
|
||||
options['through'] = { attributes: [] };
|
||||
}
|
||||
if (type === 'belongsToArray') {
|
||||
const association = associations[target] as BelongsToArrayAssociation;
|
||||
if (association) {
|
||||
options = {
|
||||
...options,
|
||||
...association.generateInclude(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const filterParser = new FilterParser(filter, {
|
||||
collection,
|
||||
|
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-field-record-set
|
2
packages/plugins/@nocobase/plugin-field-m2m-array/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-field-m2m-array/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-field-m2m-array",
|
||||
"displayName": "Collection field: Many to many (array)",
|
||||
"displayName.zh-CN": "数据表字段:多对多 (数组)",
|
||||
"version": "1.3.0-alpha",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "1.x",
|
||||
"@nocobase/server": "1.x",
|
||||
"@nocobase/test": "1.x"
|
||||
},
|
||||
"keywords": [
|
||||
"Collection fields"
|
||||
]
|
||||
}
|
2
packages/plugins/@nocobase/plugin-field-m2m-array/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-field-m2m-array/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { observer, useField } from '@formily/react';
|
||||
import { AutoComplete, Select } from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecord, useCompile } from '@nocobase/client';
|
||||
import { useMBMFields } from './hooks';
|
||||
|
||||
export const ForeignKey = observer(
|
||||
(props: any) => {
|
||||
const { disabled } = props;
|
||||
const [options, setOptions] = useState([]);
|
||||
const record = useRecord();
|
||||
const field: any = useField();
|
||||
const { type, template } = record;
|
||||
const value = record[field.props.name];
|
||||
const compile = useCompile();
|
||||
const [initialValue, setInitialValue] = useState(value || (template === 'view' ? null : field.initialValue));
|
||||
const { foreignKeys } = useMBMFields();
|
||||
useEffect(() => {
|
||||
const fields = foreignKeys;
|
||||
if (fields) {
|
||||
const sourceOptions = fields.map((k) => {
|
||||
return {
|
||||
value: k.name,
|
||||
label: compile(k.uiSchema?.title || k.name),
|
||||
};
|
||||
});
|
||||
setOptions(sourceOptions);
|
||||
if (value) {
|
||||
const option = sourceOptions.find((v) => v.value === value);
|
||||
setInitialValue(option?.label || value);
|
||||
}
|
||||
}
|
||||
}, [type]);
|
||||
const Component = template === 'view' ? Select : AutoComplete;
|
||||
return (
|
||||
<div>
|
||||
<Component
|
||||
disabled={disabled}
|
||||
value={initialValue}
|
||||
options={options}
|
||||
showSearch
|
||||
onDropdownVisibleChange={async (open) => {
|
||||
const fields = foreignKeys;
|
||||
if (fields && open) {
|
||||
setOptions(
|
||||
fields.map((k) => {
|
||||
return {
|
||||
value: k.name,
|
||||
label: compile(k.uiSchema?.title || k.name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(value, option) => {
|
||||
props?.onChange?.(value);
|
||||
setInitialValue(option.label || value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ displayName: 'MBMForeignKey' },
|
||||
);
|
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { observer, useField } from '@formily/react';
|
||||
import { useRecord, useCompile } from '@nocobase/client';
|
||||
import { useMBMFields } from './hooks';
|
||||
|
||||
export const TargetKey = observer(
|
||||
(props: any) => {
|
||||
const { value, disabled } = props;
|
||||
const { targetKey } = useRecord();
|
||||
const [options, setOptions] = useState([]);
|
||||
const [initialValue, setInitialValue] = useState(value || targetKey);
|
||||
const compile = useCompile();
|
||||
const field: any = useField();
|
||||
field.required = true;
|
||||
const { targetKeys } = useMBMFields();
|
||||
useEffect(() => {
|
||||
if (targetKeys) {
|
||||
setOptions(
|
||||
targetKeys.map((k) => {
|
||||
return {
|
||||
value: k.name,
|
||||
label: compile(k?.uiSchema?.title || k.title || k.name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [targetKeys]);
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
showSearch
|
||||
options={options}
|
||||
onDropdownVisibleChange={async (open) => {
|
||||
if (targetKeys && open) {
|
||||
setOptions(
|
||||
targetKeys.map((k) => {
|
||||
return {
|
||||
value: k.name,
|
||||
label: compile(k?.uiSchema?.title || k.title || k.name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
props?.onChange?.(value);
|
||||
setInitialValue(value);
|
||||
}}
|
||||
value={initialValue}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ displayName: 'MBMTargetKey' },
|
||||
);
|
249
packages/plugins/@nocobase/plugin-field-m2m-array/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-field-m2m-array/src/client/client.d.ts
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// CSS modules
|
||||
type CSSModuleClasses = { readonly [key: string]: string };
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.scss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sass' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.less' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.styl' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.stylus' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.pcss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
// CSS
|
||||
declare module '*.css' { }
|
||||
declare module '*.scss' { }
|
||||
declare module '*.sass' { }
|
||||
declare module '*.less' { }
|
||||
declare module '*.styl' { }
|
||||
declare module '*.stylus' { }
|
||||
declare module '*.pcss' { }
|
||||
declare module '*.sss' { }
|
||||
|
||||
// Built-in asset types
|
||||
// see `src/node/constants.ts`
|
||||
|
||||
// images
|
||||
declare module '*.apng' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jfif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.opus' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mov' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.m4a' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.vtt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.webmanifest' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// wasm?init
|
||||
declare module '*.wasm?init' {
|
||||
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
|
||||
export default initWasm;
|
||||
}
|
||||
|
||||
// web worker
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&inline' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&inline' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?inline' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useForm } from '@formily/react';
|
||||
import { useRecord, useCollectionManager_deprecated } from '@nocobase/client';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export const useMBMFields = () => {
|
||||
const { collectionName, name } = useRecord();
|
||||
const { name: dataSourceKey } = useParams();
|
||||
const { getCollection } = useCollectionManager_deprecated();
|
||||
const form = useForm();
|
||||
const { target } = form.values || {};
|
||||
|
||||
const collectionFields = useMemo(() => {
|
||||
return getCollection(collectionName || name, dataSourceKey)?.fields;
|
||||
}, [collectionName, dataSourceKey]);
|
||||
const targetFields = useMemo(() => {
|
||||
return getCollection(target, dataSourceKey)?.fields;
|
||||
}, [target, dataSourceKey]);
|
||||
|
||||
const targetKeys = useMemo(
|
||||
() =>
|
||||
targetFields?.filter((f) => {
|
||||
const isTarget = (f.primaryKey || f.unique) && f.interface;
|
||||
return isTarget;
|
||||
}),
|
||||
[targetFields],
|
||||
);
|
||||
|
||||
const foreignKeys = useMemo(() => {
|
||||
return collectionFields?.filter((f) => {
|
||||
const isArray = ['set', 'array'].includes(f.type) && f.interface === 'json';
|
||||
return isArray;
|
||||
});
|
||||
}, [collectionFields]);
|
||||
|
||||
return { targetKeys, foreignKeys };
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/client';
|
||||
import { MBMFieldInterface } from './mbm';
|
||||
import { ForeignKey } from './ForeignKey';
|
||||
import { TargetKey } from './TargetKey';
|
||||
|
||||
export class PluginM2MArrayClient extends Plugin {
|
||||
async afterAdd() {
|
||||
// await this.app.pm.add()
|
||||
}
|
||||
|
||||
async beforeLoad() {}
|
||||
|
||||
// You can get and modify the app instance here
|
||||
async load() {
|
||||
this.app.addComponents({
|
||||
MBMForeignKey: ForeignKey,
|
||||
MBMTargetKey: TargetKey,
|
||||
});
|
||||
this.app.dataSourceManager.addFieldInterfaces([MBMFieldInterface]);
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginM2MArrayClient;
|
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const NAMESPACE = 'field-m2m-array';
|
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { ISchema } from '@formily/react';
|
||||
import { Collection, CollectionFieldInterface } from '@nocobase/client';
|
||||
import { tval } from '@nocobase/utils/client';
|
||||
import { NAMESPACE } from './locale';
|
||||
|
||||
function getUniqueKeyFromCollection(collection: Collection) {
|
||||
return collection?.filterTargetKey || collection?.getPrimaryKey() || 'id';
|
||||
}
|
||||
|
||||
export class MBMFieldInterface extends CollectionFieldInterface {
|
||||
name = 'mbm';
|
||||
type = 'object';
|
||||
group = 'relation';
|
||||
order = 6;
|
||||
title = tval('Many to many (array)', { ns: NAMESPACE });
|
||||
description = tval('Many to many (array) description', { ns: NAMESPACE });
|
||||
isAssociation = true;
|
||||
default = {
|
||||
type: 'belongsToArray',
|
||||
// name,
|
||||
uiSchema: {
|
||||
// title,
|
||||
'x-component': 'AssociationField',
|
||||
'x-component-props': {
|
||||
// mode: 'tags',
|
||||
multiple: true,
|
||||
// fieldNames: {
|
||||
// label: 'id',
|
||||
// value: 'id',
|
||||
// },
|
||||
},
|
||||
},
|
||||
};
|
||||
availableTypes = ['belongsToArray'];
|
||||
schemaInitialize(schema: ISchema, { field, block, readPretty, targetCollection }) {
|
||||
// schema['type'] = 'array';
|
||||
schema['x-component-props'] = schema['x-component-props'] || {};
|
||||
schema['x-component-props'].fieldNames = schema['x-component-props'].fieldNames || {
|
||||
value: getUniqueKeyFromCollection(targetCollection),
|
||||
};
|
||||
schema['x-component-props'].fieldNames.label =
|
||||
schema['x-component-props'].fieldNames?.label ||
|
||||
targetCollection?.titleField ||
|
||||
getUniqueKeyFromCollection(targetCollection);
|
||||
if (['Table', 'Kanban'].includes(block)) {
|
||||
schema['x-component-props'] = schema['x-component-props'] || {};
|
||||
schema['x-component-props']['ellipsis'] = true;
|
||||
// 预览文件时需要的参数
|
||||
schema['x-component-props']['size'] = 'small';
|
||||
}
|
||||
}
|
||||
properties = {
|
||||
'uiSchema.title': {
|
||||
type: 'string',
|
||||
title: '{{t("Field display name")}}',
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
title: '{{t("Field name")}}',
|
||||
required: true,
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
description:
|
||||
"{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
|
||||
},
|
||||
grid: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
properties: {
|
||||
row1: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
col11: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'void',
|
||||
title: '{{t("Source collection")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'SourceCollection',
|
||||
},
|
||||
},
|
||||
},
|
||||
col12: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
title: '{{t("Target collection")}}',
|
||||
required: true,
|
||||
'x-reactions': ['{{useAsyncDataSource(loadCollections, ["file"])}}'],
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
row2: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
properties: {
|
||||
col21: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
foreignKey: {
|
||||
type: 'string',
|
||||
title: '{{t("Foreign key")}}',
|
||||
required: true,
|
||||
default: '{{ useNewId("f_") }}',
|
||||
description:
|
||||
"{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'MBMForeignKey',
|
||||
'x-validator': 'uid',
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
col22: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
properties: {
|
||||
targetKey: {
|
||||
type: 'string',
|
||||
title: '{{t("Target key")}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'MBMTargetKey',
|
||||
'x-disabled': '{{ !createOnly }}',
|
||||
description: "{{t('Field values must be unique.')}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
filterable = {
|
||||
nested: true,
|
||||
children: [
|
||||
// {
|
||||
// name: 'id',
|
||||
// title: '{{t("Exists")}}',
|
||||
// operators: [
|
||||
// { label: '{{t("exists")}}', value: '$exists', noValue: true },
|
||||
// { label: '{{t("not exists")}}', value: '$notExists', noValue: true },
|
||||
// ],
|
||||
// schema: {
|
||||
// title: '{{t("Exists")}}',
|
||||
// type: 'string',
|
||||
// 'x-component': 'Input',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
};
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './server';
|
||||
export { default } from './server';
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Many to many (array)": "Many to many (array)",
|
||||
"Many to many (array) description": "Allows to create many to many relationships between two models by storing an array of unique keys of the target model."
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Many to many (array)": "多对多(数组)",
|
||||
"Many to many (array) description": "支持通过在数组中存储目标表唯一键的方式建立多对多关系。"
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
|
||||
import { Repository } from '@nocobase/database';
|
||||
|
||||
describe('belongs to array field', () => {
|
||||
let app: MockServer;
|
||||
let db: MockDatabase;
|
||||
let fieldRepo: Repository;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['field-m2m-array', 'data-source-manager', 'data-source-main', 'error-handler'],
|
||||
});
|
||||
db = app.db;
|
||||
fieldRepo = db.getRepository('fields');
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'tags',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'stringCode',
|
||||
type: 'string',
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'users',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'tag_ids',
|
||||
type: 'set',
|
||||
dataType: 'array',
|
||||
elementType: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
await db.getRepository('collections').load();
|
||||
await db.sync();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
describe('association keys check', async () => {
|
||||
it('targetKey is required', async () => {
|
||||
try {
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
const field = await fieldRepo.create({
|
||||
values: {
|
||||
interface: 'mbm',
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await field.load({ transaction });
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.message).toContain('Target key is required');
|
||||
}
|
||||
});
|
||||
|
||||
it('foreign field must be an array or set field', async () => {
|
||||
try {
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
const field = await fieldRepo.create({
|
||||
values: {
|
||||
interface: 'mbm',
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'username',
|
||||
target: 'tags',
|
||||
targetKey: 'id',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await field.load({ transaction });
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.message).toContain(
|
||||
'The type of foreign key "username" in collection "users" must be ARRAY, JSON or JSONB',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('element type of foreign field must be match the type of target field', async () => {
|
||||
if (db.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
const field = await fieldRepo.create({
|
||||
values: {
|
||||
interface: 'mbm',
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
targetKey: 'id',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await field.load({ transaction });
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.message).toContain(
|
||||
'The element type "STRING" of foreign key "tag_ids" does not match the type "BIGINT" of target key "id" in collection "tags"',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
db.sequelize.transaction(async (transaction) => {
|
||||
const field = await fieldRepo.create({
|
||||
values: {
|
||||
interface: 'mbm',
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
targetKey: 'stringCode',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await field.load({ transaction });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,352 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { BelongsToArrayRepository } from '@nocobase/database';
|
||||
|
||||
describe('m2m array api, bigInt targetKey', () => {
|
||||
let app: MockServer;
|
||||
let db: MockDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['field-m2m-array', 'data-source-manager', 'data-source-main', 'error-handler'],
|
||||
});
|
||||
db = app.db;
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'tags',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'users',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
targetKey: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
await db.getRepository('collections').load();
|
||||
await db.sync();
|
||||
await db.getRepository('tags').create({
|
||||
values: [{ title: 'a' }, { title: 'b' }, { title: 'c' }],
|
||||
});
|
||||
await db.getRepository('users').create({
|
||||
values: [
|
||||
{ id: 1, username: 'a', tag_ids: [1, 2] },
|
||||
{ id: 2, username: 'b', tag_ids: [2, 3] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should create foreign key array', async () => {
|
||||
const field = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tag_ids',
|
||||
},
|
||||
});
|
||||
expect(field).toBeTruthy();
|
||||
expect(field.type).toBe('set');
|
||||
expect(field.options.dataType).toBe('array');
|
||||
expect(field.options.elementType).toBe('bigInt');
|
||||
const fieldModel = db.getCollection('users').getField('tag_ids');
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(fieldModel.dataType).toEqual(DataTypes.ARRAY(DataTypes.BIGINT));
|
||||
} else {
|
||||
expect(fieldModel.dataType).toEqual(DataTypes.JSON);
|
||||
}
|
||||
});
|
||||
|
||||
it('should destroy relation field when destorying foreign key array', async () => {
|
||||
await db.getRepository('fields').destroy({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tag_ids',
|
||||
},
|
||||
});
|
||||
const relationField = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
},
|
||||
});
|
||||
expect(relationField).toBeNull();
|
||||
});
|
||||
|
||||
describe('api', () => {
|
||||
it('should list appends belongsToArray', async () => {
|
||||
const users = await db.getRepository('users').find();
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(users).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: ['1', '2'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'b',
|
||||
tag_ids: ['2', '3'],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
expect(users).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: [1, 2],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'b',
|
||||
tag_ids: [2, 3],
|
||||
},
|
||||
]);
|
||||
}
|
||||
const users2 = await db.getRepository('users').find({
|
||||
appends: ['tags'],
|
||||
});
|
||||
expect(users2).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tags: [
|
||||
{ id: 1, title: 'a' },
|
||||
{ id: 2, title: 'b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'b',
|
||||
tags: [
|
||||
{ id: 2, title: 'b' },
|
||||
{ id: 3, title: 'c' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get appends belongsToArray', async () => {
|
||||
const users = await db.getRepository('users').findOne({ filterByTk: 1 });
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(users).toMatchObject({
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: ['1', '2'],
|
||||
});
|
||||
} else {
|
||||
expect(users).toMatchObject({
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: [1, 2],
|
||||
});
|
||||
}
|
||||
const users2 = await db.getRepository('users').findOne({
|
||||
filterByTk: 1,
|
||||
appends: ['tags'],
|
||||
});
|
||||
expect(users2).toMatchObject({
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tags: [
|
||||
{ id: 1, title: 'a' },
|
||||
{ id: 2, title: 'b' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter with the fields of belongsToArray', async () => {
|
||||
const search = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
$includes: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search;
|
||||
expect(res.length).toBe(1);
|
||||
} else {
|
||||
expect(search).rejects.toThrowError();
|
||||
}
|
||||
if (db.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
const search2 = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
$includes: ['b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search2;
|
||||
expect(res.length).toBe(2);
|
||||
} else {
|
||||
expect(search2).rejects.toThrowError();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create with belongsToArray', async () => {
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 3,
|
||||
username: 'c',
|
||||
tags: [{ id: 1 }, { id: 3 }],
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(user.tag_ids).toMatchObject(['1', '3']);
|
||||
} else {
|
||||
expect(user.tag_ids).toMatchObject([1, 3]);
|
||||
}
|
||||
const user2 = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 4,
|
||||
username: 'd',
|
||||
tags: [1, 3],
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(user2.tag_ids).toMatchObject(['1', '3']);
|
||||
} else {
|
||||
expect(user2.tag_ids).toMatchObject([1, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create target when creating belongsToArray', async () => {
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 5,
|
||||
username: 'e',
|
||||
tags: [{ title: 'd' }],
|
||||
},
|
||||
});
|
||||
expect(user.tag_ids).toBeDefined();
|
||||
expect(user.tag_ids.length).toBe(1);
|
||||
const tagId = user.tag_ids[0];
|
||||
const tag = await db.getRepository('tags').findOne({
|
||||
filterByTk: tagId,
|
||||
});
|
||||
expect(tag).not.toBeNull();
|
||||
expect(tag.title).toBe('d');
|
||||
});
|
||||
|
||||
it('should update with belongsToArray', async () => {
|
||||
let user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 6,
|
||||
username: 'f',
|
||||
tags: [1, 3],
|
||||
},
|
||||
});
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: [2, 3],
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject([2, 3]);
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: [{ id: 1 }, { id: 3 }],
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject([1, 3]);
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: null,
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('should create target when updating belongsToArray', async () => {
|
||||
let user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 7,
|
||||
username: 'g',
|
||||
},
|
||||
});
|
||||
expect(user.tag_ids).toBeFalsy();
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 7,
|
||||
values: {
|
||||
tags: [{ title: 'e' }],
|
||||
},
|
||||
});
|
||||
user = user[0];
|
||||
expect(user.tag_ids).toBeDefined();
|
||||
expect(user.tag_ids.length).toBe(1);
|
||||
const tagId = user.tag_ids[0];
|
||||
const tag = await db.getRepository('tags').findOne({
|
||||
filterByTk: tagId,
|
||||
});
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag.title).toBe('e');
|
||||
});
|
||||
|
||||
it('should list belongsToArray using relation', async () => {
|
||||
const repo = db.getRepository('users.tags', 1) as BelongsToArrayRepository;
|
||||
const tags = await repo.find();
|
||||
expect(tags).toMatchObject([
|
||||
{ id: 1, title: 'a' },
|
||||
{ id: 2, title: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get belongsToArray using relation', async () => {
|
||||
const repo = db.getRepository('users.tags', 1) as BelongsToArrayRepository;
|
||||
const tags = await repo.findOne({
|
||||
filterByTk: 1,
|
||||
});
|
||||
expect(tags).toMatchObject({ id: 1, title: 'a' });
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,327 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { BelongsToArrayRepository } from '@nocobase/database';
|
||||
|
||||
describe('m2m array api, string targetKey', () => {
|
||||
let app: MockServer;
|
||||
let db: MockDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['field-m2m-array', 'data-source-manager', 'data-source-main', 'error-handler'],
|
||||
});
|
||||
db = app.db;
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'tags',
|
||||
fields: [
|
||||
{
|
||||
name: 'stringCode',
|
||||
type: 'string',
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'users',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
targetKey: 'stringCode',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
await db.getRepository('collections').load();
|
||||
await db.sync();
|
||||
await db.getRepository('tags').create({
|
||||
values: [
|
||||
{ stringCode: 'a', title: 'a' },
|
||||
{ stringCode: 'b', title: 'b' },
|
||||
{ stringCode: 'c', title: 'c' },
|
||||
],
|
||||
});
|
||||
await db.getRepository('users').create({
|
||||
values: [
|
||||
{ id: 1, username: 'a', tag_ids: ['a', 'b'] },
|
||||
{ id: 2, username: 'b', tag_ids: ['b', 'c'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.clean({ drop: true });
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should create foreign key array', async () => {
|
||||
const field = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tag_ids',
|
||||
},
|
||||
});
|
||||
expect(field).toBeDefined();
|
||||
expect(field.type).toBe('set');
|
||||
expect(field.options.dataType).toBe('array');
|
||||
expect(field.options.elementType).toBe('string');
|
||||
const fieldModel = db.getCollection('users').getField('tag_ids');
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
expect(fieldModel.dataType).toEqual(DataTypes.ARRAY(DataTypes.STRING));
|
||||
} else {
|
||||
expect(fieldModel.dataType).toEqual(DataTypes.JSON);
|
||||
}
|
||||
});
|
||||
|
||||
it('should destroy relation field when destorying foreign key array', async () => {
|
||||
await db.getRepository('fields').destroy({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tag_ids',
|
||||
},
|
||||
});
|
||||
const relationField = await db.getRepository('fields').findOne({
|
||||
filter: {
|
||||
collectionName: 'users',
|
||||
name: 'tags',
|
||||
},
|
||||
});
|
||||
expect(relationField).toBeNull();
|
||||
});
|
||||
|
||||
describe('api', () => {
|
||||
it('should list appends belongsToArray', async () => {
|
||||
const users = await db.getRepository('users').find();
|
||||
expect(users).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: ['a', 'b'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'b',
|
||||
tag_ids: ['b', 'c'],
|
||||
},
|
||||
]);
|
||||
const users2 = await db.getRepository('users').find({
|
||||
appends: ['tags'],
|
||||
});
|
||||
expect(users2).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tags: [
|
||||
{ stringCode: 'a', title: 'a' },
|
||||
{ stringCode: 'b', title: 'b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'b',
|
||||
tags: [
|
||||
{ stringCode: 'b', title: 'b' },
|
||||
{ stringCode: 'c', title: 'c' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get appends belongsToArray', async () => {
|
||||
const users = await db.getRepository('users').findOne({ filterByTk: 1 });
|
||||
expect(users).toMatchObject({
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tag_ids: ['a', 'b'],
|
||||
});
|
||||
const users2 = await db.getRepository('users').findOne({
|
||||
filterByTk: 1,
|
||||
appends: ['tags'],
|
||||
});
|
||||
expect(users2).toMatchObject({
|
||||
id: 1,
|
||||
username: 'a',
|
||||
tags: [
|
||||
{ stringCode: 'a', title: 'a' },
|
||||
{ stringCode: 'b', title: 'b' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter with the fields of belongsToArray', async () => {
|
||||
const search = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
$includes: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search;
|
||||
expect(res.length).toBe(1);
|
||||
} else {
|
||||
expect(search).rejects.toThrowError();
|
||||
}
|
||||
if (db.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
const search2 = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
$includes: ['b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search2;
|
||||
expect(res.length).toBe(2);
|
||||
} else {
|
||||
expect(search2).rejects.toThrowError();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create with belongsToArray', async () => {
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 3,
|
||||
username: 'c',
|
||||
tags: [{ stringCode: 'a' }, { stringCode: 'c' }],
|
||||
},
|
||||
});
|
||||
expect(user.tag_ids).toMatchObject(['a', 'c']);
|
||||
const user2 = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 4,
|
||||
username: 'd',
|
||||
tags: ['a', 'c'],
|
||||
},
|
||||
});
|
||||
expect(user2.tag_ids).toMatchObject(['a', 'c']);
|
||||
});
|
||||
|
||||
it('should create target when creating belongsToArray', async () => {
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 5,
|
||||
username: 'e',
|
||||
tags: [{ stringCode: 'd', title: 'd' }],
|
||||
},
|
||||
});
|
||||
expect(user.tag_ids).toBeDefined();
|
||||
expect(user.tag_ids.length).toBe(1);
|
||||
const tagCode = user.tag_ids[0];
|
||||
const tag = await db.getRepository('tags').findOne({
|
||||
filter: {
|
||||
stringCode: tagCode,
|
||||
},
|
||||
});
|
||||
expect(tag).not.toBeNull();
|
||||
expect(tag.title).toBe('d');
|
||||
});
|
||||
|
||||
it('should update with belongsToArray', async () => {
|
||||
let user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 6,
|
||||
username: 'f',
|
||||
tags: ['a', 'c'],
|
||||
},
|
||||
});
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: ['b', 'c'],
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject(['b', 'c']);
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: [{ stringCode: 'a' }, { stringCode: 'c' }],
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject(['a', 'c']);
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 6,
|
||||
values: {
|
||||
tags: null,
|
||||
},
|
||||
});
|
||||
expect(user[0].tag_ids).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('should create target when updating belongsToArray', async () => {
|
||||
let user = await db.getRepository('users').create({
|
||||
values: {
|
||||
id: 7,
|
||||
username: 'g',
|
||||
},
|
||||
});
|
||||
expect(user.tag_ids).toBeFalsy();
|
||||
user = await db.getRepository('users').update({
|
||||
filterByTk: 7,
|
||||
values: {
|
||||
tags: [{ stringCode: 'e', title: 'e' }],
|
||||
},
|
||||
});
|
||||
user = user[0];
|
||||
expect(user.tag_ids).toBeDefined();
|
||||
expect(user.tag_ids.length).toBe(1);
|
||||
const tagCode = user.tag_ids[0];
|
||||
const tag = await db.getRepository('tags').findOne({
|
||||
filter: {
|
||||
stringCode: tagCode,
|
||||
},
|
||||
});
|
||||
expect(tag).toBeDefined();
|
||||
expect(tag.title).toBe('e');
|
||||
});
|
||||
|
||||
it('should list belongsToArray using relation', async () => {
|
||||
const repo = db.getRepository('users.tags', 1) as BelongsToArrayRepository;
|
||||
const tags = await repo.find();
|
||||
expect(tags).toMatchObject([
|
||||
{ stringCode: 'a', title: 'a' },
|
||||
{ stringCode: 'b', title: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get belongsToArray using relation', async () => {
|
||||
const repo = db.getRepository('users.tags', 1) as BelongsToArrayRepository;
|
||||
const tags = await repo.findOne({
|
||||
filterByTk: 'a',
|
||||
});
|
||||
expect(tags).toMatchObject({ stringCode: 'a', title: 'a' });
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { BaseColumnFieldOptions, BelongsToArrayAssociation, Model, RelationField } from '@nocobase/database';
|
||||
|
||||
export const elementTypeMap = {
|
||||
nanoid: 'string',
|
||||
sequence: 'string',
|
||||
};
|
||||
|
||||
export class BelongsToArrayField extends RelationField {
|
||||
get dataType() {
|
||||
return 'BelongsToArray';
|
||||
}
|
||||
|
||||
private setForeignKeyArray = async (model: Model, { values, transaction }) => {
|
||||
const { name, foreignKey, target, targetKey } = this.options;
|
||||
if (!values || values[name] === undefined) {
|
||||
return;
|
||||
}
|
||||
const value: any[] = values[name] || [];
|
||||
const tks = [];
|
||||
const items = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'object') {
|
||||
tks.push(item);
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
const repo = this.database.getRepository(target);
|
||||
const itemTks = items.map((item) => item[targetKey]).filter((tk) => tk);
|
||||
const instances = await repo.find({
|
||||
filter: {
|
||||
[targetKey]: itemTks,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
tks.push(...instances.map((instance: Model) => instance[targetKey]));
|
||||
const toCreate = items.filter((item) => !item[targetKey] || !tks.includes(item[targetKey]));
|
||||
const m = this.database.getModel(target);
|
||||
const newInstances = await m.bulkCreate(toCreate, { transaction });
|
||||
tks.push(...newInstances.map((instance: Model) => instance[targetKey]));
|
||||
model.set(foreignKey, tks);
|
||||
};
|
||||
|
||||
init() {
|
||||
super.init();
|
||||
const { name, ...opts } = this.options;
|
||||
this.collection.model.associations[name] = new BelongsToArrayAssociation({
|
||||
db: this.database,
|
||||
source: this.collection.model,
|
||||
as: name,
|
||||
...opts,
|
||||
}) as any;
|
||||
}
|
||||
|
||||
checkTargetCollection() {
|
||||
const { target } = this.options;
|
||||
if (!target) {
|
||||
throw new Error('Target is required in the options of many to many (array) field.');
|
||||
}
|
||||
const targetCollection = this.database.getCollection(target);
|
||||
if (!targetCollection) {
|
||||
this.database.addPendingField(this);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
checkAssociationKeys() {
|
||||
const { foreignKey, target, targetKey } = this.options;
|
||||
|
||||
if (!targetKey) {
|
||||
throw new Error('Target key is required in the options of many to many (array) field.');
|
||||
}
|
||||
|
||||
const targetField = this.database.getModel(target).getAttributes()[targetKey];
|
||||
const foreignField = this.collection.model.getAttributes()[foreignKey];
|
||||
if (!foreignField || !targetField) {
|
||||
return;
|
||||
}
|
||||
const foreignType = foreignField.type.constructor.toString();
|
||||
if (!['ARRAY', 'JSONTYPE', 'JSON', 'JSONB'].includes(foreignType)) {
|
||||
throw new Error(
|
||||
`The type of foreign key "${foreignKey}" in collection "${this.collection.name}" must be ARRAY, JSON or JSONB`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.database.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetType = targetField.type.constructor.toString();
|
||||
const elementType = (foreignField.type as any).type.constructor.toString();
|
||||
if (foreignType === 'ARRAY' && elementType !== targetType) {
|
||||
throw new Error(
|
||||
`The element type "${elementType}" of foreign key "${foreignKey}" does not match the type "${targetType}" of target key "${targetKey}" in collection "${target}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bind() {
|
||||
if (!this.checkTargetCollection()) {
|
||||
return false;
|
||||
}
|
||||
this.checkAssociationKeys();
|
||||
this.on('beforeSave', this.setForeignKeyArray);
|
||||
}
|
||||
|
||||
unbind() {
|
||||
delete this.collection.model.associations[this.name];
|
||||
this.off('beforeSave', this.setForeignKeyArray);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BelongsToArrayFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'belongsToArray';
|
||||
foreignKey: string;
|
||||
target: string;
|
||||
targetKey: string;
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
|
||||
export function beforeDestroyForeignKey(db: Database) {
|
||||
return async (model: Model, { transaction }) => {
|
||||
const { isForeignKey, collectionName, name: fkName, type } = model.get();
|
||||
|
||||
if (!isForeignKey || type !== 'set') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldKeys = [];
|
||||
const collection = db.getCollection(collectionName);
|
||||
|
||||
for (const [, field] of collection.fields) {
|
||||
const fieldKey = field.options?.key;
|
||||
if (!fieldKey || field.type !== 'belongsToArray' || field.foreignKey !== fkName) {
|
||||
continue;
|
||||
}
|
||||
fieldKeys.push(fieldKey);
|
||||
}
|
||||
|
||||
const r = db.getRepository('fields');
|
||||
|
||||
await r.destroy({
|
||||
filter: {
|
||||
'key.$in': fieldKeys,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
};
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
import { elementTypeMap } from '../belongs-to-array-field';
|
||||
|
||||
export const createForeignKey = (db: Database) => {
|
||||
return async (model: Model, { transaction }) => {
|
||||
const { type, collectionName, target, targetKey, foreignKey } = model.get();
|
||||
if (type !== 'belongsToArray') {
|
||||
return;
|
||||
}
|
||||
const r = db.getRepository('fields');
|
||||
const instance = await r.findOne({
|
||||
filter: {
|
||||
collectionName,
|
||||
name: foreignKey,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (!instance) {
|
||||
const targetField = await r.findOne({
|
||||
filter: {
|
||||
collectionName: target,
|
||||
name: targetKey,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (!targetField) {
|
||||
throw new Error(`${target}.${targetKey} not found`);
|
||||
}
|
||||
const field = await r.create({
|
||||
values: {
|
||||
interface: 'json',
|
||||
collectionName,
|
||||
name: foreignKey,
|
||||
type: 'set',
|
||||
dataType: 'array',
|
||||
elementType: elementTypeMap[targetField.type] || targetField.type,
|
||||
isForeignKey: true,
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
title: foreignKey,
|
||||
'x-component': 'Input.JSON',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await field.load({ transaction });
|
||||
}
|
||||
};
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { default } from './plugin';
|
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { BelongsToArrayField } from './belongs-to-array-field';
|
||||
import { createForeignKey } from './hooks/create-foreign-key';
|
||||
import { beforeDestroyForeignKey } from './hooks/before-destroy-foreign-key';
|
||||
import { DataSource, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
|
||||
export class PluginFieldM2MArrayServer extends Plugin {
|
||||
async afterAdd() {}
|
||||
|
||||
async beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
this.app.dataSourceManager.beforeAddDataSource((dataSource: DataSource) => {
|
||||
const collectionManager = dataSource.collectionManager;
|
||||
if (collectionManager instanceof SequelizeCollectionManager) {
|
||||
collectionManager.registerFieldTypes({
|
||||
belongsToArray: BelongsToArrayField,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.db.on('fields.afterCreate', createForeignKey(this.db));
|
||||
this.db.on('fields.beforeDestroy', beforeDestroyForeignKey(this.db));
|
||||
}
|
||||
|
||||
async install() {}
|
||||
|
||||
async afterEnable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default PluginFieldM2MArrayServer;
|
@ -307,7 +307,7 @@ const PortsCom = React.memo<any>(({ targetGraph, collectionData, setTargetNode,
|
||||
if (
|
||||
v.isForeignKey ||
|
||||
v.primaryKey ||
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo', 'id'].includes(v.interface)
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo', 'id', 'mbm'].includes(v.interface)
|
||||
) {
|
||||
return 'initPorts';
|
||||
} else {
|
||||
|
@ -85,7 +85,7 @@ export const formatData = (data) => {
|
||||
group: 'list',
|
||||
...field,
|
||||
});
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo'].includes(field.interface) && edgeData.push(field);
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo', 'mbm'].includes(field.interface) && edgeData.push(field);
|
||||
});
|
||||
|
||||
targetTableKeys.push(item.name);
|
||||
@ -111,7 +111,7 @@ export const formatPortData = (ports) => {
|
||||
if (
|
||||
v.isForeignKey ||
|
||||
v.primaryKey ||
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo', 'id'].includes(v.interface)
|
||||
['obo', 'oho', 'o2o', 'o2m', 'm2o', 'm2m', 'linkTo', 'id', 'mbm'].includes(v.interface)
|
||||
) {
|
||||
return 'initPorts';
|
||||
} else {
|
||||
@ -470,6 +470,8 @@ const getRelationship = (relatioship) => {
|
||||
case 'obo':
|
||||
case 'oho':
|
||||
return ['1', '1'];
|
||||
case 'mbm':
|
||||
return ['N', 'N'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
@ -294,7 +294,7 @@ function matchFieldType(field, type: VariableDataType): boolean {
|
||||
}
|
||||
|
||||
function isAssociationField(field): boolean {
|
||||
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
|
||||
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'].includes(field.type);
|
||||
}
|
||||
|
||||
function getNextAppends(field, appends: string[] | null): string[] | null {
|
||||
|
@ -33,6 +33,7 @@
|
||||
"@nocobase/plugin-field-formula": "1.3.0-alpha",
|
||||
"@nocobase/plugin-field-markdown-vditor": "1.3.0-alpha",
|
||||
"@nocobase/plugin-field-sequence": "1.3.0-alpha",
|
||||
"@nocobase/plugin-field-m2m-array": "1.3.0-alpha",
|
||||
"@nocobase/plugin-file-manager": "1.3.0-alpha",
|
||||
"@nocobase/plugin-gantt": "1.3.0-alpha",
|
||||
"@nocobase/plugin-graph-collection-manager": "1.3.0-alpha",
|
||||
|
@ -70,6 +70,7 @@ export class PresetNocoBase extends Plugin {
|
||||
'auth-sms>=0.10.0-alpha.2',
|
||||
'field-markdown-vditor>=0.21.0-alpha.16',
|
||||
'workflow-mailer',
|
||||
'field-m2m-array',
|
||||
];
|
||||
|
||||
splitNames(name: string) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user