Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-10-09 15:10:30 +00:00
7 changed files with 285 additions and 80 deletions
+2
View File
@@ -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 = (
@@ -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();
}
});
});
@@ -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();
});
});
});
@@ -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} />
@@ -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 ?? [];
}