From c7706249ba23cc11bb3f0dd35d687360570d3771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 11 May 2026 17:07:49 +0200 Subject: [PATCH] fix(catalog-backend): use explicit null sentinel in dedup keys and add buildEntitySearch dedup tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `=== null ? '\x01' : value` instead of `?? ''` in both buildEntitySearch and syncSearchRows dedup maps, so that null and empty-string values are treated as distinct keys. In theory an entity could produce both value=null and value='' for the same key (e.g. spec.foo: [null, '']), and the old encoding would silently drop one of the two distinct rows. Also adds two focused unit tests to buildEntitySearch.test.ts: one covering deduplication of duplicate array values (e.g. tags: ['java', 'java', 'Java']), and one confirming that null and empty-string are kept as separate rows. Signed-off-by: Fredrik Adelöw Co-authored-by: Cursor --- .../stitcher/buildEntitySearch.test.ts | 35 +++++++++++++++++++ .../operations/stitcher/buildEntitySearch.ts | 2 +- .../operations/stitcher/syncSearchRows.ts | 10 ++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.test.ts b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.test.ts index 5a9e634e35..b4903d3144 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.test.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.test.ts @@ -255,6 +255,41 @@ describe('buildEntitySearch', () => { ]); }); + it('deduplicates rows from duplicate array values', () => { + const rows = buildEntitySearch('eid', { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n', tags: ['java', 'java', 'Java'] }, + }); + // 'java' and 'Java' both normalise to value 'java'; all three occurrences + // should collapse into a single row (first-wins for original_value). + const tagRows = rows.filter(r => r.key === 'metadata.tags'); + expect(tagRows).toHaveLength(1); + expect(tagRows[0]).toEqual( + expect.objectContaining({ value: 'java', original_value: 'java' }), + ); + // The synthetic boolean path key (metadata.tags.java) should also appear + // exactly once. + const boolRows = rows.filter(r => r.key === 'metadata.tags.java'); + expect(boolRows).toHaveLength(1); + }); + + it('treats null and empty-string values as distinct for deduplication', () => { + // An array with both null and '' for the same key must produce two rows + // since value=null and value='' are distinct in the database. + const rows = buildEntitySearch('eid', { + apiVersion: 'a', + kind: 'b', + metadata: { name: 'n' }, + spec: { foo: [null, ''] }, + }); + const fooRows = rows.filter(r => r.key === 'spec.foo'); + expect(fooRows).toHaveLength(2); + const values = fooRows.map(r => r.value); + expect(values).toContain(null); + expect(values).toContain(''); + }); + it('rejects duplicate keys', () => { expect(() => buildEntitySearch('eid', { diff --git a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts index 96b74e19bf..bbee57c84f 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts @@ -235,7 +235,7 @@ export function buildEntitySearch( // violate the unique constraint on (entity_id, key, value). const seen = new Set(); return rows.filter(row => { - const k = `${row.key}\0${row.value ?? ''}`; + const k = `${row.key}\0${row.value === null ? '\x01' : row.value}`; if (seen.has(k)) return false; seen.add(k); return true; diff --git a/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts b/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts index 17f7bf1135..dce65b4109 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts @@ -50,11 +50,15 @@ export async function syncSearchRows( ): Promise { // Dedup by (key, value) — the UNIQUE constraint on (entity_id, key, value) // rejects duplicates, and the same lowercased value with different original - // casing is semantically a single entry. Keep the last occurrence so that - // if the entity data has e.g. tags: ['Java', 'java'], the later one wins. + // casing is semantically a single entry. Keep the first occurrence, which + // matches the first-wins semantics of buildEntitySearch so that both layers + // consistently pick the same original_value for a given input order. const dedupMap = new Map(); for (const entry of searchEntries) { - dedupMap.set(`${entry.key}\0${entry.value ?? ''}`, entry); + const k = `${entry.key}\0${entry.value === null ? '\x01' : entry.value}`; + if (!dedupMap.has(k)) { + dedupMap.set(k, entry); + } } const deduped = [...dedupMap.values()];