diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 71b9172..06edfb5 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -503,3 +503,140 @@ describe('Custom handler plain-ID resolution', () => { expect(queryStr).not.toContain('model_configuration_id') }) }) + +// --------------------------------------------------------------------------- +// FK-on-child relationships: parent.hasVersion / hasConfiguration / hasSetup +// where the child carries an FK column pointing back to the parent. +// --------------------------------------------------------------------------- +describe('PUT model with hasVersion sets software_id on child rows', () => { + beforeEach(() => { mockQuery.mockReset(); mockMutate.mockReset() }) + + it('emits clear+link update_modelcatalog_software_version mutations with software_id', async () => { + mockMutate.mockResolvedValueOnce({ data: {} }) + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_software_by_pk: { + id: 'https://w3id.org/okn/i/mint/MODEL-1', + label: 'M', + description: null, + }, + }, + }) + + const req = makeReq({ + params: { id: 'MODEL-1' }, + headers: { authorization: 'Bearer test' }, + body: { + type: ['https://w3id.org/okn/o/sdm#Model'], + id: 'https://w3id.org/okn/i/mint/MODEL-1', + label: ['M'], + hasVersion: [{ id: 'https://w3id.org/okn/i/mint/V-1' }], + }, + }) + const reply = makeReply() + await (CatalogService as any).models_id_put(req, reply) + + expect(mockMutate).toHaveBeenCalledOnce() + const args = mockMutate.mock.calls[0][0] + const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + expect(m).toContain('clear_versions: update_modelcatalog_software_version') + expect(m).toContain('link_versions: update_modelcatalog_software_version') + expect(m).toContain('software_id: { _eq: $id }') + expect(m).toContain('software_id: $id') + expect(args.variables.child_ids_versions).toEqual(['https://w3id.org/okn/i/mint/V-1']) + expect(args.variables.id).toBe('https://w3id.org/okn/i/mint/MODEL-1') + }) + + it('omits link branch when hasVersion is empty array (clear-only replace semantics)', async () => { + mockMutate.mockResolvedValueOnce({ data: {} }) + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_software_by_pk: { id: 'https://w3id.org/okn/i/mint/MODEL-2', label: 'M2', description: null }, + }, + }) + + const req = makeReq({ + params: { id: 'MODEL-2' }, + headers: { authorization: 'Bearer test' }, + body: { + type: ['https://w3id.org/okn/o/sdm#Model'], + id: 'https://w3id.org/okn/i/mint/MODEL-2', + label: ['M2'], + hasVersion: [], + }, + }) + const reply = makeReply() + await (CatalogService as any).models_id_put(req, reply) + + const args = mockMutate.mock.calls[0][0] + const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + expect(m).toContain('clear_versions:') + expect(m).not.toContain('link_versions:') + expect(args.variables.child_ids_versions).toEqual([]) + }) + + it('handles softwareversions.hasConfiguration -> software_version_id', async () => { + mockMutate.mockResolvedValueOnce({ data: {} }) + mockQuery.mockResolvedValueOnce({ + data: { + modelcatalog_software_version_by_pk: { + id: 'https://w3id.org/okn/i/mint/V-1', + label: 'v1', + description: null, + }, + }, + }) + + const req = makeReq({ + params: { id: 'V-1' }, + headers: { authorization: 'Bearer test' }, + body: { + id: 'https://w3id.org/okn/i/mint/V-1', + label: ['v1'], + hasConfiguration: [{ id: 'https://w3id.org/okn/i/mint/CFG-1' }], + }, + }) + const reply = makeReply() + await (CatalogService as any).softwareversions_id_put(req, reply) + + const args = mockMutate.mock.calls[0][0] + const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + expect(m).toContain('clear_configurations: update_modelcatalog_configuration') + expect(m).toContain('link_configurations: update_modelcatalog_configuration') + expect(m).toContain('software_version_id: { _eq: $id }') + expect(m).toContain('software_version_id: $id') + }) +}) + +describe('POST software with hasVersion links existing version rows', () => { + beforeEach(() => { mockMutate.mockReset() }) + + it('emits insert + link_versions update with software_id = parentId', async () => { + mockMutate.mockResolvedValueOnce({ + data: { + insert_modelcatalog_software_one: { id: 'https://w3id.org/okn/i/mint/NEW-1' }, + }, + }) + + const req = makeReq({ + headers: { authorization: 'Bearer test' }, + body: { + type: ['Software'], + id: 'https://w3id.org/okn/i/mint/NEW-1', + label: ['New'], + hasVersion: [{ id: 'https://w3id.org/okn/i/mint/V-99' }], + }, + }) + const reply = makeReply() + await (CatalogService as any).softwares_post(req, reply) + + expect(mockMutate).toHaveBeenCalledOnce() + const args = mockMutate.mock.calls[0][0] + const m = typeof args.mutation === 'string' ? args.mutation : args.mutation?.loc?.source?.body ?? '' + expect(m).toContain('insert_modelcatalog_software_one') + expect(m).toContain('link_versions: update_modelcatalog_software_version') + expect(m).toContain('software_id: $parentId') + expect(args.variables.parentId).toBe('https://w3id.org/okn/i/mint/NEW-1') + expect(args.variables.child_ids_versions).toEqual(['https://w3id.org/okn/i/mint/V-99']) + }) +}) diff --git a/src/mappers/resource-registry.ts b/src/mappers/resource-registry.ts index b590a36..50f2bab 100644 --- a/src/mappers/resource-registry.ts +++ b/src/mappers/resource-registry.ts @@ -22,6 +22,14 @@ export interface RelationshipConfig { junctionRelName?: string; /** FK column name in the junction table pointing back to the parent entity. Required when junctionTable is set. */ parentFkColumn?: string; + /** + * FK column name on the child entity table pointing back to this parent (one-to-many FK relationships). + * Set on parent.relationships[X] when the relationship is materialized as a direct FK on the child row, + * not via a junction table. PUT/POST handler updates this FK to link/unlink child rows. + * Mutually exclusive with junctionTable. + * e.g. softwares.hasVersion: childFkColumn = 'software_id' (column on modelcatalog_software_version). + */ + childFkColumn?: string; /** The API resource name of the target type (for nested transforms) */ targetResource: string; /** @@ -81,6 +89,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -122,6 +131,7 @@ export const RESOURCE_REGISTRY: Record = { hasConfiguration: { hasuraRelName: 'configurations', type: 'array', + childFkColumn: 'software_version_id', targetResource: 'modelconfigurations', }, hasModelCategory: { @@ -203,6 +213,7 @@ export const RESOURCE_REGISTRY: Record = { hasSetup: { hasuraRelName: 'child_configurations', type: 'array', + childFkColumn: 'model_configuration_id', targetResource: 'modelconfigurationsetups', }, hasInput: { @@ -622,6 +633,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -650,6 +662,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -678,6 +691,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -706,6 +720,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -734,6 +749,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -764,6 +780,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { @@ -792,6 +809,7 @@ export const RESOURCE_REGISTRY: Record = { hasVersion: { hasuraRelName: 'versions', type: 'array', + childFkColumn: 'software_id', targetResource: 'softwareversions', }, hasModelCategory: { diff --git a/src/service.ts b/src/service.ts index 9ef307f..afeae1e 100644 --- a/src/service.ts +++ b/src/service.ts @@ -209,13 +209,64 @@ class CatalogServiceImpl { const object = { ...input, ...junctionInserts } const tableSuffix = resourceConfig.hasuraTable.replace('modelcatalog_', '') - const mutationStr = ` - mutation CreateMutation($object: modelcatalog_${tableSuffix}_insert_input!) { - insert_modelcatalog_${tableSuffix}_one(object: $object) { - id + + // FK-on-child relationships: link existing child rows back to this newly created parent + // by setting the child's FK column. Multi-root mutation runs alongside the parent insert. + const parentId = input['id'] as string + const childFkParts: string[] = [] + const childFkVarDecls: string[] = [] + const childFkVariables: Record = {} + for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { + if (!relConfig.childFkColumn) continue + if (body[apiFieldName] === undefined) continue + const targetConfig = getResourceConfig(relConfig.targetResource) + if (!targetConfig?.hasuraTable) continue + + const rawValue = body[apiFieldName] + const items = Array.isArray(rawValue) ? (rawValue as unknown[]) : [] + const newIds = items + .map((item) => { + const rawId = + typeof item === 'string' + ? item + : ((item as Record) || {})['id'] + if (typeof rawId !== 'string' || !rawId) return null + return rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}` + }) + .filter((x): x is string => !!x) + + if (newIds.length === 0) continue + + const childSuffix = targetConfig.hasuraTable.replace('modelcatalog_', '') + const idsVar = `child_ids_${relConfig.hasuraRelName}` + childFkVariables[idsVar] = newIds + childFkVarDecls.push(`$${idsVar}: [String!]!`) + childFkParts.push( + `link_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { id: { _in: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: $parentId }) { affected_rows }` + ) + } + + let mutationStr: string + let mutationVariables: Record + if (childFkParts.length === 0) { + mutationStr = ` + mutation CreateMutation($object: modelcatalog_${tableSuffix}_insert_input!) { + insert_modelcatalog_${tableSuffix}_one(object: $object) { + id + } } - } - ` + ` + mutationVariables = { object } + } else { + const extraVarDecls = childFkVarDecls.length > 0 ? `, ${childFkVarDecls.join(', ')}` : '' + mutationStr = ` + mutation CreateWithChildFks($object: modelcatalog_${tableSuffix}_insert_input!, $parentId: String!${extraVarDecls}) { + insert_modelcatalog_${tableSuffix}_one(object: $object) { id } + ${childFkParts.join('\n ')} + } + ` + mutationVariables = { object, parentId, ...childFkVariables } + } const authHeader = req.headers?.authorization if (!authHeader) { @@ -227,7 +278,7 @@ class CatalogServiceImpl { const writeClient = getWriteClient(authHeader) const result = await writeClient.mutate({ mutation: gql`${mutationStr}`, - variables: { object }, + variables: mutationVariables, }) const data = result.data as Record | null const dataKey = `insert_modelcatalog_${tableSuffix}_one` @@ -322,9 +373,52 @@ class CatalogServiceImpl { ) } - // Build mutation string: simple _set if no junctions, multi-root otherwise (D-03) + // FK-on-child relationships: parent.has where child row carries an FK column + // pointing back to the parent (one-to-many). We replicate junction "replace" + // semantics by clearing the FK on rows previously linked to this parent that + // are not in the new list, then setting the FK on the rows in the new list. + const childFkParts: string[] = [] + const childFkVarDecls: string[] = [] + for (const [apiFieldName, relConfig] of Object.entries(resourceConfig.relationships)) { + if (!relConfig.childFkColumn) continue + if (body[apiFieldName] === undefined) continue + const targetConfig = getResourceConfig(relConfig.targetResource) + if (!targetConfig?.hasuraTable) continue + + const childSuffix = targetConfig.hasuraTable.replace('modelcatalog_', '') + const rawValue = body[apiFieldName] + const items = Array.isArray(rawValue) ? (rawValue as unknown[]) : [] + const newIds = items + .map((item) => { + const rawId = + typeof item === 'string' + ? item + : ((item as Record) || {})['id'] + if (typeof rawId !== 'string' || !rawId) return null + return rawId.startsWith('https://') ? rawId : `${ID_PREFIX}${rawId}` + }) + .filter((x): x is string => !!x) + + const idsVar = `child_ids_${relConfig.hasuraRelName}` + variables[idsVar] = newIds + childFkVarDecls.push(`$${idsVar}: [String!]!`) + + // Step 1: clear FK on rows previously linked to this parent that aren't in the new list + childFkParts.push( + `clear_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { ${relConfig.childFkColumn}: { _eq: $id }, id: { _nin: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: null }) { affected_rows }` + ) + + // Step 2: set FK on rows in the new list (only when non-empty -- _in: [] would still work but skip the no-op call) + if (newIds.length > 0) { + childFkParts.push( + `link_${relConfig.hasuraRelName}: update_modelcatalog_${childSuffix}(where: { id: { _in: $${idsVar} } }, _set: { ${relConfig.childFkColumn}: $id }) { affected_rows }` + ) + } + } + + // Build mutation string: simple _set if no junctions or child FK updates, multi-root otherwise (D-03) let mutationStr: string - if (junctionParts.length === 0) { + if (junctionParts.length === 0 && childFkParts.length === 0) { mutationStr = ` mutation UpdateMutation($id: String!, $set: modelcatalog_${tableSuffix}_set_input!) { update_modelcatalog_${tableSuffix}_by_pk(pk_columns: { id: $id }, _set: $set) { @@ -349,11 +443,13 @@ class CatalogServiceImpl { }) .join(', ') - const extraVarDecls = juncVarDecls ? `, ${juncVarDecls}` : '' + const extraDecls = [juncVarDecls, childFkVarDecls.join(', ')].filter(Boolean).join(', ') + const extraVarDecls = extraDecls ? `, ${extraDecls}` : '' + const allParts = [...junctionParts, ...childFkParts] mutationStr = ` mutation UpdateWithJunctions($id: String!, $set: modelcatalog_${tableSuffix}_set_input!${extraVarDecls}) { update_modelcatalog_${tableSuffix}_by_pk(pk_columns: { id: $id }, _set: $set) { id } - ${junctionParts.join('\n ')} + ${allParts.join('\n ')} } ` }