diff --git a/docs/v3-workflows.md b/docs/v3-workflows.md index f50179608e9..45f16891e5d 100644 --- a/docs/v3-workflows.md +++ b/docs/v3-workflows.md @@ -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. diff --git a/packages/core/database/src/__tests__/eager-loading/eager-loading-tree.test.ts b/packages/core/database/src/__tests__/eager-loading/eager-loading-tree.test.ts index 5a7283151ac..c8cd465f900 100644 --- a/packages/core/database/src/__tests__/eager-loading/eager-loading-tree.test.ts +++ b/packages/core/database/src/__tests__/eager-loading/eager-loading-tree.test.ts @@ -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`; diff --git a/packages/core/database/src/eager-loading/eager-loading-tree.ts b/packages/core/database/src/eager-loading/eager-loading-tree.ts index 91a1262e6d3..22822459fbd 100644 --- a/packages/core/database/src/eager-loading/eager-loading-tree.ts +++ b/packages/core/database/src/eager-loading/eager-loading-tree.ts @@ -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 = ( diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/AssociatedConfig.test.tsx b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/AssociatedConfig.test.tsx index 09f0884e9ed..3e8288145f9 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/AssociatedConfig.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/AssociatedConfig.test.tsx @@ -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, + ); + 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(); + } + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/aggregateFieldset.test.tsx b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/aggregateFieldset.test.tsx index df1571532c9..b4c5e4b6823 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/aggregateFieldset.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/__tests__/aggregateFieldset.test.tsx @@ -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(); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AggregateFieldset.tsx b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AggregateFieldset.tsx index ad0fde2ecea..b8adc54e405 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AggregateFieldset.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AggregateFieldset.tsx @@ -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() { > - ) : ( - - { - form.setFieldValue(['config', 'params', 'field'], null); - form.setFieldValue(['config', 'params', 'filter'], null); - }} - /> - - )} + ) : null} + {/* Keep the target collection registered so validateFields includes it when saving associated aggregates. */} + diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AssociatedConfig.tsx b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AssociatedConfig.tsx index 428a7aa5d65..036a527abbb 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AssociatedConfig.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client-v2/components/AssociatedConfig.tsx @@ -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) => 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 ?? []; }