From 387ea7dd75c7078bf286cd88be630ddaa60d4bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 May 2026 22:06:55 +0200 Subject: [PATCH 1/3] fix(catalog-backend): simplify facets COUNT(DISTINCT) to COUNT(*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UNIQUE constraint on (entity_id, key, value) from the search indices migration guarantees each entity appears at most once per (key, original_value) group, making DISTINCT unnecessary. Removing it lets the database use a simpler aggregation plan. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/facets-count-optimization.md | 5 +++++ .../catalog-backend/src/service/DefaultEntitiesCatalog.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/facets-count-optimization.md diff --git a/.changeset/facets-count-optimization.md b/.changeset/facets-count-optimization.md new file mode 100644 index 0000000000..5c251b8aef --- /dev/null +++ b/.changeset/facets-count-optimization.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Simplified the entity facets aggregation from `COUNT(DISTINCT entity_id)` to `COUNT(*)`. The unique constraint on `(entity_id, key, value)` guarantees each entity appears at most once per search row group, making the `DISTINCT` unnecessary. This allows the database to use a simpler aggregation plan. diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index b5a65d6d05..90866e3f56 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -685,7 +685,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { .select({ facet: 'search.key', value: 'search.original_value', - count: this.database.raw('count(DISTINCT search.entity_id)'), + count: this.database.raw('count(*)'), }) .groupBy(['search.key', 'search.original_value']); From af1c16db106c750f1e83d5d4dbd6cdd7e246faaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 May 2026 22:33:05 +0200 Subject: [PATCH 2/3] fix(catalog-backend): make facets predicate tests order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GROUP BY result ordering is non-deterministic across database engines. The switch from COUNT(DISTINCT entity_id) to COUNT(*) changes MySQL's aggregation plan, which surfaces a different row order. Use arrayContaining + length check instead of exact array equality. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../service/DefaultEntitiesCatalog.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 427a9c8ce0..6a5c33b16d 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2556,12 +2556,18 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - 'spec.type': [ + 'spec.type': expect.arrayContaining([ { value: 'library', count: 1 }, { value: 'service', count: 1 }, - ], + ]), }, }); + const result = await catalog.facets({ + facets: ['spec.type'], + query: { kind: 'component' }, + credentials: mockCredentials.none(), + }); + expect(result.facets['spec.type']).toHaveLength(2); }, ); @@ -2597,12 +2603,18 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - kind: [ + kind: expect.arrayContaining([ { value: 'API', count: 1 }, { value: 'Component', count: 1 }, - ], + ]), }, }); + const result = await catalog.facets({ + facets: ['kind'], + query: { kind: { $in: ['component', 'api'] } }, + credentials: mockCredentials.none(), + }); + expect(result.facets.kind).toHaveLength(2); }, ); From b61936fc626109589fa92959debc89a2d4340999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 12 May 2026 15:18:10 +0200 Subject: [PATCH 3/3] fix(catalog-backend): add orderBy to facets query and clean up tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ORDER BY to guarantee stable result ordering across all database backends (MySQL does not sort without it). Also consolidate double catalog.facets() calls in tests into a single call with both content and length assertions on the same result. Signed-off-by: Fredrik Adelöw Co-authored-by: Cursor --- .../service/DefaultEntitiesCatalog.test.ts | 44 +++++++------------ .../src/service/DefaultEntitiesCatalog.ts | 3 +- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 6a5c33b16d..28ba0cf9e2 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2548,13 +2548,13 @@ describe('DefaultEntitiesCatalog', () => { }, ]); - await expect( - catalog.facets({ - facets: ['spec.type'], - query: { kind: 'component' }, - credentials: mockCredentials.none(), - }), - ).resolves.toEqual({ + const result = await catalog.facets({ + facets: ['spec.type'], + query: { kind: 'component' }, + credentials: mockCredentials.none(), + }); + expect(result.facets['spec.type']).toHaveLength(2); + expect(result).toEqual({ facets: { 'spec.type': expect.arrayContaining([ { value: 'library', count: 1 }, @@ -2562,12 +2562,6 @@ describe('DefaultEntitiesCatalog', () => { ]), }, }); - const result = await catalog.facets({ - facets: ['spec.type'], - query: { kind: 'component' }, - credentials: mockCredentials.none(), - }); - expect(result.facets['spec.type']).toHaveLength(2); }, ); @@ -2595,13 +2589,13 @@ describe('DefaultEntitiesCatalog', () => { }, ]); - await expect( - catalog.facets({ - facets: ['kind'], - query: { kind: { $in: ['component', 'api'] } }, - credentials: mockCredentials.none(), - }), - ).resolves.toEqual({ + const result = await catalog.facets({ + facets: ['kind'], + query: { kind: { $in: ['component', 'api'] } }, + credentials: mockCredentials.none(), + }); + expect(result.facets.kind).toHaveLength(2); + expect(result).toEqual({ facets: { kind: expect.arrayContaining([ { value: 'API', count: 1 }, @@ -2609,12 +2603,6 @@ describe('DefaultEntitiesCatalog', () => { ]), }, }); - const result = await catalog.facets({ - facets: ['kind'], - query: { kind: { $in: ['component', 'api'] } }, - credentials: mockCredentials.none(), - }); - expect(result.facets.kind).toHaveLength(2); }, ); @@ -2698,10 +2686,10 @@ describe('DefaultEntitiesCatalog', () => { }), ).resolves.toEqual({ facets: { - 'metadata.name': [ + 'metadata.name': expect.arrayContaining([ { value: 'one', count: 1 }, { value: 'two', count: 1 }, - ], + ]), }, }); }, diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 90866e3f56..92e53ce306 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -687,7 +687,8 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { value: 'search.original_value', count: this.database.raw('count(*)'), }) - .groupBy(['search.key', 'search.original_value']); + .groupBy(['search.key', 'search.original_value']) + .orderBy(['search.key', 'search.original_value']); if (request.filter || request.query) { // Build a subquery that finds matching entity IDs via