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
30 changes: 27 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: CI
on:
push:
branches: [main]
branches: ['**']
pull_request:
branches: [main]

Expand All @@ -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:
Expand All @@ -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 }}
7 changes: 7 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/hasura/field-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ child_configurations {
description
}
inputs {
is_optional
input {
id
label
Expand Down
37 changes: 37 additions & 0 deletions src/mappers/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
expect(inputs).toHaveProperty('data');
const data = inputs['data'] as Record<string, unknown>[];
expect(data).toHaveLength(1);
const junctionRow = data[0] as Record<string, unknown>;
// 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<string, unknown>)['data'] as Record<string, unknown>;
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<string, unknown>;
const data = inputs['data'] as Record<string, unknown>[];
const junctionRow = data[0] as Record<string, unknown>;
// 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');
});
});
54 changes: 54 additions & 0 deletions src/mappers/__tests__/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;

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<Record<string, unknown>>;
expect(hasInput[0]).not.toHaveProperty('isOptional');
});
});

describe('transformList', () => {
it('maps transformRow over an array of rows', () => {
const rows = [
Expand Down
16 changes: 14 additions & 2 deletions src/mappers/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -213,7 +219,7 @@ export function buildJunctionInserts(
}
}

return {
const junctionRow: Record<string, unknown> = {
[relConfig.junctionRelName!]: {
data: nestedData,
on_conflict: {
Expand All @@ -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`,
Expand Down
9 changes: 9 additions & 0 deletions src/mappers/resource-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

export interface ResourceConfig {
Expand Down Expand Up @@ -206,6 +212,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
junctionRelName: 'input',
parentFkColumn: 'configuration_id',
targetResource: 'datasetspecifications',
junctionColumns: { is_optional: 'isOptional' },
},
hasOutput: {
hasuraRelName: 'outputs',
Expand Down Expand Up @@ -290,6 +297,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
junctionRelName: 'input',
parentFkColumn: 'configuration_id',
targetResource: 'datasetspecifications',
junctionColumns: { is_optional: 'isOptional' },
},
hasOutput: {
hasuraRelName: 'outputs',
Expand Down Expand Up @@ -367,6 +375,7 @@ export const RESOURCE_REGISTRY: Record<string, ResourceConfig> = {
junctionRelName: 'input',
parentFkColumn: 'configuration_id',
targetResource: 'datasetspecifications',
junctionColumns: { is_optional: 'isOptional' },
},
hasOutput: {
hasuraRelName: 'outputs',
Expand Down
19 changes: 16 additions & 3 deletions src/mappers/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
: 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) {
Expand Down
8 changes: 7 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,16 @@ class CatalogServiceImpl {
const targetId = rawItemId
? rawItemId.startsWith('https://') ? rawItemId : `${ID_PREFIX}${rawItemId}`
: `${ID_PREFIX}${randomUUID()}`
return {
const row: Record<string, unknown> = {
[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_', '')
Expand Down
Loading