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:
YANG QIA 2024-07-10 15:04:24 +08:00 committed by GitHub
parent 36f70c8aba
commit e0b5128c9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2093 additions and 47 deletions

View File

@ -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']) {

View File

@ -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,
// };
}

View File

@ -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,
);
};

View File

@ -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();

View File

@ -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({})];
}
}

View File

@ -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',
},
};
};

View File

@ -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} />

View File

@ -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;

View File

@ -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' },

View File

@ -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,

View File

@ -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;

View File

@ -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;
}

View File

@ -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';
}

View File

@ -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

View File

@ -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';

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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',
};
}
}

View File

@ -40,6 +40,8 @@ const postgres = {
polygon: 'json',
circle: 'json',
uuid: 'uuid',
set: 'set',
array: 'array',
};
const mysql = {

View File

@ -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';

View File

@ -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 选项符合预期

View File

@ -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'],
});
});
});

View File

@ -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: () => {

View File

@ -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,

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-field-record-set

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -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"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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' },
);

View File

@ -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' },
);

View 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;
}

View File

@ -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 };
};

View File

@ -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;

View File

@ -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';

View File

@ -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',
// },
// },
],
};
}

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -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."
}

View File

@ -0,0 +1,4 @@
{
"Many to many (array)": "多对多(数组)",
"Many to many (array) description": "支持通过在数组中存储目标表唯一键的方式建立多对多关系。"
}

View File

@ -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();
});
});
});

View File

@ -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' });
});
});
});

View File

@ -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' });
});
});
});

View File

@ -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;
}

View File

@ -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,
});
};
}

View File

@ -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 });
}
};
};

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -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;

View File

@ -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 {

View File

@ -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 [];
}

View File

@ -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 {

View File

@ -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",

View File

@ -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) {