From 9c7a7478242cdffd88afbf38b666a401c7007d7b Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Thu, 7 May 2026 21:41:22 -0400 Subject: [PATCH] fix(request): use update_columns:[] on nested target on_conflict POST /modelconfigurations|/modelconfigurationsetups|/datasetspecifications with relation fields (hasInput/hasOutput/hasModelCategory/hasPresentation) returned 400 'null value in column "label"' when the client linked an existing target by id alone. buildJunctionInserts emitted update_columns:['label'] on the nested target-entity insert. With nestedData = {id} only, on Hasura PK conflict the existing target row got UPDATE label=NULL and tripped the not-null constraint. Outer junction insert already used update_columns:[]; the inner inconsistency was the bug. Switch to update_columns:[] for link-or-noop semantics on existing target rows. New target rows still insert with all supplied fields. Renames remain the responsibility of the target's own PUT endpoint. Tests: updated existing assertion in request.test.ts (Test 1) and added two regressions covering hasInput as bare-string id and {id}-only object. --- src/mappers/__tests__/request.test.ts | 37 ++++++++++++++++++++++++++- src/mappers/request.ts | 2 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index c534046..a0c7b81 100644 --- a/src/mappers/__tests__/request.test.ts +++ b/src/mappers/__tests__/request.test.ts @@ -233,7 +233,11 @@ describe('buildJunctionInserts', () => { expect(targetData['id']).toBe('https://w3id.org/okn/i/mint/Economy'); const targetConflict = category['on_conflict'] as Record; expect(targetConflict['constraint']).toBe('modelcatalog_model_category_pkey'); - expect(targetConflict['update_columns']).toEqual(['label']); + // Link-or-noop semantics: never overwrite columns on existing target rows. + // Previous value ['label'] caused null-label cascade (bug-087) when client + // sent only `{id}` -- nestedData lacked label and on_conflict overwrote + // the existing row's label with NULL. + expect(targetConflict['update_columns']).toEqual([]); }); it('Test 2: generates UUID-based ID with https prefix when no ID provided', () => { @@ -343,6 +347,37 @@ describe('buildJunctionInserts', () => { expect(nestedData).not.toHaveProperty('is_optional'); }); + it('Regression bug-087: linking by string id alone never sets update_columns to overwrite target.label with NULL', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { hasInput: ['https://w3id.org/okn/i/mint/existing-ds-id'] }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + const nestedTarget = junctionRow['input'] as Record; + const nestedData = nestedTarget['data'] as Record; + // Client sent only an id -- nested data must contain only id, no label. + expect(Object.keys(nestedData)).toEqual(['id']); + expect(nestedData['id']).toBe('https://w3id.org/okn/i/mint/existing-ds-id'); + // Critical: on_conflict must NOT update label. Updating label with the + // missing-from-payload value would null-clobber the existing target row + // and trip the not-null constraint on dataset_specification.label. + const targetConflict = nestedTarget['on_conflict'] as Record; + expect(targetConflict['update_columns']).toEqual([]); + }); + + it('Regression bug-087: object-form link with only id likewise produces empty update_columns', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { hasInput: [{ id: 'https://w3id.org/okn/i/mint/existing-ds-id' }] }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + const nestedTarget = junctionRow['input'] as Record; + const targetConflict = nestedTarget['on_conflict'] as Record; + expect(targetConflict['update_columns']).toEqual([]); + }); + it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { const configConfig = getResourceConfig('modelconfigurations')!; const body = { diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 3e3200d..55ea21f 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -224,7 +224,7 @@ export function buildJunctionInserts( data: nestedData, on_conflict: { constraint: `${targetTable}_pkey`, - update_columns: ['label'], + update_columns: [], }, }, };