mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-28 03:54:28 +08:00
Merge branch 'next' into develop
This commit is contained in:
@@ -11,3 +11,5 @@ The Pro workflows remain independent and operate on the Pro repository's own bra
|
||||
Documentation deployment uses the repository secrets `V3_DOCS_ALI_OSS_BUCKET`, `V3_DOCS_ALI_OSS_REGION` and `V3_DOCS_ALI_CDN_DOMAIN` for its destination; keep the existing v2 target secrets unchanged. Documentation and UI Library reuse `DOCS_ALI_OSS_ACCESS_KEY_ID` and `DOCS_ALI_OSS_ACCESS_KEY_SECRET`, with permission to access their respective destinations. UI Library publishes to `nocobase-ui-library` in `cn-beijing`. Both workflows publish on matching pushes to `v3-develop`; manual runs publish only when `publish` is enabled. No additional migration variable is required, and pull requests remain build-only.
|
||||
|
||||
Implementation files, scripts and application dependencies stay on the v3 branches. These entries need only the workflow token's Actions permission to dispatch the same workflow on `v3-develop`; they do not check out or execute v3 application code on `main`.
|
||||
|
||||
Pull requests changing only `docs/**` skip v3 Quality, Changeset Check and Guard Main. The Docs workflow checks and builds without publishing; matching pushes to `v3-develop` build and publish, while manual publishing remains available. Quality also skips documentation-only pushes. Changes outside `docs/` retain the normal checks, and Preview routing remains separately managed by the bot.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Database, { createMockDatabase } from '@nocobase/database';
|
||||
import Database, { createMockDatabase, DataTypes } from '@nocobase/database';
|
||||
import { EagerLoadingTree } from '../../eager-loading/eager-loading-tree';
|
||||
|
||||
const skipSqlite = process.env.DB_DIALECT == 'sqlite' ? it.skip : it;
|
||||
@@ -621,67 +621,147 @@ describe('Eager loading tree', () => {
|
||||
expect(u1.get('posts')[0].get('tags')[0].get('tagCategory').get('name')).toBe('c1');
|
||||
});
|
||||
|
||||
it.each(['mssql', 'mysql', 'mariadb'])(
|
||||
'should group direct root order fields for %s relation filters without grouping association order fields',
|
||||
async (dialect) => {
|
||||
const User = db.collection({
|
||||
name: 'users',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name' },
|
||||
{ type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'userId' },
|
||||
],
|
||||
});
|
||||
it('should group direct root order fields for relation filters without grouping association order fields', async () => {
|
||||
const User = db.collection({
|
||||
name: 'users',
|
||||
fields: [
|
||||
{ type: 'string', name: 'name' },
|
||||
{ type: 'hasMany', name: 'posts', target: 'posts', foreignKey: 'userId' },
|
||||
],
|
||||
});
|
||||
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
fields: [{ type: 'string', name: 'title' }],
|
||||
});
|
||||
const Post = db.collection({
|
||||
name: 'posts',
|
||||
fields: [{ type: 'string', name: 'title' }],
|
||||
});
|
||||
|
||||
await db.sync();
|
||||
await db.sync();
|
||||
|
||||
const rootInstance = User.model.build({ id: 1, name: 'user1' });
|
||||
const rootFindAllSpy = vi
|
||||
.spyOn(User.model, 'findAll')
|
||||
.mockResolvedValueOnce([rootInstance])
|
||||
.mockResolvedValueOnce([rootInstance]);
|
||||
const childFindAllSpy = vi.spyOn(Post.model, 'findAll').mockResolvedValue([]);
|
||||
vi.spyOn(db, 'inDialect').mockImplementation((...dialects: string[]) => dialects.includes(dialect));
|
||||
const rootInstance = User.model.build({ id: 1, name: 'user1' });
|
||||
const rootFindAllSpy = vi
|
||||
.spyOn(User.model, 'findAll')
|
||||
.mockResolvedValueOnce([rootInstance])
|
||||
.mockResolvedValueOnce([rootInstance]);
|
||||
const childFindAllSpy = vi.spyOn(Post.model, 'findAll').mockResolvedValue([]);
|
||||
|
||||
const rootOrder = [
|
||||
['name', 'ASC'],
|
||||
[{ model: Post.model, as: 'posts' }, 'title', 'ASC'],
|
||||
];
|
||||
const rootQueryOptions = {
|
||||
include: [
|
||||
{
|
||||
association: 'posts',
|
||||
attributes: ['id', 'title'],
|
||||
where: { title: 'matched' },
|
||||
},
|
||||
],
|
||||
filter: { posts: { title: { $includes: 'matched' } } },
|
||||
order: rootOrder,
|
||||
};
|
||||
const eagerLoadingTree = EagerLoadingTree.buildFromSequelizeOptions({
|
||||
model: User.model,
|
||||
rootAttributes: ['id', 'name'],
|
||||
rootOrder,
|
||||
includeOption: rootQueryOptions.include,
|
||||
db,
|
||||
rootQueryOptions,
|
||||
});
|
||||
const rootOrder = [
|
||||
['name', 'ASC'],
|
||||
[{ model: Post.model, as: 'posts' }, 'title', 'ASC'],
|
||||
];
|
||||
const rootQueryOptions = {
|
||||
include: [
|
||||
{
|
||||
association: 'posts',
|
||||
attributes: ['id', 'title'],
|
||||
where: { title: 'matched' },
|
||||
},
|
||||
],
|
||||
filter: { posts: { title: { $includes: 'matched' } } },
|
||||
order: rootOrder,
|
||||
};
|
||||
const eagerLoadingTree = EagerLoadingTree.buildFromSequelizeOptions({
|
||||
model: User.model,
|
||||
rootAttributes: ['id', 'name'],
|
||||
rootOrder,
|
||||
includeOption: rootQueryOptions.include,
|
||||
db,
|
||||
rootQueryOptions,
|
||||
});
|
||||
|
||||
await eagerLoadingTree.load();
|
||||
await eagerLoadingTree.load();
|
||||
|
||||
expect(rootFindAllSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
group: ['users.id', 'users.name'],
|
||||
}),
|
||||
);
|
||||
expect(childFindAllSpy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
expect(rootFindAllSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
group: ['users.id', 'users.name'],
|
||||
}),
|
||||
);
|
||||
expect(childFindAllSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should filter by a to-many relation sorted by root fields when the table has no primary key constraint', async () => {
|
||||
// External tables or views may have no primary key constraint, so PostgreSQL cannot infer root columns from the id.
|
||||
const Dept = db.collection({
|
||||
name: 'depts',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
fields: [
|
||||
{ type: 'integer', name: 'id', primaryKey: true },
|
||||
{ type: 'string', name: 'name' },
|
||||
],
|
||||
});
|
||||
const Through = db.collection({
|
||||
name: 'operators_depts',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
fields: [
|
||||
{ type: 'integer', name: 'operator_id' },
|
||||
{ type: 'integer', name: 'dept_id' },
|
||||
],
|
||||
});
|
||||
const Operator = db.collection({
|
||||
name: 'operators',
|
||||
autoGenId: false,
|
||||
timestamps: false,
|
||||
fields: [
|
||||
{ type: 'integer', name: 'id', primaryKey: true },
|
||||
{ type: 'string', name: 'name' },
|
||||
{
|
||||
type: 'belongsToMany',
|
||||
name: 'depts',
|
||||
target: 'depts',
|
||||
through: 'operators_depts',
|
||||
foreignKey: 'operator_id',
|
||||
otherKey: 'dept_id',
|
||||
sourceKey: 'id',
|
||||
targetKey: 'id',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await db.prepare();
|
||||
|
||||
const queryInterface = db.sequelize.getQueryInterface();
|
||||
await queryInterface.createTable(Operator.getTableNameWithSchema(), {
|
||||
id: { type: DataTypes.INTEGER },
|
||||
name: { type: DataTypes.STRING },
|
||||
});
|
||||
await queryInterface.createTable(Dept.getTableNameWithSchema(), {
|
||||
id: { type: DataTypes.INTEGER },
|
||||
name: { type: DataTypes.STRING },
|
||||
});
|
||||
await queryInterface.createTable(Through.getTableNameWithSchema(), {
|
||||
operator_id: { type: DataTypes.INTEGER },
|
||||
dept_id: { type: DataTypes.INTEGER },
|
||||
});
|
||||
|
||||
await Operator.model.bulkCreate([
|
||||
{ id: 1, name: 'c' },
|
||||
{ id: 2, name: 'a' },
|
||||
{ id: 3, name: 'b' },
|
||||
]);
|
||||
await Dept.model.bulkCreate([
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
]);
|
||||
await Through.model.bulkCreate([
|
||||
{ operator_id: 1, dept_id: 1 },
|
||||
{ operator_id: 1, dept_id: 2 },
|
||||
{ operator_id: 2, dept_id: 1 },
|
||||
{ operator_id: 3, dept_id: 2 },
|
||||
]);
|
||||
|
||||
const [rows, count] = await db.getRepository('operators').findAndCount({
|
||||
filter: { depts: { name: { $includes: 'A' } } },
|
||||
appends: ['depts'],
|
||||
sort: ['name'],
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(rows.map((row) => row.get('name'))).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('should use bind parameters when loading parent recursively with string primary keys', async () => {
|
||||
const payload = `root') UNION ALL SELECT 'pwned', NULL WHERE ('1'='1`;
|
||||
|
||||
@@ -289,13 +289,13 @@ export class EagerLoadingTree {
|
||||
throw new Error(`Model ${node.model.name} does not have primary key`);
|
||||
}
|
||||
|
||||
const group = [`${node.model.name}.${primaryKeyField}`];
|
||||
|
||||
if (this.db.inDialect('mssql') || this.db.isMySQLCompatibleDialect()) {
|
||||
// Strict GROUP BY dialects reject root ORDER BY fields that are not grouped. Include direct root order
|
||||
// fields without changing the root row cardinality; to-many association order fields stay excluded.
|
||||
group.push(...getRootModelOrderFields(node).map((field) => `${node.model.name}.${field}`));
|
||||
}
|
||||
// Strict GROUP BY dialects (MSSQL, MySQL ONLY_FULL_GROUP_BY, and PostgreSQL when the table has no real primary
|
||||
// key constraint, e.g. external tables or views) reject root ORDER BY fields that are not grouped. Include direct
|
||||
// root order fields without changing the root row cardinality; to-many association order fields stay excluded.
|
||||
const group = [
|
||||
`${node.model.name}.${primaryKeyField}`,
|
||||
...getRootModelOrderFields(node).map((field) => `${node.model.name}.${field}`),
|
||||
];
|
||||
|
||||
// find the paginated root ids after deduplication
|
||||
const ids = (
|
||||
|
||||
+68
@@ -12,6 +12,7 @@ import { act, render, waitFor } from '@testing-library/react';
|
||||
import { Form } from 'antd';
|
||||
import type { CascaderProps } from 'antd';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCollectionFieldOptions } from '../../../../plugin-workflow/src/client-v2/canvas/collectionFieldOptions';
|
||||
import { AssociatedConfig } from '../components/AssociatedConfig';
|
||||
|
||||
type MockField = {
|
||||
@@ -412,4 +413,71 @@ describe('AssociatedConfig', () => {
|
||||
expect(getForm()?.getFieldValue(['config', 'params', 'filter'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{{$context.data.id}}', ['$context', 'data', 'comments']],
|
||||
['{{$context.data.author0.id}}', ['$context', 'data', 'author0', 'comments']],
|
||||
])('restores %s without eagerly traversing unrelated or target collections', async (associatedKey, selectedPath) => {
|
||||
const getCollectionAllFields = vi.fn((collection: string) => [
|
||||
...Array.from({ length: 8 }, (_, index) => ({
|
||||
collectionName: collection,
|
||||
name: `author${index}`,
|
||||
foreignKey: `author${index}Id`,
|
||||
target: collection,
|
||||
type: 'belongsTo',
|
||||
interface: 'm2o',
|
||||
})),
|
||||
{
|
||||
collectionName: collection,
|
||||
name: 'comments',
|
||||
target: 'comments',
|
||||
type: 'hasMany',
|
||||
interface: 'o2m',
|
||||
},
|
||||
]);
|
||||
const trigger = mockWorkflowPlugin.triggers.get();
|
||||
const useVariables = vi.spyOn(trigger, 'useVariables').mockImplementation(
|
||||
(_config, options) =>
|
||||
getCollectionFieldOptions({
|
||||
...options,
|
||||
fields: [{ name: 'data', type: 'hasOne', target: 'posts', collectionName: 'posts' }],
|
||||
compile: (key: string) => key,
|
||||
collectionManager: { getCollectionAllFields },
|
||||
}) as ReturnType<typeof trigger.useVariables>,
|
||||
);
|
||||
const getTrigger = vi.spyOn(mockWorkflowPlugin.triggers, 'get').mockReturnValue(trigger);
|
||||
|
||||
try {
|
||||
renderWithForm({
|
||||
config: {
|
||||
association: {
|
||||
associatedCollection: 'posts',
|
||||
associatedKey,
|
||||
name: 'comments',
|
||||
},
|
||||
collection: 'comments',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getTriggerOptions().triggerDataOptions.some((option) => option.value === 'comments')).toBe(true);
|
||||
});
|
||||
|
||||
expect(cascaderProps.current?.value).toEqual(selectedPath);
|
||||
expect(getCollectionAllFields.mock.calls.length).toBe(selectedPath.length - 2);
|
||||
expect(getCollectionAllFields.mock.calls.every(([collection]) => collection === 'posts')).toBe(true);
|
||||
expect(findOption(getTriggerOptions().triggerDataOptions, 'author1').children).toBeUndefined();
|
||||
let options = getCascaderOptions();
|
||||
let selectedOption: MockOption | undefined;
|
||||
for (const key of selectedPath) {
|
||||
selectedOption = findOption(options, key);
|
||||
options = selectedOption.children ?? [];
|
||||
}
|
||||
expect(selectedOption?.label).toBe('comments');
|
||||
expect(selectedOption?.children).toBeUndefined();
|
||||
} finally {
|
||||
useVariables.mockRestore();
|
||||
getTrigger.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+45
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Form } from 'antd';
|
||||
import { AggregateFieldset } from '../components/AggregateFieldset';
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('AggregateFieldset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps showing fields from an associated collection after the collection field is unmounted', async () => {
|
||||
it('keeps showing fields from an associated collection while the collection selector is hidden', async () => {
|
||||
const getForm = renderWithForm({
|
||||
config: {
|
||||
aggregator: 'count',
|
||||
@@ -194,4 +194,47 @@ describe('AggregateFieldset', () => {
|
||||
expect(getForm()?.getFieldValue(['config', 'collection'])).toBe('comments');
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the associated collection and restores aggregate fields when reopening the configuration', async () => {
|
||||
const initialValues = {
|
||||
config: {
|
||||
aggregator: 'count',
|
||||
associated: true,
|
||||
collection: 'comments',
|
||||
association: {
|
||||
name: 'comments',
|
||||
associatedCollection: 'posts',
|
||||
associatedKey: '{{$context.data.id}}',
|
||||
},
|
||||
params: {
|
||||
field: 'id',
|
||||
distinct: true,
|
||||
filter: { id: { $notNull: true } },
|
||||
},
|
||||
precision: 2,
|
||||
},
|
||||
};
|
||||
const getForm = renderWithForm(initialValues);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('field')).toHaveValue('id');
|
||||
});
|
||||
|
||||
let savedValues: typeof initialValues | undefined;
|
||||
await act(async () => {
|
||||
savedValues = await getForm()?.validateFields();
|
||||
});
|
||||
|
||||
expect(savedValues).toEqual(initialValues);
|
||||
|
||||
cleanup();
|
||||
renderWithForm(savedValues);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('field')).toHaveValue('id');
|
||||
expect(screen.getByLabelText('field')).toHaveAttribute('data-collection', 'comments');
|
||||
expect(screen.getByTestId('filter')).toHaveAttribute('data-collection', 'comments');
|
||||
expect(screen.getByRole('checkbox', { name: 'Distinct' })).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
-12
@@ -31,8 +31,6 @@ function AggregateFieldSelect({ collection }: { collection?: string }) {
|
||||
|
||||
function AggregateParamsFields() {
|
||||
const t = useT();
|
||||
// The collection field is conditionally rendered when aggregating an associated collection. Keep watching its
|
||||
// value after that field is unmounted so the aggregate field and filter selectors can still load the target table.
|
||||
const collection = Form.useWatch(['config', 'collection'], { preserve: true });
|
||||
const aggregator = Form.useWatch(['config', 'aggregator']);
|
||||
|
||||
@@ -105,16 +103,21 @@ export function AggregateFieldset() {
|
||||
>
|
||||
<AssociatedConfig />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name={['config', 'collection']} label={t('Data of collection')} rules={[{ required: true }]}>
|
||||
<CollectionCascader
|
||||
onChange={() => {
|
||||
form.setFieldValue(['config', 'params', 'field'], null);
|
||||
form.setFieldValue(['config', 'params', 'filter'], null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
) : null}
|
||||
{/* Keep the target collection registered so validateFields includes it when saving associated aggregates. */}
|
||||
<Form.Item
|
||||
name={['config', 'collection']}
|
||||
label={t('Data of collection')}
|
||||
hidden={associated}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<CollectionCascader
|
||||
onChange={() => {
|
||||
form.setFieldValue(['config', 'params', 'field'], null);
|
||||
form.setFieldValue(['config', 'params', 'filter'], null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<AggregateFieldSelect collection={collection} />
|
||||
|
||||
+12
-3
@@ -144,6 +144,10 @@ function createRootOption({
|
||||
};
|
||||
}
|
||||
|
||||
function matchAssociationBranch(field: CollectionTriggerField) {
|
||||
return matchToManyField(field) || ['belongsTo', 'hasOne', 'belongsToArray'].includes(field.type ?? '');
|
||||
}
|
||||
|
||||
function useAssociatedFields() {
|
||||
const flowEngine = useFlowEngine();
|
||||
const current = useNodeContext();
|
||||
@@ -153,7 +157,9 @@ function useAssociatedFields() {
|
||||
flowEngine.context.app.pm.get('@nocobase/plugin-workflow')) as WorkflowPluginLike | undefined;
|
||||
const compile = (key: string, options?: Record<string, unknown>) => flowEngine.context.t(key, options);
|
||||
const variableOptions: UseVariableOptions = {
|
||||
types: [matchToManyField],
|
||||
// Match intermediate relations directly so the variable builder does not recursively search their target tables.
|
||||
// Only to-many fields are committed by onChange; other relations remain lazy navigation branches.
|
||||
types: [matchAssociationBranch],
|
||||
appends: null,
|
||||
depth: 4,
|
||||
};
|
||||
@@ -265,13 +271,16 @@ export function AssociatedConfig({
|
||||
let currentOptions = options;
|
||||
let currentOption: AssociatedCascaderOption | undefined;
|
||||
let resolved = true;
|
||||
for (const key of selectedPath) {
|
||||
for (const [index, key] of selectedPath.entries()) {
|
||||
currentOption = currentOptions.find((option) => option.value === key);
|
||||
if (!currentOption) {
|
||||
resolved = false;
|
||||
break;
|
||||
}
|
||||
await loadOptionChildren(currentOption);
|
||||
// The final relation already has its label and field metadata; its target fields are not needed for display.
|
||||
if (index < selectedPath.length - 1) {
|
||||
await loadOptionChildren(currentOption);
|
||||
}
|
||||
currentOptions = currentOption.children ?? [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user