diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263884b..31db10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main] + branches: ['**'] pull_request: branches: [main] @@ -20,13 +20,18 @@ jobs: build-and-push: needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 + - name: Compute sanitized branch tag + run: | + SAFE_BRANCH="${GITHUB_REF_NAME}" + # Docker tag spec forbids '/'; replace with '-' so slash-containing branches build + echo "SAFE_BRANCH=${SAFE_BRANCH//\//-}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -38,6 +43,25 @@ jobs: push: true tags: | ghcr.io/mintproject/model-catalog-api:${{ github.sha }} - ghcr.io/mintproject/model-catalog-api:latest + ghcr.io/mintproject/model-catalog-api:${{ env.SAFE_BRANCH }} cache-from: type=gha cache-to: type=gha,mode=max + + tag-latest: + needs: build-and-push + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Tag commit SHA image as latest + run: | + docker buildx imagetools create \ + --tag ghcr.io/mintproject/model-catalog-api:latest \ + ghcr.io/mintproject/model-catalog-api:${{ github.sha }} diff --git a/openapi.yaml b/openapi.yaml index 12ec498..9ed95cd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14028,6 +14028,13 @@ components: type: integer nullable: true type: array + isOptional: + description: >- + When true, this input is optional for the configuration. + Ensemble manager will skip it during Tapis job submission if no dataset + is bound, rather than failing with an error. + nullable: true + type: boolean id: description: identifier nullable: false diff --git a/src/hasura/field-maps.ts b/src/hasura/field-maps.ts index fdd2eaf..6156a1e 100644 --- a/src/hasura/field-maps.ts +++ b/src/hasura/field-maps.ts @@ -214,6 +214,7 @@ child_configurations { description } inputs { + is_optional input { id label diff --git a/src/mappers/__tests__/request.test.ts b/src/mappers/__tests__/request.test.ts index d19c6f5..c534046 100644 --- a/src/mappers/__tests__/request.test.ts +++ b/src/mappers/__tests__/request.test.ts @@ -321,4 +321,41 @@ describe('buildJunctionInserts', () => { expect(personData).toHaveProperty('first_name', 'John'); expect(personData).toHaveProperty('last_name', 'Doe'); }); + + it('Test 10: spreads is_optional=true from isOptional onto configuration_input junction row (D-21)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset', isOptional: true } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + expect(inputs).toHaveProperty('data'); + const data = inputs['data'] as Record[]; + expect(data).toHaveLength(1); + const junctionRow = data[0] as Record; + // is_optional lives on the outer junction row (modelcatalog_configuration_input), + // NOT inside junctionRow['input']['data']. Verify it is at the top level: + expect(junctionRow).toHaveProperty('is_optional', true); + // Nested entity data should NOT have is_optional: + const nestedData = (junctionRow['input'] as Record)['data'] as Record; + expect(nestedData).not.toHaveProperty('is_optional'); + }); + + it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset' } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + // When isOptional is not provided, the key should be absent (not defaulted to false) + // so that Postgres applies its own column default: + expect(junctionRow).not.toHaveProperty('is_optional'); + }); }); diff --git a/src/mappers/__tests__/response.test.ts b/src/mappers/__tests__/response.test.ts index 300d424..70a0993 100644 --- a/src/mappers/__tests__/response.test.ts +++ b/src/mappers/__tests__/response.test.ts @@ -194,6 +194,60 @@ describe('transformRow - nested related objects', () => { }); }); +describe('transformRow - junction column hoist (D-21 round-trip)', () => { + const configurationConfig = RESOURCE_REGISTRY['modelconfigurations']!; + + it('hoists is_optional from junction row onto nested DSpec as scalar (not array)', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + label: 'Cfg1', + // GraphQL traversal: configuration -> inputs[] (junction rows) -> input (DSpec) + inputs: [ + { + is_optional: false, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec1', + label: 'Dspec1', + }, + }, + { + is_optional: true, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec2', + label: 'Dspec2', + }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + + expect(Array.isArray(hasInput)).toBe(true); + expect(hasInput).toHaveLength(2); + + // Junction column hoisted as scalar — array wrapping makes UI coerce + // !![false] to true on refresh and the checkbox lies. + expect(hasInput[0]!['isOptional']).toBe(false); + expect(hasInput[1]!['isOptional']).toBe(true); + expect(Array.isArray(hasInput[0]!['isOptional'])).toBe(false); + }); + + it('omits isOptional when junction column is null', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + inputs: [ + { + is_optional: null, + input: { id: 'https://w3id.org/okn/i/mint/Dspec1' }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + expect(hasInput[0]).not.toHaveProperty('isOptional'); + }); +}); + describe('transformList', () => { it('maps transformRow over an array of rows', () => { const rows = [ diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 08454e5..3e3200d 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -196,10 +196,16 @@ export function buildJunctionInserts( nestedData['id'] = `${ID_PREFIX}${randomUUID()}`; } + // Build set of camelCase keys that belong to the junction row itself (not the nested entity) + const junctionCamelKeys = new Set( + relConfig.junctionColumns ? Object.values(relConfig.junctionColumns) : [] + ); + // Copy scalar fields from nested object (camelCase -> snake_case) - // Skip 'id' (already handled), 'type' (not stored) + // Skip 'id' (already handled), 'type' (not stored), and junction-row-level fields for (const [key, value] of Object.entries(item)) { if (key === 'id' || key === 'type') continue; + if (junctionCamelKeys.has(key)) continue; // junction column — goes on outer row, not nested entity const snakeKey = camelToSnake(key); const unwrapped = Array.isArray(value) ? value.length === 1 @@ -213,7 +219,7 @@ export function buildJunctionInserts( } } - return { + const junctionRow: Record = { [relConfig.junctionRelName!]: { data: nestedData, on_conflict: { @@ -222,6 +228,12 @@ export function buildJunctionInserts( }, }, }; + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) junctionRow[colName] = item[camelKey]; + } + } + return junctionRow; }), on_conflict: { constraint: `${relConfig.junctionTable}_pkey`, diff --git a/src/mappers/resource-registry.ts b/src/mappers/resource-registry.ts index 8647d9d..b590a36 100644 --- a/src/mappers/resource-registry.ts +++ b/src/mappers/resource-registry.ts @@ -24,6 +24,12 @@ export interface RelationshipConfig { parentFkColumn?: string; /** The API resource name of the target type (for nested transforms) */ targetResource: string; + /** + * Extra scalar columns stored on the junction row itself (beyond the two FK columns). + * Used by the PUT path in service.ts to pass per-row extra data. + * Map from junction column name (snake_case) to the corresponding camelCase key in the request body item. + */ + junctionColumns?: Record; } export interface ResourceConfig { @@ -206,6 +212,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -290,6 +297,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -367,6 +375,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', diff --git a/src/mappers/response.ts b/src/mappers/response.ts index 3b6a135..99cf349 100644 --- a/src/mappers/response.ts +++ b/src/mappers/response.ts @@ -98,14 +98,27 @@ export function transformRow( // If this relationship goes through a junction table, each item is a junction row. // The actual target entity is nested under junctionRelName inside the junction row. const junctionRelName = relConfig.junctionRelName; + const junctionColumnNames = relConfig.junctionColumns + ? Object.keys(relConfig.junctionColumns) + : []; const transformed = relArray .map((item) => { - // Junction traversal: extract the nested target entity if junctionRelName is set - // and the nested entity exists as a key in the junction row. const targetRow = (junctionRelName && item[junctionRelName] != null) ? item[junctionRelName] as Record : item; - return transformRow(targetRow, targetConfig, depth + 1); + const out = transformRow(targetRow, targetConfig, depth + 1); + // Hoist junction-row scalar columns (e.g. is_optional) onto the + // transformed nested entity so they survive junction traversal. + // Emit as scalar (not array): these are junction column values like + // bool/text, not v1.8.0-style array-wrapped object properties. Array + // wrapping makes the UI coerce !![false] -> true on refresh. + for (const colName of junctionColumnNames) { + const val = item[colName]; + if (val !== null && val !== undefined) { + out[snakeToCamel(colName)] = val; + } + } + return out; }) .filter((item) => item['id'] !== null && item['id'] !== undefined); if (transformed.length > 0) { diff --git a/src/service.ts b/src/service.ts index 913040a..9ef307f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -304,10 +304,16 @@ class CatalogServiceImpl { const targetId = rawItemId ? rawItemId.startsWith('https://') ? rawItemId : `${ID_PREFIX}${rawItemId}` : `${ID_PREFIX}${randomUUID()}` - return { + const row: Record = { [relConfig.parentFkColumn!]: fullId, [targetFkColumn]: targetId, } + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) row[colName] = item[camelKey] + } + } + return row }) const juncSuffix2 = relConfig.junctionTable.replace('modelcatalog_', '')