Skip to content
Open
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
48 changes: 48 additions & 0 deletions frontend/packages/schema/src/parser/tbls/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,54 @@ describe(processor, () => {
expect(value.tables['posts']?.constraints).toEqual(expected)
})

it('INTERLEAVE constraint', async () => {
const { value } = await processor(
JSON.stringify({
name: 'testdb',
tables: [
{
name: 'singers',
type: 'TABLE',
columns: [{ name: 'singer_id', type: 'int', nullable: false }],
},
{
name: 'albums',
type: 'TABLE',
columns: [
{ name: 'singer_id', type: 'int', nullable: false },
{ name: 'album_id', type: 'int', nullable: false },
],
constraints: [
{
type: 'INTERLEAVE',
name: 'albums_interleave',
def: 'INTERLEAVE IN PARENT singers ON DELETE CASCADE',
table: 'albums',
referenced_table: 'singers',
columns: ['singer_id', 'album_id'],
referenced_columns: ['singer_id'],
},
],
},
],
}),
)

const expected = {
albums_interleave: {
type: 'FOREIGN KEY',
name: 'albums_interleave',
columnNames: ['singer_id', 'album_id'],
targetTableName: 'singers',
targetColumnNames: ['singer_id'],
updateConstraint: 'NO_ACTION',
deleteConstraint: 'CASCADE',
},
Comment on lines +654 to +663
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected normalized FOREIGN KEY currently asserts columnNames: ['singer_id', 'album_id'] while targetColumnNames only has ['singer_id']. If processInterleaveConstraint is updated to slice the local columns to the referenced column count (to avoid mismatches / cardinality issues), update this expectation accordingly (likely columnNames: ['singer_id']).

Copilot uses AI. Check for mistakes.
}

expect(value.tables['albums']?.constraints).toEqual(expected)
})

it('UNIQUE constraint', async () => {
const { value } = await processor(
JSON.stringify({
Expand Down
55 changes: 55 additions & 0 deletions frontend/packages/schema/src/parser/tbls/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,59 @@ function processCheckConstraint(constraint: {
return null
}

/**
* Process an INTERLEAVE constraint as a FOREIGN KEY fallback.
*
* tbls can represent Cloud Spanner parent-child relationships as
* INTERLEAVE constraints, but Liam's internal schema model does not
* support INTERLEAVE as a first-class constraint type.
*
* To keep the fix scoped to the tbls parser and reuse the existing
* relationship generation pipeline, INTERLEAVE is normalized into a
* FOREIGN KEY-shaped constraint here.
*
* If we fully support Spanner INTERLEAVE in Liam, we should reconsider this approach
* and potentially add INTERLEAVE as a first-class constraint type in the schema model.
*
* @see https://github.com/liam-hq/liam/issues/2411#issuecomment-3082740297
*/
function processInterleaveConstraint(constraint: {
type: string
name: string
columns?: string[]
def: string
referenced_table?: string
referenced_columns?: string[]
}): [string, Constraints[string]] | null {
if (
constraint.type === 'INTERLEAVE' &&
constraint.columns &&
constraint.columns.length > 0 &&
constraint.referenced_columns &&
constraint.referenced_columns.length > 0 &&
constraint.referenced_table
) {
const { updateConstraint, deleteConstraint } = extractForeignKeyActions(
constraint.def,
)

return [
constraint.name,
{
type: 'FOREIGN KEY',
name: constraint.name,
columnNames: constraint.columns,
targetTableName: constraint.referenced_table,
targetColumnNames: constraint.referenced_columns,
updateConstraint,
deleteConstraint,
},
Comment on lines +238 to +260
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INTERLEAVE constraints can list the child table’s full primary key columns (e.g. [parent_id, child_id]) while referenced_columns only contains the parent key columns. Normalizing this directly into a FOREIGN KEY with mismatched columnNames/targetColumnNames can (1) produce invalid FK definitions for any downstream consumer (e.g. deparsers) and (2) skew relationship cardinality calculation because determineCardinalityForForeignKey uses columnNames as-is. Consider mapping only the parent-key portion, e.g. columnNames: constraint.columns.slice(0, constraint.referenced_columns.length) (and guard that the slice length is > 0).

Copilot uses AI. Check for mistakes.
]
}

return null
Comment on lines +239 to +264
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processInterleaveConstraint duplicates most of processForeignKeyConstraint. To reduce maintenance risk (e.g. future changes to FK normalization not being applied here), consider reusing processForeignKeyConstraint by transforming the input (type: 'FOREIGN KEY', possibly slicing columns) and delegating to the existing handler.

Suggested change
constraint.type === 'INTERLEAVE' &&
constraint.columns &&
constraint.columns.length > 0 &&
constraint.referenced_columns &&
constraint.referenced_columns.length > 0 &&
constraint.referenced_table
) {
const { updateConstraint, deleteConstraint } = extractForeignKeyActions(
constraint.def,
)
return [
constraint.name,
{
type: 'FOREIGN KEY',
name: constraint.name,
columnNames: constraint.columns,
targetTableName: constraint.referenced_table,
targetColumnNames: constraint.referenced_columns,
updateConstraint,
deleteConstraint,
},
]
}
return null
constraint.type !== 'INTERLEAVE' ||
!constraint.columns ||
constraint.columns.length === 0 ||
!constraint.referenced_columns ||
constraint.referenced_columns.length === 0 ||
!constraint.referenced_table
) {
return null
}
return processForeignKeyConstraint({
...constraint,
type: 'FOREIGN KEY',
})

Copilot uses AI. Check for mistakes.
}

/**
* Process constraints for a table
*/
Expand Down Expand Up @@ -244,6 +297,8 @@ function processConstraints(
result = processUniqueConstraint(constraint)
} else if (constraint.type === 'CHECK') {
result = processCheckConstraint(constraint)
} else if (constraint.type === 'INTERLEAVE') {
result = processInterleaveConstraint(constraint)
}

// Add constraint to the collection if valid
Expand Down