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
async move(sourceInstanceId: TargetKey, targetInstanceId: TargetKey, options: MoveOptions = {}) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId);
const targetInstance = await this.collection.repository.findById(targetInstanceId);
const sourceInstance = await this.collection.repository.findByTargetKey(sourceInstanceId);
const targetInstance = await this.collection.repository.findByTargetKey(targetInstanceId);
if (this.scopeKey && sourceInstance.get(this.scopeKey) !== targetInstance.get(this.scopeKey)) {
await sourceInstance.update({
@ -88,7 +88,7 @@ export class SortAbleCollection {
}
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];
if (targetScopeValue && sourceInstance.get(this.scopeKey) !== targetScopeValue) {
@ -108,7 +108,7 @@ export class SortAbleCollection {
}
async sticky(sourceInstanceId: TargetKey) {
const sourceInstance = await this.collection.repository.findById(sourceInstanceId);
const sourceInstance = await this.collection.repository.findByTargetKey(sourceInstanceId);
await sourceInstance.update(
{
[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 { Col, Row } from 'antd';
import merge from 'deepmerge';
import { isArray } from 'lodash';
import template from 'lodash/template';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
@ -307,7 +308,15 @@ export const useFilterByTk = () => {
const association = getCollectionField(assoc);
return recordData?.[association.targetKey || 'id'];
}
return recordData?.[collection.filterTargetKey || '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'];
}
};
/**

View File

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

View File

@ -319,9 +319,10 @@ export const useTableSelectorProps = () => {
dragSort: false,
rowKey: ctx.rowKey || 'id',
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.selectedRowKeys = selectedRowKeys;
ctx.field.data.selectedRowData = selectedRowData;
},
async onRowDragEnd({ from, to }) {
await ctx.resource.move({

View File

@ -1080,13 +1080,25 @@ export const useBulkDestroyActionProps = () => {
const { field } = useBlockRequestContext();
const { resource, service } = useBlockRequestContext();
const { setSubmitted } = useActionContext();
const collection = useCollection_deprecated();
const { filterTargetKey } = collection;
return {
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) {
return;
}
await resource.destroy({
filterByTk: field.data?.selectedRowKeys,
filterByTk,
});
field.data.selectedRowKeys = [];
const currentPage = service.params[0]?.page;
@ -1098,7 +1110,7 @@ export const useBulkDestroyActionProps = () => {
callBack?.();
}
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.")}}`,
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'],
},
footer: {

View File

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

View File

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

View File

@ -8,10 +8,9 @@
*/
import { ISchema } from '@formily/react';
import { Collection } from '../../data-source';
import { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { constraintsProps, relationshipType, reverseFieldProperties } from './properties';
import { getUniqueKeyFromCollection } from './utils';
export class O2MFieldInterface extends CollectionFieldInterface {
name = 'o2m';
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 { CollectionFieldInterface } from '../../data-source/collection-field-interface/CollectionFieldInterface';
import { getUniqueKeyFromCollection } from './o2m';
import { getUniqueKeyFromCollection } from './utils';
import { constraintsProps, relationshipType, reverseFieldProperties } from './properties';
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.")}}`,
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'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.")}}`,
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
multiple: true,
},
'x-reactions': ['{{useAsyncDataSource(loadFilterTargetKeys)}}'],
},
...getConfigurableProperties('category', 'description'),

View File

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

View File

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

View File

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

View File

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

View File

@ -169,7 +169,7 @@ const PagePopupsItemProvider: FC<{
collection={params.collection || context.collection}
association={context.association}
sourceId={params.sourceid}
filterByTk={params.filterbytk}
filterByTk={parseQueryString(params.filterbytk)}
// @ts-ignore
record={storedContext.record}
parentRecord={storedContext.parentRecord}
@ -465,3 +465,25 @@ function findSchemaByUid(uid: string, rootSchema: Schema, resultRef: { value: Sc
});
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) => {
try {
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) => {
let label = compile(option[fieldNames.label]);

View File

@ -521,6 +521,9 @@ export const Table: any = withDynamicSchemaProps(
* @returns
*/
const defaultRowKey = useCallback((record: any) => {
if (rowKey) {
return getRowKey(record);
}
if (record.key) {
return record.key;
}
@ -536,13 +539,21 @@ export const Table: any = withDynamicSchemaProps(
const getRowKey = useCallback(
(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();
} else {
// 如果 rowKey 是函数或未提供,使用 defaultRowKey
return (rowKey ?? defaultRowKey)(record)?.toString();
}
},
[rowKey, defaultRowKey],
[JSON.stringify(rowKey), defaultRowKey],
);
const dataSourceKeys = field?.value?.map?.(getRowKey);
@ -623,6 +634,7 @@ export const Table: any = withDynamicSchemaProps(
onChange(selectedRowKeys: any[], selectedRows: any[]) {
field.data = field.data || {};
field.data.selectedRowKeys = selectedRowKeys;
field.data.selectedRowData = selectedRows;
setSelectedRowKeys(selectedRowKeys);
onRowSelectionChange?.(selectedRowKeys, selectedRows);
},
@ -720,7 +732,7 @@ export const Table: any = withDynamicSchemaProps(
const rowClassName = useCallback(
(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : ''),
[selectedRow, highlightRow, rowKey],
[selectedRow, highlightRow, JSON.stringify(rowKey)],
);
const onExpandValue = useCallback(
@ -771,7 +783,7 @@ export const Table: any = withDynamicSchemaProps(
<SortableWrapper>
<MemoizedAntdTable
ref={tableSizeRefCallback}
rowKey={rowKey ?? defaultRowKey}
rowKey={defaultRowKey}
dataSource={dataSource}
tableLayout="auto"
{...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;
writableView?: boolean;
filterTargetKey?: string;
filterTargetKey?: string | string[];
fields?: FieldOptions[];
model?: string | ModelStatic<Model>;
repository?: string | RepositoryType;
@ -169,14 +169,21 @@ export class Collection<
this.setSortable(options.sortable);
}
get filterTargetKey() {
get filterTargetKey(): string | string[] {
const targetKey = this.options?.filterTargetKey;
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;
}

View File

@ -53,7 +53,7 @@ import extendOperators from './operators';
import QueryInterface from './query-interface/query-interface';
import buildQueryInterface from './query-interface/query-interface-builder';
import { RelationRepository } from './relation-repository/relation-repository';
import { Repository } from './repository';
import { Repository, TargetKey } from './repository';
import {
AfterDefineCollectionListener,
BeforeDefineCollectionListener,
@ -634,11 +634,11 @@ export class Database extends EventEmitter implements AsyncEmitter {
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('.');
if (relation) {
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]);
}
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() {
const queryParams = this.filterParser.toSequelizeParams();
if (this.options?.filterByTk) {
const filterByTkWhere = this.filterByTkToWhereOption();
queryParams.where = {
[Op.and]: [
queryParams.where,
{
[this.context.targetKey || this.collection.filterTargetKey]: this.options.filterByTk,
},
],
[Op.and]: [queryParams.where, filterByTkWhere],
};
}
@ -115,7 +140,7 @@ export class OptionsParser {
let defaultSortField = this.model.primaryKeyAttribute;
if (!defaultSortField && this.collection.filterTargetKey) {
if (!defaultSortField && this.collection.filterTargetKey && !Array.isArray(this.collection.filterTargetKey)) {
defaultSortField = this.collection.filterTargetKey;
}

View File

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

View File

@ -16,7 +16,7 @@ import { RelationField } from '../fields/relation-field';
import FilterParser from '../filter-parser';
import { Model } from '../model';
import { OptionsParser } from '../options-parser';
import { CreateOptions, Filter, FindOptions } from '../repository';
import { CreateOptions, Filter, FindOptions, TargetKey } from '../repository';
import { updateAssociations } from '../update-associations';
import { UpdateGuard } from '../update-guard';
@ -31,17 +31,19 @@ export abstract class RelationRepository {
targetCollection: Collection;
associationName: string;
associationField: RelationField;
sourceKeyValue: string | number;
sourceKeyValue: TargetKey;
sourceInstance: Model;
db: Database;
database: Database;
constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) {
constructor(sourceCollection: Collection, association: string, sourceKeyValue: TargetKey) {
this.db = sourceCollection.context.database;
this.database = this.db;
this.sourceCollection = sourceCollection;
this.sourceKeyValue = sourceKeyValue;
this.setSourceKeyValue(sourceKeyValue);
this.associationName = 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);
}
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() {
return this.db.getCollection(this.targetModel.name);
}
@ -145,12 +165,20 @@ export abstract class RelationRepository {
async getSourceModel(transaction?: Transaction) {
if (!this.sourceInstance) {
this.sourceInstance = await this.sourceCollection.model.findOne({
where: {
[this.associationField.sourceKey]: this.sourceKeyValue,
},
transaction,
});
this.sourceInstance = this.isMultiTargetKey()
? await this.sourceCollection.repository.findOne({
filter: {
// @ts-ignore
...this.sourceKeyValue,
},
transaction,
})
: await this.sourceCollection.model.findOne({
where: {
[this.associationField.sourceKey]: this.sourceKeyValue,
},
transaction,
});
}
return this.sourceInstance;

View File

@ -59,7 +59,10 @@ export interface FilterAble {
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[];
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) {
return;
}
@ -312,13 +315,12 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
}
if (countOptions?.filterByTk) {
const optionParser = new OptionsParser(options, {
collection: this.collection,
});
options['where'] = {
[Op.and]: [
options['where'] || {},
{
[this.collection.filterTargetKey]: options.filterByTk,
},
],
[Op.and]: [options['where'] || {}, optionParser.filterByTkToWhereOption()],
};
}
@ -331,13 +333,11 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
delete queryOptions.include;
}
const count = await this.collection.model.count({
// @ts-ignore
return await this.collection.model.count({
...queryOptions,
transaction,
});
// @ts-ignore
return count;
}
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);
}
findByTargetKey(targetKey: TargetKey) {
return this.findOne({ filterByTk: targetKey });
}
/**
* Find one record from database
*
@ -767,15 +771,30 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
}
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,
where: {
[modelFilterKey]: {
[Op.in]: filterByTk,
},
[Op.or]: where,
},
transaction,
});
};
return await this.model.destroy(destroyOptions);
}
if (options.filter && isValidFilter(options.filter)) {

View File

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

View File

@ -20,6 +20,23 @@ export class SQLCollection extends Collection {
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() {
return true;
}
@ -32,18 +49,6 @@ export class SQLCollection extends Collection {
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() {
const { autoGenId, sql } = this.options;
const model = class extends SQLModel {};

View File

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

View File

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

View File

@ -136,6 +136,10 @@ export const ConfigurationTable = () => {
if (isFieldInherits && item.template === 'view') {
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 nameIncluded = !targetScope?.[field.props?.name] || targetScope[field.props.name].includes(item.name);
return templateIncluded && nameIncluded;
@ -166,10 +170,10 @@ export const ConfigurationTable = () => {
};
const loadFilterTargetKeys = async (field) => {
const { name } = field.form.values;
const { fields } = getCollection(name);
const { name, fields: targetFields } = field.form.values;
const { fields } = getCollection(name) || {};
return Promise.resolve({
data: fields,
data: fields || targetFields,
}).then(({ data }) => {
return data
.filter((field) => {

View File

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