diff --git a/packages/core/database/src/__tests__/repository/find-with-invalid-appends.test.ts b/packages/core/database/src/__tests__/repository/find-with-invalid-appends.test.ts new file mode 100644 index 00000000000..8e558b03f86 --- /dev/null +++ b/packages/core/database/src/__tests__/repository/find-with-invalid-appends.test.ts @@ -0,0 +1,157 @@ +/** + * 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 { createMockDatabase, Database } from '@nocobase/database'; +import { vi } from 'vitest'; + +describe('find with invalid appends', () => { + let db: Database; + let warnSpy: ReturnType; + + beforeEach(async () => { + db = await createMockDatabase(); + await db.clean({ drop: true }); + warnSpy = vi.spyOn(db.logger, 'warn'); + }); + + afterEach(async () => { + warnSpy.mockRestore(); + await db.close(); + }); + + it('should warn and skip when appending a non-existent association', async () => { + const User = db.collection({ + name: 'users', + fields: [{ name: 'name', type: 'string' }], + }); + + await db.sync(); + + await User.repository.create({ values: { name: 'u1' } }); + + const rows = await User.repository.find({ + appends: ['nonExistentRelation'], + }); + + expect(rows.length).toBe(1); + expect(rows[0].get('name')).toBe('u1'); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('nonExistentRelation'); + }); + + it('should warn and skip multiple non-existent associations', async () => { + const User = db.collection({ + name: 'users', + fields: [{ name: 'name', type: 'string' }], + }); + + await db.sync(); + + await User.repository.create({ values: { name: 'u1' } }); + + const rows = await User.repository.find({ + appends: ['badRelation1', 'badRelation2'], + }); + + expect(rows.length).toBe(1); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('badRelation1'); + expect(warnSpy.mock.calls[0][0]).toContain('badRelation2'); + }); + + it('should still load valid appends when mixed with non-existent ones', async () => { + db.collection({ + name: 'profiles', + fields: [{ name: 'bio', type: 'string' }], + }); + + const User = db.collection({ + name: 'users', + fields: [ + { name: 'name', type: 'string' }, + { name: 'profile', type: 'belongsTo', target: 'profiles' }, + ], + }); + + await db.sync(); + + await User.repository.create({ + values: { name: 'u1', profile: { bio: 'hello' } }, + }); + + const rows = await User.repository.find({ + appends: ['profile', 'nonExistent'], + }); + + expect(rows.length).toBe(1); + expect(rows[0].get('profile')).toBeTruthy(); + expect(rows[0].get('profile').get('bio')).toBe('hello'); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('nonExistent'); + expect(warnSpy.mock.calls[0][0]).not.toContain('profile'); + }); + + it('should warn when nested append has non-existent association', async () => { + db.collection({ + name: 'profiles', + fields: [{ name: 'bio', type: 'string' }], + }); + + const User = db.collection({ + name: 'users', + fields: [ + { name: 'name', type: 'string' }, + { name: 'profile', type: 'belongsTo', target: 'profiles' }, + ], + }); + + await db.sync(); + + await User.repository.create({ + values: { name: 'u1', profile: { bio: 'hello' } }, + }); + + const rows = await User.repository.find({ + appends: ['profile.nonExistentNested'], + }); + + expect(rows.length).toBe(1); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('nonExistentNested'); + }); + + it('should not warn when all appends are valid', async () => { + db.collection({ + name: 'profiles', + fields: [{ name: 'bio', type: 'string' }], + }); + + const User = db.collection({ + name: 'users', + fields: [ + { name: 'name', type: 'string' }, + { name: 'profile', type: 'belongsTo', target: 'profiles' }, + ], + }); + + await db.sync(); + + await User.repository.create({ + values: { name: 'u1', profile: { bio: 'hello' } }, + }); + + const rows = await User.repository.find({ + appends: ['profile'], + }); + + expect(rows.length).toBe(1); + expect(rows[0].get('profile')).toBeTruthy(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/database/src/errors/association-not-found-error.ts b/packages/core/database/src/errors/association-not-found-error.ts new file mode 100644 index 00000000000..b2a459a5cd5 --- /dev/null +++ b/packages/core/database/src/errors/association-not-found-error.ts @@ -0,0 +1,15 @@ +/** + * 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. + */ + +export class AssociationNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'AssociationNotFoundError'; + } +} diff --git a/packages/core/database/src/options-parser.ts b/packages/core/database/src/options-parser.ts index fe9f41bd073..21b49b45b9b 100644 --- a/packages/core/database/src/options-parser.ts +++ b/packages/core/database/src/options-parser.ts @@ -15,6 +15,7 @@ import FilterParser from './filter-parser'; import { Appends, Except, FindOptions } from './repository'; import qs from 'qs'; import { BelongsToArrayAssociation } from './belongs-to-array/belongs-to-array-repository'; +import { AssociationNotFoundError } from './errors/association-not-found-error'; const debug = require('debug')('noco-database'); @@ -30,6 +31,7 @@ export class OptionsParser { model: ModelStatic; filterParser: FilterParser; context: OptionsParserContext; + associationNotFoundWarnings: string[] = []; constructor(options: FindOptions, context: OptionsParserContext) { const { collection } = context; @@ -381,7 +383,7 @@ export class OptionsParser { if (appendFields.length == 2) { const association = associations[appendFields[0]]; if (!association) { - throw new Error(`association ${appendFields[0]} in ${model.name} not found`); + throw new AssociationNotFoundError(`association ${appendFields[0]} in ${model.name} not found`); } const associationModel = associations[appendFields[0]].target; @@ -428,7 +430,13 @@ export class OptionsParser { // association not exists const association = associations[appendAssociation]; if (!association) { - throw new Error(`association ${appendAssociation} in ${model.name} not found`); + throw new AssociationNotFoundError(`association ${appendAssociation} in ${model.name} not found`); + } + const targetCollectionName = this.database.getCollectionByModelName(association.target.name)?.name; + if (!targetCollectionName) { + throw new AssociationNotFoundError( + `target collection for association ${appendAssociation} in ${model.name} not found`, + ); } let includeOptions = { association: appendAssociation, @@ -497,7 +505,15 @@ export class OptionsParser { // handle every appends for (const append of sortedAppends) { - setInclude(this.model, filterParams, append); + try { + setInclude(this.model, filterParams, append); + } catch (error) { + if (error instanceof AssociationNotFoundError) { + this.associationNotFoundWarnings.push(error.message); + continue; + } + throw error; + } } debug('filter params: %o', filterParams); diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index cd7db88b3e4..6cfa539a003 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -51,6 +51,7 @@ import { updateAssociations, updateModelByValues } from './update-associations'; import { UpdateGuard } from './update-guard'; import { processIncludes } from './utils'; import { valuesToFilter } from './utils/filter-utils'; +import { AssociationNotFoundError } from './errors/association-not-found-error'; const debug = require('debug')('noco-database'); @@ -945,6 +946,11 @@ export class Repository 0) { + this.database.logger.warn(parser.associationNotFoundWarnings.join('; ')); + } + debug('sequelize query params %o', params); if (options.where && params.where) {