chore: support composite record unique key (#5007)

* refactor: filterTargetkey support mutiple

* refactor: getRowKey

* fix: bug

* chore: test

* chore: test

* chore: test

* fix: bug

* fix: build

* fix: useBulkDestroyActionProps support join primary key

* fix: build

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: fieldNames

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: sourceIdValue

* fix: remoteselect

* chore: test

* chore: multi target key in has many relation repository

* chore: test

* chore: multiple relation repository

* fix: test

* refactor: target collection not support join collection

* Update update-associations.ts

---------

Co-authored-by: Chareice <chareice@live.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Katherine 2024-09-10 21:51:37 +08:00 committed by GitHub
parent 5112b8e77e
commit 0d32ba4ff5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 653 additions and 108 deletions

View File

@ -75,8 +75,8 @@ export class SortAbleCollection {
// insert source position to target position // insert source position to target position
async move(sourceInstanceId: TargetKey, targetInstanceId: TargetKey, options: MoveOptions = {}) { async move(sourceInstanceId: TargetKey, targetInstanceId: TargetKey, options: MoveOptions = {}) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId); const sourceInstance = await this.collection.repository.findByTargetKey(sourceInstanceId);
const targetInstance = await this.collection.repository.findById(targetInstanceId); const targetInstance = await this.collection.repository.findByTargetKey(targetInstanceId);
if (this.scopeKey && sourceInstance.get(this.scopeKey) !== targetInstance.get(this.scopeKey)) { if (this.scopeKey && sourceInstance.get(this.scopeKey) !== targetInstance.get(this.scopeKey)) {
await sourceInstance.update({ await sourceInstance.update({
@ -88,7 +88,7 @@ export class SortAbleCollection {
} }
async changeScope(sourceInstanceId: TargetKey, targetScope: any, method?: string) { async changeScope(sourceInstanceId: TargetKey, targetScope: any, method?: string) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId); const sourceInstance = await this.collection.repository.findByTargetKey(sourceInstanceId);
const targetScopeValue = targetScope[this.scopeKey]; const targetScopeValue = targetScope[this.scopeKey];
if (targetScopeValue && sourceInstance.get(this.scopeKey) !== targetScopeValue) { if (targetScopeValue && sourceInstance.get(this.scopeKey) !== targetScopeValue) {
@ -108,7 +108,7 @@ export class SortAbleCollection {
} }
async sticky(sourceInstanceId: TargetKey) { async sticky(sourceInstanceId: TargetKey) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId); const sourceInstance = await this.collection.repository.findByTargetKey(sourceInstanceId);
await sourceInstance.update( await sourceInstance.update(
{ {
[this.field.get('name')]: 0, [this.field.get('name')]: 0,

View File

@ -11,6 +11,7 @@ import { Field, GeneralField } from '@formily/core';
import { RecursionField, useField, useFieldSchema } from '@formily/react'; import { RecursionField, useField, useFieldSchema } from '@formily/react';
import { Col, Row } from 'antd'; import { Col, Row } from 'antd';
import merge from 'deepmerge'; import merge from 'deepmerge';
import { isArray } from 'lodash';
import template from 'lodash/template'; import template from 'lodash/template';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -307,7 +308,15 @@ export const useFilterByTk = () => {
const association = getCollectionField(assoc); const association = getCollectionField(assoc);
return recordData?.[association.targetKey || 'id']; return recordData?.[association.targetKey || 'id'];
} }
if (isArray(collection.filterTargetKey)) {
const filterByTk = {};
for (const key of collection.filterTargetKey) {
filterByTk[key] = recordData?.[key];
}
return filterByTk;
} else {
return recordData?.[collection.filterTargetKey || 'id']; return recordData?.[collection.filterTargetKey || 'id'];
}
}; };
/** /**

View File

@ -183,9 +183,10 @@ export const useTableFieldProps = () => {
rowKey: (record: any) => { rowKey: (record: any) => {
return field.value?.indexOf?.(record); return field.value?.indexOf?.(record);
}, },
onRowSelectionChange(selectedRowKeys) { onRowSelectionChange(selectedRowKeys, selectedRowData) {
ctx.field.data = ctx?.field?.data || {}; ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys; ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx.field.data.selectedRowData = selectedRowData;
}, },
onChange({ current, pageSize }) { onChange({ current, pageSize }) {
ctx.service.run({ page: current, pageSize }); ctx.service.run({ page: current, pageSize });

View File

@ -319,9 +319,10 @@ export const useTableSelectorProps = () => {
dragSort: false, dragSort: false,
rowKey: ctx.rowKey || 'id', rowKey: ctx.rowKey || 'id',
pagination: fieldSchema?.['x-component-props']?.pagination === false ? false : field.componentProps.pagination, pagination: fieldSchema?.['x-component-props']?.pagination === false ? false : field.componentProps.pagination,
onRowSelectionChange(selectedRowKeys, selectedRows) { onRowSelectionChange(selectedRowKeys, selectedRowData) {
ctx.field.data = ctx?.field?.data || {}; ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys; ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx.field.data.selectedRowData = selectedRowData;
}, },
async onRowDragEnd({ from, to }) { async onRowDragEnd({ from, to }) {
await ctx.resource.move({ await ctx.resource.move({

View File

@ -1080,13 +1080,25 @@ export const useBulkDestroyActionProps = () => {
const { field } = useBlockRequestContext(); const { field } = useBlockRequestContext();
const { resource, service } = useBlockRequestContext(); const { resource, service } = useBlockRequestContext();
const { setSubmitted } = useActionContext(); const { setSubmitted } = useActionContext();
const collection = useCollection_deprecated();
const { filterTargetKey } = collection;
return { return {
async onClick(e?, callBack?) { async onClick(e?, callBack?) {
let filterByTk = field.data?.selectedRowKeys;
if (Array.isArray(filterTargetKey)) {
filterByTk = field.data.selectedRowData.map((v) => {
const obj = {};
filterTargetKey.map((j) => {
obj[j] = v[j];
});
return obj;
});
}
if (!field?.data?.selectedRowKeys?.length) { if (!field?.data?.selectedRowKeys?.length) {
return; return;
} }
await resource.destroy({ await resource.destroy({
filterByTk: field.data?.selectedRowKeys, filterByTk,
}); });
field.data.selectedRowKeys = []; field.data.selectedRowKeys = [];
const currentPage = service.params[0]?.page; const currentPage = service.params[0]?.page;
@ -1098,7 +1110,7 @@ export const useBulkDestroyActionProps = () => {
callBack?.(); callBack?.();
} }
setSubmitted?.(true); setSubmitted?.(true);
// service?.refresh?.(); service?.refresh?.();
}, },
}; };
}; };

View File

@ -64,6 +64,9 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`, description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'], 'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'],
}, },
footer: { footer: {

View File

@ -10,7 +10,7 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { getUniqueKeyFromCollection } from './o2m'; import { getUniqueKeyFromCollection } from './utils';
import { defaultProps, relationshipType, reverseFieldProperties } from './properties'; import { defaultProps, relationshipType, reverseFieldProperties } from './properties';
export class M2MFieldInterface extends CollectionFieldInterface { export class M2MFieldInterface extends CollectionFieldInterface {

View File

@ -9,7 +9,7 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { getUniqueKeyFromCollection } from './o2m'; import { getUniqueKeyFromCollection } from './utils';
import { constraintsProps, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, relationshipType, reverseFieldProperties } from './properties';
export class M2OFieldInterface extends CollectionFieldInterface { export class M2OFieldInterface extends CollectionFieldInterface {

View File

@ -8,10 +8,9 @@
*/ */
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { Collection } from '../../data-source';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { constraintsProps, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, relationshipType, reverseFieldProperties } from './properties';
import { getUniqueKeyFromCollection } from './utils';
export class O2MFieldInterface extends CollectionFieldInterface { export class O2MFieldInterface extends CollectionFieldInterface {
name = 'o2m'; name = 'o2m';
type = 'object'; type = 'object';
@ -215,7 +214,3 @@ export class O2MFieldInterface extends CollectionFieldInterface {
], ],
}; };
} }
export function getUniqueKeyFromCollection(collection: Collection) {
return collection?.filterTargetKey || collection?.getPrimaryKey() || 'id';
}

View File

@ -9,7 +9,7 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface'; import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { getUniqueKeyFromCollection } from './o2m'; import { getUniqueKeyFromCollection } from './utils';
import { constraintsProps, relationshipType, reverseFieldProperties } from './properties'; import { constraintsProps, relationshipType, reverseFieldProperties } from './properties';
export class O2OFieldInterface extends CollectionFieldInterface { export class O2OFieldInterface extends CollectionFieldInterface {

View File

@ -0,0 +1,20 @@
/**
* 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 { Collection } from '../../../data-source';
export function getUniqueKeyFromCollection(collection: Collection) {
if (collection?.filterTargetKey) {
if (Array.isArray(collection.filterTargetKey)) {
return collection?.filterTargetKey?.[0];
}
return collection?.filterTargetKey;
}
return collection?.getPrimaryKey() || 'id';
}

View File

@ -90,6 +90,9 @@ export class SqlCollectionTemplate extends CollectionTemplate {
description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`, description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'], 'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'],
}, },
}; };

View File

@ -153,6 +153,9 @@ export class ViewCollectionTemplate extends CollectionTemplate {
description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`, description: `{{t( "If a collection lacks a primary key, you must configure a unique record key to locate row records within a block, failure to configure this will prevent the creation of data blocks for the collection.")}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'], 'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'],
}, },
...getConfigurableProperties('category', 'description'), ...getConfigurableProperties('category', 'description'),

View File

@ -8,6 +8,7 @@
*/ */
import type { SchemaKey } from '@formily/json-schema'; import type { SchemaKey } from '@formily/json-schema';
import qs from 'qs';
import type { DataSource } from '../data-source'; import type { DataSource } from '../data-source';
import type { CollectionFieldOptions, CollectionOptions, GetCollectionFieldPredicate } from './Collection'; import type { CollectionFieldOptions, CollectionOptions, GetCollectionFieldPredicate } from './Collection';
@ -159,10 +160,23 @@ export class CollectionManager {
); );
return; return;
} }
const getTargetKey = (collection: Collection) => collection.filterTargetKey || collection.getPrimaryKey() || 'id';
const buildFilterByTk = (targetKey: string | string[], record: Record<string, any>) => {
if (Array.isArray(targetKey)) {
const filterByTk = {};
targetKey.forEach((key) => {
filterByTk[key] = record[key];
});
return qs.stringify(filterByTk);
} else {
return record[targetKey];
}
};
if (collectionOrAssociation instanceof Collection) { if (collectionOrAssociation instanceof Collection) {
const key = collectionOrAssociation.filterTargetKey || collectionOrAssociation.getPrimaryKey() || 'id'; const targetKey = getTargetKey(collectionOrAssociation);
return collectionRecordOrAssociationRecord[key]; return buildFilterByTk(targetKey, collectionRecordOrAssociationRecord);
} }
if (collectionOrAssociation.includes('.')) { if (collectionOrAssociation.includes('.')) {
@ -186,9 +200,8 @@ export class CollectionManager {
); );
return; return;
} }
const targetKey = getTargetKey(targetCollection);
const key = targetCollection?.filterTargetKey || targetCollection?.getPrimaryKey() || 'id'; return buildFilterByTk(targetKey, collectionRecordOrAssociationRecord);
return collectionRecordOrAssociationRecord[key];
} }
getSourceKeyByAssociation(associationName: string) { getSourceKeyByAssociation(associationName: string) {

View File

@ -9,7 +9,7 @@
import { IResource } from '@nocobase/sdk'; import { IResource } from '@nocobase/sdk';
import React, { FC, ReactNode, createContext, useContext, useMemo } from 'react'; import React, { FC, ReactNode, createContext, useContext, useMemo } from 'react';
import { isArray } from 'lodash';
import { useAPIClient } from '../../api-client'; import { useAPIClient } from '../../api-client';
import { useCollectionManager } from '../collection'; import { useCollectionManager } from '../collection';
import { CollectionRecord } from '../collection-record'; import { CollectionRecord } from '../collection-record';
@ -34,8 +34,17 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr
if (association && parentRecord) { if (association && parentRecord) {
const sourceKey = cm.getSourceKeyByAssociation(association); const sourceKey = cm.getSourceKeyByAssociation(association);
const parentRecordData = parentRecord instanceof CollectionRecord ? parentRecord.data : parentRecord; const parentRecordData = parentRecord instanceof CollectionRecord ? parentRecord.data : parentRecord;
if (isArray(sourceKey)) {
const filterByTk = {};
for (const key of sourceKey) {
filterByTk[key] = parentRecordData?.[key];
}
return encodeURIComponent(JSON.stringify(filterByTk));
} else {
return parentRecordData[sourceKey]; return parentRecordData[sourceKey];
} }
}
}, [association, sourceId, parentRecord]); }, [association, sourceId, parentRecord]);
const resource = useMemo(() => { const resource = useMemo(() => {

View File

@ -16,7 +16,6 @@ import { findFilterTargets } from '../../../../../block-provider/hooks';
import { DataBlock, useFilterBlock } from '../../../../../filter-provider/FilterProvider'; import { DataBlock, useFilterBlock } from '../../../../../filter-provider/FilterProvider';
import { mergeFilter } from '../../../../../filter-provider/utils'; import { mergeFilter } from '../../../../../filter-provider/utils';
import { removeNullCondition } from '../../../../../schema-component'; import { removeNullCondition } from '../../../../../schema-component';
import { useCollection } from '../../../../../data-source';
export const useTableBlockProps = () => { export const useTableBlockProps = () => {
const field = useField<ArrayField>(); const field = useField<ArrayField>();
@ -58,9 +57,10 @@ export const useTableBlockProps = () => {
dragSort: ctx.dragSort && ctx.dragSortBy, dragSort: ctx.dragSort && ctx.dragSortBy,
rowKey: ctx.rowKey || 'id', rowKey: ctx.rowKey || 'id',
pagination: fieldSchema?.['x-component-props']?.pagination === false ? false : field.componentProps.pagination, pagination: fieldSchema?.['x-component-props']?.pagination === false ? false : field.componentProps.pagination,
onRowSelectionChange: useCallback((selectedRowKeys) => { onRowSelectionChange: useCallback((selectedRowKeys, selectedRowData) => {
ctx.field.data = ctx?.field?.data || {}; ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys; ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx.field.data.selectedRowData = selectedRowData;
ctx?.field?.onRowSelect?.(selectedRowKeys); ctx?.field?.onRowSelect?.(selectedRowKeys);
}, []), }, []),
onRowDragEnd: useCallback( onRowDragEnd: useCallback(

View File

@ -53,7 +53,7 @@ export const useTableSelectorProps = () => {
type: multiple ? 'checkbox' : 'radio', type: multiple ? 'checkbox' : 'radio',
selectedRowKeys: rcSelectRows selectedRowKeys: rcSelectRows
?.filter((item) => options.every((row) => row[rowKey] !== item[rowKey])) ?.filter((item) => options.every((row) => row[rowKey] !== item[rowKey]))
.map((item) => item[rowKey]), .map((item) => item[rowKey]?.toString()),
}, },
onRowSelectionChange(selectedRowKeys, selectedRows) { onRowSelectionChange(selectedRowKeys, selectedRows) {
if (multiple) { if (multiple) {

View File

@ -169,7 +169,7 @@ const PagePopupsItemProvider: FC<{
collection={params.collection || context.collection} collection={params.collection || context.collection}
association={context.association} association={context.association}
sourceId={params.sourceid} sourceId={params.sourceid}
filterByTk={params.filterbytk} filterByTk={parseQueryString(params.filterbytk)}
// @ts-ignore // @ts-ignore
record={storedContext.record} record={storedContext.record}
parentRecord={storedContext.parentRecord} parentRecord={storedContext.parentRecord}
@ -465,3 +465,25 @@ function findSchemaByUid(uid: string, rootSchema: Schema, resultRef: { value: Sc
}); });
return resultRef.value; return resultRef.value;
} }
function parseQueryString(queryString) {
// 如果没有 '&',直接返回原始字符串
if (!queryString?.includes?.('=')) {
return queryString;
}
// 解码查询字符串
const decodedString = decodeURIComponent(queryString);
// 将解码后的字符串按 '&' 分隔成键值对
const pairs = decodedString.split('&');
// 将键值对转换为对象
const params = pairs.reduce((acc, pair) => {
const [key, value] = pair.split('=');
acc[key] = value;
return acc;
}, {});
return params;
}

View File

@ -92,7 +92,9 @@ const InternalRemoteSelect = connect(
(options) => { (options) => {
try { try {
return options return options
.filter((v) => ['number', 'string'].includes(typeof v[fieldNames.value])) .filter((v) => {
return ['number', 'string'].includes(typeof v[fieldNames.value]) || !v[fieldNames.value];
})
.map((option) => { .map((option) => {
let label = compile(option[fieldNames.label]); let label = compile(option[fieldNames.label]);

View File

@ -521,6 +521,9 @@ export const Table: any = withDynamicSchemaProps(
* @returns * @returns
*/ */
const defaultRowKey = useCallback((record: any) => { const defaultRowKey = useCallback((record: any) => {
if (rowKey) {
return getRowKey(record);
}
if (record.key) { if (record.key) {
return record.key; return record.key;
} }
@ -536,13 +539,21 @@ export const Table: any = withDynamicSchemaProps(
const getRowKey = useCallback( const getRowKey = useCallback(
(record: any) => { (record: any) => {
if (typeof rowKey === 'string') { if (Array.isArray(rowKey)) {
// 使用多个字段值组合生成唯一键
return rowKey
.map((keyField) => {
return record[keyField]?.toString() || '';
})
.join('-');
} else if (typeof rowKey === 'string') {
return record[rowKey]?.toString(); return record[rowKey]?.toString();
} else { } else {
// 如果 rowKey 是函数或未提供,使用 defaultRowKey
return (rowKey ?? defaultRowKey)(record)?.toString(); return (rowKey ?? defaultRowKey)(record)?.toString();
} }
}, },
[rowKey, defaultRowKey], [JSON.stringify(rowKey), defaultRowKey],
); );
const dataSourceKeys = field?.value?.map?.(getRowKey); const dataSourceKeys = field?.value?.map?.(getRowKey);
@ -623,6 +634,7 @@ export const Table: any = withDynamicSchemaProps(
onChange(selectedRowKeys: any[], selectedRows: any[]) { onChange(selectedRowKeys: any[], selectedRows: any[]) {
field.data = field.data || {}; field.data = field.data || {};
field.data.selectedRowKeys = selectedRowKeys; field.data.selectedRowKeys = selectedRowKeys;
field.data.selectedRowData = selectedRows;
setSelectedRowKeys(selectedRowKeys); setSelectedRowKeys(selectedRowKeys);
onRowSelectionChange?.(selectedRowKeys, selectedRows); onRowSelectionChange?.(selectedRowKeys, selectedRows);
}, },
@ -720,7 +732,7 @@ export const Table: any = withDynamicSchemaProps(
const rowClassName = useCallback( const rowClassName = useCallback(
(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : ''), (record) => (selectedRow.includes(record[rowKey]) ? highlightRow : ''),
[selectedRow, highlightRow, rowKey], [selectedRow, highlightRow, JSON.stringify(rowKey)],
); );
const onExpandValue = useCallback( const onExpandValue = useCallback(
@ -771,7 +783,7 @@ export const Table: any = withDynamicSchemaProps(
<SortableWrapper> <SortableWrapper>
<MemoizedAntdTable <MemoizedAntdTable
ref={tableSizeRefCallback} ref={tableSizeRefCallback}
rowKey={rowKey ?? defaultRowKey} rowKey={defaultRowKey}
dataSource={dataSource} dataSource={dataSource}
tableLayout="auto" tableLayout="auto"
{...others} {...others}

View File

@ -0,0 +1,187 @@
/**
* 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, { mockDatabase } from '../../index';
describe('multi filter target key', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
it('should set multi filter target keys', async () => {
const Student = db.collection({
name: 'students',
autoGenId: false,
filterTargetKey: ['name', 'classId'],
fields: [
{
name: 'name',
type: 'string',
primaryKey: true,
},
{
name: 'classId',
type: 'bigInt',
primaryKey: true,
},
{
name: 'age',
type: 'integer',
},
],
});
await db.sync();
const s1 = await Student.repository.create({
values: {
name: 's1',
classId: 1,
age: 10,
},
});
// it should find by name and classId
const findRes = await Student.repository.find({
filterByTk: {
name: 's1',
classId: 1,
},
});
expect(findRes.length).toBe(1);
// update
await Student.repository.update({
filterByTk: {
name: 's1',
classId: 1,
},
values: {
age: '20',
},
});
const s1Updated = await Student.repository.findOne({
filterByTk: {
name: 's1',
classId: 1,
},
});
expect(s1Updated.age).toBe(20);
// destroy
await Student.repository.destroy({
filterByTk: {
name: 's1',
classId: 1,
},
});
expect(await Student.repository.count()).toBe(0);
});
it('should save multi filter target keys association', async () => {
const Class = db.collection({
name: 'classes',
autoGenId: true,
fields: [
{
name: 'name',
type: 'string',
},
{
name: 'students',
type: 'hasMany',
target: 'students',
foreignKey: 'classId',
targetKey: 'name',
},
],
});
const Student = db.collection({
name: 'students',
autoGenId: false,
filterTargetKey: ['key1', 'key2'],
fields: [
{
name: 'name',
type: 'string',
primaryKey: true,
},
{
name: 'key1',
type: 'bigInt',
},
{
name: 'key2',
type: 'string',
},
{
name: 'age',
type: 'integer',
},
],
});
await db.sync();
const s1 = await Student.repository.create({
values: {
name: 's1',
key1: 1,
key2: 'k1',
},
});
const s2 = await Student.repository.create({
values: {
name: 's2',
key1: 2,
key2: 'k2',
},
});
const c1 = await Class.repository.create({
values: {
name: 'c1',
students: [
{
name: 's1',
key1: 1,
key2: 'k1',
},
{
name: 's2',
key1: 2,
key2: 'k2',
},
],
},
});
const c1Students = await Class.repository.find({
filterByTk: {
name: 'c1',
},
appends: ['students'],
});
expect(c1Students[0].students.length).toBe(2);
});
});

View File

@ -0,0 +1,145 @@
/**
* 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, { HasManyRepository, mockDatabase } from '../../index';
describe('multi target key in association repository', () => {
let db: Database;
beforeEach(async () => {
db = mockDatabase();
await db.clean({ drop: true });
});
afterEach(async () => {
await db.close();
});
describe('use multi target key as source key', async () => {
test('has many repository', async () => {
const Student = db.collection({
name: 'students',
autoGenId: false,
filterTargetKey: ['name', 'classId'],
fields: [
{
name: 'name',
type: 'string',
primaryKey: true,
},
{
name: 'classId',
type: 'bigInt',
primaryKey: true,
},
{
name: 'age',
type: 'integer',
},
{
name: 'id',
type: 'uid',
},
{
name: 'books',
type: 'hasMany',
target: 'books',
foreignKey: 'studentId',
targetKey: 'id',
},
],
});
const Book = db.collection({
name: 'books',
autoGenId: false,
filterTargetKey: ['name', 'author'],
fields: [
{
name: 'name',
type: 'string',
primaryKey: true,
},
{
name: 'author',
type: 'string',
primaryKey: true,
},
{
name: 'student',
type: 'belongsTo',
foreignKey: 'studentId',
targetKey: 'id',
},
],
});
await db.sync();
await Student.repository.create({
values: [
{
name: 's1',
classId: 1,
age: 10,
books: [
{
name: 'b1',
author: 'a1',
},
{
name: 'b2',
author: 'a1',
},
],
},
{
name: 's2',
classId: 2,
age: 11,
books: [
{
name: 'b3',
author: 'a1',
},
],
},
],
});
const hasManyRepo = db.getRepository<HasManyRepository>(
'students.books',
encodeURIComponent(JSON.stringify({ name: 's1', classId: 1 })),
);
const res = await hasManyRepo.find();
expect(res.length).toBe(2);
const b2a1 = await hasManyRepo.findOne({
filterByTk: {
name: 'b2',
author: 'a1',
},
});
expect(b2a1).toBeDefined();
expect(b2a1.get('name')).toBe('b2');
expect(b2a1.get('author')).toBe('a1');
await db
.getRepository<HasManyRepository>('students.books', {
name: 's2',
classId: 2,
})
.destroy({});
expect(await db.getRepository('books').count()).toBe(2);
});
});
});

View File

@ -98,7 +98,7 @@ export interface CollectionOptions extends Omit<ModelOptions, 'name' | 'hooks'>
viewName?: string; viewName?: string;
writableView?: boolean; writableView?: boolean;
filterTargetKey?: string; filterTargetKey?: string | string[];
fields?: FieldOptions[]; fields?: FieldOptions[];
model?: string | ModelStatic<Model>; model?: string | ModelStatic<Model>;
repository?: string | RepositoryType; repository?: string | RepositoryType;
@ -169,14 +169,21 @@ export class Collection<
this.setSortable(options.sortable); this.setSortable(options.sortable);
} }
get filterTargetKey() { get filterTargetKey(): string | string[] {
const targetKey = this.options?.filterTargetKey; const targetKey = this.options?.filterTargetKey;
if (Array.isArray(targetKey)) {
return targetKey;
}
if (targetKey && this.model.getAttributes()[targetKey]) { if (targetKey && this.model.getAttributes()[targetKey]) {
return targetKey; return targetKey;
} }
if (this.model.primaryKeyAttributes.length > 1) { if (this.model.primaryKeyAttributes.length > 1) {
return null; return null;
} }
return this.model.primaryKeyAttribute; return this.model.primaryKeyAttribute;
} }

View File

@ -53,7 +53,7 @@ import extendOperators from './operators';
import QueryInterface from './query-interface/query-interface'; import QueryInterface from './query-interface/query-interface';
import buildQueryInterface from './query-interface/query-interface-builder'; import buildQueryInterface from './query-interface/query-interface-builder';
import { RelationRepository } from './relation-repository/relation-repository'; import { RelationRepository } from './relation-repository/relation-repository';
import { Repository } from './repository'; import { Repository, TargetKey } from './repository';
import { import {
AfterDefineCollectionListener, AfterDefineCollectionListener,
BeforeDefineCollectionListener, BeforeDefineCollectionListener,
@ -634,11 +634,11 @@ export class Database extends EventEmitter implements AsyncEmitter {
getRepository<R extends Repository>(name: string): R; getRepository<R extends Repository>(name: string): R;
getRepository<R extends RelationRepository>(name: string, relationId: string | number): R; getRepository<R extends RelationRepository>(name: string, relationId: TargetKey): R;
getRepository<R extends ArrayFieldRepository>(name: string, relationId: string | number): R; getRepository<R extends ArrayFieldRepository>(name: string, relationId: TargetKey): R;
getRepository<R extends RelationRepository>(name: string, relationId?: string | number): Repository | R { getRepository<R extends RelationRepository>(name: string, relationId?: TargetKey): Repository | R {
const [collection, relation] = name.split('.'); const [collection, relation] = name.split('.');
if (relation) { if (relation) {
return this.getRepository(collection)?.relation(relation)?.of(relationId) as R; return this.getRepository(collection)?.relation(relation)?.of(relationId) as R;

View File

@ -76,17 +76,42 @@ export class OptionsParser {
return this.isAssociation(path.split('.')[0]); return this.isAssociation(path.split('.')[0]);
} }
filterByTkToWhereOption() {
const filterByTkOption = this.options?.filterByTk;
if (!filterByTkOption) {
return {};
}
// multi filter target key
if (lodash.isPlainObject(this.options.filterByTk)) {
const where = {};
for (const [key, value] of Object.entries(filterByTkOption)) {
where[key] = value;
}
return where;
}
// single filter target key
const filterTargetKey = this.context.targetKey || this.collection.filterTargetKey;
if (Array.isArray(filterTargetKey)) {
throw new Error('multi filter target key value must be object');
}
return {
[filterTargetKey]: filterByTkOption,
};
}
toSequelizeParams() { toSequelizeParams() {
const queryParams = this.filterParser.toSequelizeParams(); const queryParams = this.filterParser.toSequelizeParams();
if (this.options?.filterByTk) { if (this.options?.filterByTk) {
const filterByTkWhere = this.filterByTkToWhereOption();
queryParams.where = { queryParams.where = {
[Op.and]: [ [Op.and]: [queryParams.where, filterByTkWhere],
queryParams.where,
{
[this.context.targetKey || this.collection.filterTargetKey]: this.options.filterByTk,
},
],
}; };
} }
@ -115,7 +140,7 @@ export class OptionsParser {
let defaultSortField = this.model.primaryKeyAttribute; let defaultSortField = this.model.primaryKeyAttribute;
if (!defaultSortField && this.collection.filterTargetKey) { if (!defaultSortField && this.collection.filterTargetKey && !Array.isArray(this.collection.filterTargetKey)) {
defaultSortField = this.collection.filterTargetKey; defaultSortField = this.collection.filterTargetKey;
} }

View File

@ -17,18 +17,20 @@ export class HasManyRepository extends MultipleRelationRepository {
async find(options?: FindOptions): Promise<any> { async find(options?: FindOptions): Promise<any> {
const targetRepository = this.targetCollection.repository; const targetRepository = this.targetCollection.repository;
const addFilter = { const targetFilterOptions = await this.targetRepositoryFilterOptionsBySourceValue();
[this.association.foreignKey]: this.sourceKeyValue,
};
if (options?.filterByTk) { const findOptionsOmit = ['where', 'values', 'attributes'];
addFilter[this.associationField.targetKey] = options.filterByTk;
if (options?.filterByTk && !this.isMultiTargetKey(options.filterByTk)) {
// @ts-ignore
targetFilterOptions[this.associationField.targetKey] = options.filterByTk;
findOptionsOmit.push('filterByTk');
} }
const findOptions = { const findOptions = {
...omit(options, ['filterByTk', 'where', 'values', 'attributes']), ...omit(options, findOptionsOmit),
filter: { filter: {
$and: [options.filter || {}, addFilter], $and: [options?.filter || {}, targetFilterOptions],
}, },
}; };
@ -37,14 +39,11 @@ export class HasManyRepository extends MultipleRelationRepository {
async aggregate(options: AggregateOptions) { async aggregate(options: AggregateOptions) {
const targetRepository = this.targetCollection.repository; const targetRepository = this.targetCollection.repository;
const addFilter = {
[this.association.foreignKey]: this.sourceKeyValue,
};
const aggOptions = { const aggOptions = {
...options, ...options,
filter: { filter: {
$and: [options.filter || {}, addFilter], $and: [options.filter || {}, await this.targetRepositoryFilterOptionsBySourceValue()],
}, },
}; };

View File

@ -32,6 +32,21 @@ export interface AssociatedOptions extends Transactionable {
} }
export abstract class MultipleRelationRepository extends RelationRepository { export abstract class MultipleRelationRepository extends RelationRepository {
async targetRepositoryFilterOptionsBySourceValue(): Promise<any> {
let filterForeignKeyValue = this.sourceKeyValue;
if (this.isMultiTargetKey()) {
const sourceModel = await this.getSourceModel();
// @ts-ignore
filterForeignKeyValue = sourceModel.get(this.association.sourceKey);
}
return {
[this.association.foreignKey]: filterForeignKeyValue,
};
}
async find(options?: FindOptions): Promise<any> { async find(options?: FindOptions): Promise<any> {
const targetRepository = this.targetCollection.repository; const targetRepository = this.targetCollection.repository;
@ -49,9 +64,7 @@ export abstract class MultipleRelationRepository extends RelationRepository {
const appendFilter = { const appendFilter = {
isPivotFilter: true, isPivotFilter: true,
association: pivotAssoc, association: pivotAssoc,
where: { where: await this.targetRepositoryFilterOptionsBySourceValue(),
[association.foreignKey]: this.sourceKeyValue,
},
}; };
return targetRepository.find({ return targetRepository.find({

View File

@ -16,7 +16,7 @@ import { RelationField } from '../fields/relation-field';
import FilterParser from '../filter-parser'; import FilterParser from '../filter-parser';
import { Model } from '../model'; import { Model } from '../model';
import { OptionsParser } from '../options-parser'; import { OptionsParser } from '../options-parser';
import { CreateOptions, Filter, FindOptions } from '../repository'; import { CreateOptions, Filter, FindOptions, TargetKey } from '../repository';
import { updateAssociations } from '../update-associations'; import { updateAssociations } from '../update-associations';
import { UpdateGuard } from '../update-guard'; import { UpdateGuard } from '../update-guard';
@ -31,17 +31,19 @@ export abstract class RelationRepository {
targetCollection: Collection; targetCollection: Collection;
associationName: string; associationName: string;
associationField: RelationField; associationField: RelationField;
sourceKeyValue: string | number; sourceKeyValue: TargetKey;
sourceInstance: Model; sourceInstance: Model;
db: Database; db: Database;
database: Database; database: Database;
constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) { constructor(sourceCollection: Collection, association: string, sourceKeyValue: TargetKey) {
this.db = sourceCollection.context.database; this.db = sourceCollection.context.database;
this.database = this.db; this.database = this.db;
this.sourceCollection = sourceCollection; this.sourceCollection = sourceCollection;
this.sourceKeyValue = sourceKeyValue;
this.setSourceKeyValue(sourceKeyValue);
this.associationName = association; this.associationName = association;
this.association = this.sourceCollection.model.associations[association]; this.association = this.sourceCollection.model.associations[association];
@ -51,6 +53,24 @@ export abstract class RelationRepository {
this.targetCollection = this.sourceCollection.context.database.modelCollection.get(this.targetModel); this.targetCollection = this.sourceCollection.context.database.modelCollection.get(this.targetModel);
} }
decodeMultiTargetKey(str: string) {
try {
const decoded = decodeURIComponent(str);
return JSON.parse(decoded);
} catch (e) {
return false;
}
}
setSourceKeyValue(sourceKeyValue: TargetKey) {
this.sourceKeyValue =
typeof sourceKeyValue === 'string' ? this.decodeMultiTargetKey(sourceKeyValue) || sourceKeyValue : sourceKeyValue;
}
isMultiTargetKey(value?: any) {
return lodash.isPlainObject(value || this.sourceKeyValue);
}
get collection() { get collection() {
return this.db.getCollection(this.targetModel.name); return this.db.getCollection(this.targetModel.name);
} }
@ -145,7 +165,15 @@ export abstract class RelationRepository {
async getSourceModel(transaction?: Transaction) { async getSourceModel(transaction?: Transaction) {
if (!this.sourceInstance) { if (!this.sourceInstance) {
this.sourceInstance = await this.sourceCollection.model.findOne({ this.sourceInstance = this.isMultiTargetKey()
? await this.sourceCollection.repository.findOne({
filter: {
// @ts-ignore
...this.sourceKeyValue,
},
transaction,
})
: await this.sourceCollection.model.findOne({
where: { where: {
[this.associationField.sourceKey]: this.sourceKeyValue, [this.associationField.sourceKey]: this.sourceKeyValue,
}, },

View File

@ -59,7 +59,10 @@ export interface FilterAble {
filter: Filter; filter: Filter;
} }
export type TargetKey = string | number; export type BaseTargetKey = string | number;
export type MultiTargetKey = Record<string, BaseTargetKey>;
export type TargetKey = BaseTargetKey | MultiTargetKey;
export type TK = TargetKey | TargetKey[]; export type TK = TargetKey | TargetKey[];
type FieldValue = string | number | bigint | boolean | Date | Buffer | null | FieldValue[] | FilterWithOperator; type FieldValue = string | number | bigint | boolean | Date | Buffer | null | FieldValue[] | FilterWithOperator;
@ -211,7 +214,7 @@ class RelationRepositoryBuilder<R extends RelationRepository> {
} }
} }
of(id: string | number): R { of(id: TargetKey): R {
if (!this.association) { if (!this.association) {
return; return;
} }
@ -312,13 +315,12 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
} }
if (countOptions?.filterByTk) { if (countOptions?.filterByTk) {
const optionParser = new OptionsParser(options, {
collection: this.collection,
});
options['where'] = { options['where'] = {
[Op.and]: [ [Op.and]: [options['where'] || {}, optionParser.filterByTkToWhereOption()],
options['where'] || {},
{
[this.collection.filterTargetKey]: options.filterByTk,
},
],
}; };
} }
@ -331,13 +333,11 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
delete queryOptions.include; delete queryOptions.include;
} }
const count = await this.collection.model.count({ // @ts-ignore
return await this.collection.model.count({
...queryOptions, ...queryOptions,
transaction, transaction,
}); });
// @ts-ignore
return count;
} }
async aggregate(options: AggregateOptions & { optionsTransformer?: (options: any) => any }): Promise<any> { async aggregate(options: AggregateOptions & { optionsTransformer?: (options: any) => any }): Promise<any> {
@ -504,6 +504,10 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
return this.collection.model.findByPk(id); return this.collection.model.findByPk(id);
} }
findByTargetKey(targetKey: TargetKey) {
return this.findOne({ filterByTk: targetKey });
}
/** /**
* Find one record from database * Find one record from database
* *
@ -767,15 +771,30 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
} }
if (filterByTk && !options.filter) { if (filterByTk && !options.filter) {
return await this.model.destroy({ const where = [];
for (const tk of filterByTk) {
const optionParser = new OptionsParser(
{
filterByTk: tk,
},
{
collection: this.collection,
},
);
where.push(optionParser.filterByTkToWhereOption());
}
const destroyOptions = {
...options, ...options,
where: { where: {
[modelFilterKey]: { [Op.or]: where,
[Op.in]: filterByTk,
},
}, },
transaction, transaction,
}); };
return await this.model.destroy(destroyOptions);
} }
if (options.filter && isValidFilter(options.filter)) { if (options.filter && isValidFilter(options.filter)) {

View File

@ -21,6 +21,7 @@ import {
import Database from './database'; import Database from './database';
import { Model } from './model'; import { Model } from './model';
import { UpdateGuard } from './update-guard'; import { UpdateGuard } from './update-guard';
import { TargetKey } from './repository';
function isUndefinedOrNull(value: any) { function isUndefinedOrNull(value: any) {
return typeof value === 'undefined' || value === null; return typeof value === 'undefined' || value === null;
@ -58,7 +59,7 @@ type UpdateValue = { [key: string]: any };
interface UpdateOptions extends Transactionable { interface UpdateOptions extends Transactionable {
filter?: any; filter?: any;
filterByTk?: number | string; filterByTk?: TargetKey;
// 字段白名单 // 字段白名单
whitelist?: string[]; whitelist?: string[];
// 字段黑名单 // 字段黑名单
@ -454,6 +455,7 @@ export async function updateMultipleAssociation(
const attributes = { const attributes = {
[targetKey]: item[targetKey], [targetKey]: item[targetKey],
}; };
const instance = association.target.build(attributes, { isNewRecord: false }); const instance = association.target.build(attributes, { isNewRecord: false });
setItems.push(instance); setItems.push(instance);
} }

View File

@ -20,6 +20,23 @@ export class SQLCollection extends Collection {
super(options, context); super(options, context);
} }
/* istanbul ignore next -- @preserve */
get filterTargetKey() {
const targetKey = this.options?.filterTargetKey || 'id';
if (Array.isArray(targetKey)) {
return targetKey;
}
if (targetKey && this.model.getAttributes()[targetKey]) {
return targetKey;
}
if (this.model.primaryKeyAttributes.length > 1) {
return null;
}
return this.model.primaryKeyAttribute;
}
isSql() { isSql() {
return true; return true;
} }
@ -32,18 +49,6 @@ export class SQLCollection extends Collection {
return undefined; return undefined;
} }
/* istanbul ignore next -- @preserve */
get filterTargetKey() {
const targetKey = this.options?.filterTargetKey || 'id';
if (targetKey && this.model.getAttributes()[targetKey]) {
return targetKey;
}
if (this.model.primaryKeyAttributes.length > 1) {
return null;
}
return this.model.primaryKeyAttribute;
}
modelInit() { modelInit() {
const { autoGenId, sql } = this.options; const { autoGenId, sql } = this.options;
const model = class extends SQLModel {}; const model = class extends SQLModel {};

View File

@ -77,6 +77,9 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema
), ),
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
'x-component-props': {
multiple: true,
},
enum: '{{filterTargetKeyOptions}}', enum: '{{filterTargetKeyOptions}}',
}, },
footer: { footer: {

View File

@ -62,13 +62,16 @@ export const SetFilterTargetKey = (props) => {
{size === 'small' ? <br /> : ' '} {size === 'small' ? <br /> : ' '}
<Space.Compact style={{ marginTop: 5 }}> <Space.Compact style={{ marginTop: 5 }}>
<Select <Select
onChange={(value, option) => { onChange={(value, option: any) => {
console.log(value, option);
setFilterTargetKey(value); setFilterTargetKey(value);
setTitle(option['label']); setTitle(option.map((v) => v['label']).join(','));
}} }}
placeholder={t('Select field')} placeholder={t('Select field')}
size={'small'} size={'small'}
options={options} options={options}
mode="multiple"
style={{ width: '200px' }}
/> />
<Popconfirm <Popconfirm
placement="bottom" placement="bottom"
@ -105,7 +108,7 @@ export const SetFilterTargetKey = (props) => {
} }
ctx?.refresh?.(); ctx?.refresh?.();
refresh(); refresh();
// await app.dataSourceManager.getDataSource(dataSourceKey).reload(); await app.dataSourceManager.getDataSource(dataSourceKey).reload();
collection.setOption('filterTargetKey', filterTargetKey); collection.setOption('filterTargetKey', filterTargetKey);
}} }}
> >

View File

@ -136,6 +136,10 @@ export const ConfigurationTable = () => {
if (isFieldInherits && item.template === 'view') { if (isFieldInherits && item.template === 'view') {
return false; return false;
} }
//目标表不支持联合主键表
if (field.props.name === 'target' && Array.isArray(item.filterTargetKey) && item.filterTargetKey.length > 1) {
return false;
}
const templateIncluded = !targetScope?.template || targetScope.template.includes(item.template); const templateIncluded = !targetScope?.template || targetScope.template.includes(item.template);
const nameIncluded = !targetScope?.[field.props?.name] || targetScope[field.props.name].includes(item.name); const nameIncluded = !targetScope?.[field.props?.name] || targetScope[field.props.name].includes(item.name);
return templateIncluded && nameIncluded; return templateIncluded && nameIncluded;
@ -166,10 +170,10 @@ export const ConfigurationTable = () => {
}; };
const loadFilterTargetKeys = async (field) => { const loadFilterTargetKeys = async (field) => {
const { name } = field.form.values; const { name, fields: targetFields } = field.form.values;
const { fields } = getCollection(name); const { fields } = getCollection(name) || {};
return Promise.resolve({ return Promise.resolve({
data: fields, data: fields || targetFields,
}).then(({ data }) => { }).then(({ data }) => {
return data return data
.filter((field) => { .filter((field) => {

View File

@ -13,7 +13,7 @@ import { tval } from '@nocobase/utils/client';
import { NAMESPACE } from './locale'; import { NAMESPACE } from './locale';
function getUniqueKeyFromCollection(collection: Collection) { function getUniqueKeyFromCollection(collection: Collection) {
return collection?.filterTargetKey || collection?.getPrimaryKey() || 'id'; return collection?.filterTargetKey?.[0] || collection?.filterTargetKey || collection?.getPrimaryKey() || 'id';
} }
export class MBMFieldInterface extends CollectionFieldInterface { export class MBMFieldInterface extends CollectionFieldInterface {