Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})
18 changes: 18 additions & 0 deletions src/mappers/resource-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -81,6 +89,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -122,6 +131,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasConfiguration: {
hasuraRelName: 'configurations',
type: 'array',
childFkColumn: 'software_version_id',
targetResource: 'modelconfigurations',
},
hasModelCategory: {
Expand Down Expand Up @@ -203,6 +213,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasSetup: {
hasuraRelName: 'child_configurations',
type: 'array',
childFkColumn: 'model_configuration_id',
targetResource: 'modelconfigurationsetups',
},
hasInput: {
Expand Down Expand Up @@ -622,6 +633,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -650,6 +662,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -678,6 +691,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -706,6 +720,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -734,6 +749,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -764,6 +780,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down Expand Up @@ -792,6 +809,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
hasVersion: {
hasuraRelName: 'versions',
type: 'array',
childFkColumn: 'software_id',
targetResource: 'softwareversions',
},
hasModelCategory: {
Expand Down
118 changes: 107 additions & 11 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
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<string, unknown>) || {})['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<string, unknown>
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) {
Expand All @@ -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<string, unknown> | null
const dataKey = `insert_modelcatalog_${tableSuffix}_one`
Expand Down Expand Up @@ -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<X> 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<string, unknown>) || {})['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) {
Expand All @@ -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 ')}
}
`
}
Expand Down
Loading