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
46 changes: 32 additions & 14 deletions packages/3-extensions/sql-orm-client/src/collection-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,10 @@ export interface ResolvedIncludeRelation {
readonly relatedNamespaceId: string;
readonly relatedTableName: string;
readonly localTableName: string;
readonly targetColumn: string;
readonly localColumn: string;
/** Target-side join columns, positionally paired with `localColumns`. */
readonly targetColumns: readonly string[];
/** Local-side join columns, positionally paired with `targetColumns`. */
readonly localColumns: readonly string[];
readonly cardinality: RelationCardinalityTag | undefined;
readonly through?: IncludeThroughDescriptor;
}
Expand Down Expand Up @@ -336,22 +338,38 @@ export function resolveIncludeRelation(
{ meta: { model: baseModelName, relation: relationName } },
);
}
const localField = relation.on.localFields[0];
const targetField = relation.on.targetFields[0];
if (!localField || !targetField) {
const localFields = relation.on.localFields;
const targetFields = relation.on.targetFields;
const localColumns: string[] = [];
const targetColumns: string[] = [];

if (localFields.length !== targetFields.length) {
throw new InternalError(
`Relation '${relationName}' on model '${declaringModelName}' has incomplete join metadata (missing localFields or targetFields)`,
);
}

for (let i = 0; i < localFields.length; i++) {
const localField = localFields[i];
const targetField = targetFields[i];
if (!localField || !targetField) {
throw new InternalError(
`Relation '${relationName}' on model '${declaringModelName}' has incomplete join metadata (missing localFields or targetFields)`,
);
}
localColumns.push(resolveFieldToColumn(contract, namespaceId, declaringModelName, localField));
targetColumns.push(
resolveFieldToColumn(contract, relation.toNamespace, relation.to, targetField),
);
}

if (localColumns.length === 0) {
throw new InternalError(
`Relation '${relationName}' on model '${declaringModelName}' has incomplete join metadata (missing localFields or targetFields)`,
);
}

const relatedTableName = resolveModelTableName(contract, relation.toNamespace, relation.to);
const localColumn = resolveFieldToColumn(contract, namespaceId, declaringModelName, localField);
const targetColumn = resolveFieldToColumn(
contract,
relation.toNamespace,
relation.to,
targetField,
);

let through: IncludeThroughDescriptor | undefined;
if (relation.through !== undefined) {
Expand All @@ -373,8 +391,8 @@ export function resolveIncludeRelation(
relatedNamespaceId: relation.toNamespace,
relatedTableName,
localTableName,
targetColumn,
localColumn,
targetColumns,
localColumns,
cardinality: relation.cardinality,
...ifDefined('through', through),
};
Expand Down
4 changes: 2 additions & 2 deletions packages/3-extensions/sql-orm-client/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ class CollectionImpl<
relatedNamespaceId: relation.relatedNamespaceId,
relatedTableName: relation.relatedTableName,
localTableName: relation.localTableName,
targetColumn: relation.targetColumn,
localColumn: relation.localColumn,
targetColumns: relation.targetColumns,
localColumns: relation.localColumns,
cardinality: relation.cardinality,
...ifDefined('through', relation.through),
nested: nestedState,
Expand Down
54 changes: 35 additions & 19 deletions packages/3-extensions/sql-orm-client/src/query-plan-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,39 @@ interface IncludeParentSource {
}

function localColumnsForRowInclude(include: IncludeExpr): readonly string[] {
return include.through?.parentLocalColumns ?? [include.localColumn];
return include.through?.parentLocalColumns ?? include.localColumns;
}

/**
* Correlate a child row back to its parent across every column of the
* relation's key. Composite foreign keys contribute one equality per
* column, ANDed together — mirroring the relation-filter join in
* `model-accessor.ts`. Correlating on a prefix of the key would match
* every child sharing that prefix.
*/
function buildIncludeJoinExpr(
include: IncludeExpr,
childTableRef: string,
parentLocalRefs: readonly ColumnRef[],
): AnyExpression {
const joinExprs: AnyExpression[] = [];

if (parentLocalRefs.length !== include.targetColumns.length) {
throw new InternalError(`Include '${include.relationName}' has incomplete join metadata`);
}

for (let i = 0; i < parentLocalRefs.length; i++) {
const parentLocalRef = parentLocalRefs[i];
const targetColumn = include.targetColumns[i];
if (parentLocalRef === undefined || !targetColumn) {
throw new InternalError(`Include '${include.relationName}' has incomplete join metadata`);
}
joinExprs.push(BinaryExpr.eq(ColumnRef.of(childTableRef, targetColumn), parentLocalRef));
}

const firstExpr = joinExprs[0];
assertDefined(firstExpr, `Include '${include.relationName}' has no parent-local column ref`);
return joinExprs.length === 1 ? firstExpr : AndExpr.of(joinExprs);
}

function resolveParentLocalRefs(
Expand Down Expand Up @@ -578,15 +610,7 @@ function buildIncludeChildRowsSelect(
whereExpr = childWhere ? AndExpr.of([artifacts.whereExpr, childWhere]) : artifacts.whereExpr;
junctionJoins = [artifacts.junctionJoin];
} else {
const parentLocalRef = parentLocalRefs[0];
assertDefined(
parentLocalRef,
`Include '${include.relationName}' has no parent-local column ref`,
);
const joinExpr = BinaryExpr.eq(
ColumnRef.of(childTableRef, include.targetColumn),
parentLocalRef,
);
const joinExpr = buildIncludeJoinExpr(include, childTableRef, parentLocalRefs);
whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
}

Expand Down Expand Up @@ -1019,15 +1043,7 @@ function buildIncludeChildScalarSelect(
whereExpr = childWhere ? AndExpr.of([artifacts.whereExpr, childWhere]) : artifacts.whereExpr;
junctionJoins = [artifacts.junctionJoin];
} else {
const parentLocalRef = parentLocalRefs[0];
assertDefined(
parentLocalRef,
`Include '${include.relationName}' has no parent-local column ref`,
);
const joinExpr = BinaryExpr.eq(
ColumnRef.of(childTableRef, include.targetColumn),
parentLocalRef,
);
const joinExpr = buildIncludeJoinExpr(include, childTableRef, parentLocalRefs);
whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr;
}

Expand Down
6 changes: 4 additions & 2 deletions packages/3-extensions/sql-orm-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ export interface IncludeExpr {
readonly relatedNamespaceId: string;
readonly relatedTableName: string;
readonly localTableName: string;
readonly targetColumn: string;
readonly localColumn: string;
/** Target-side join columns, positionally paired with `localColumns`. */
readonly targetColumns: readonly string[];
/** Local-side join columns, positionally paired with `targetColumns`. */
readonly localColumns: readonly string[];
readonly cardinality: RelationCardinalityTag | undefined;
readonly through?: IncludeThroughDescriptor;
readonly nested: CollectionState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,38 @@ describe('collection-contract capability detection', () => {
relatedNamespaceId: 'public',
relatedTableName: 'posts',
localTableName: 'users',
targetColumn: 'user_id',
localColumn: 'id',
targetColumns: ['user_id'],
localColumns: ['id'],
cardinality: '1:N',
});
});

it('resolveIncludeRelation() resolves every column of a composite foreign key', () => {
const composite = withPatchedDomainModels(getTestContract(), (models) => {
const user = models['User'] as Record<string, unknown>;
return {
...models,
User: {
...user,
relations: {
...(user['relations'] as Record<string, unknown>),
posts: {
to: { model: 'Post', namespace: 'public' },
cardinality: '1:N',
on: { localFields: ['id', 'email'], targetFields: ['userId', 'title'] },
},
},
},
};
});

expect(resolveIncludeRelation(composite, 'public', 'User', 'posts')).toEqual({
relatedModelName: 'Post',
relatedNamespaceId: 'public',
relatedTableName: 'posts',
localTableName: 'users',
targetColumns: ['user_id', 'title'],
localColumns: ['id', 'email'],
cardinality: '1:N',
});
});
Expand Down Expand Up @@ -164,6 +194,46 @@ describe('collection-contract capability detection', () => {
);
});

it('resolveIncludeRelation() throws when composite key arrays have unequal length', () => {
const unequal = withPatchedDomainModels(getTestContract(), (models) => ({
...models,
User: {
...(models['User'] as Record<string, unknown>),
relations: {
posts: {
to: { model: 'Post', namespace: 'public' },
cardinality: '1:N',
on: { localFields: ['id', 'email'], targetFields: ['userId'] },
},
},
},
}));

expect(() => resolveIncludeRelation(unequal, 'public', 'User', 'posts')).toThrow(
/incomplete join metadata/,
);
});

it('resolveIncludeRelation() throws when a later composite key pair is empty', () => {
const emptyLater = withPatchedDomainModels(getTestContract(), (models) => ({
...models,
User: {
...(models['User'] as Record<string, unknown>),
relations: {
posts: {
to: { model: 'Post', namespace: 'public' },
cardinality: '1:N',
on: { localFields: ['id', 'email'], targetFields: ['userId', ''] },
},
},
},
}));

expect(() => resolveIncludeRelation(emptyLater, 'public', 'User', 'posts')).toThrow(
/incomplete join metadata/,
);
});

it('resolveUpsertConflictColumns() maps explicit criteria and falls back to primary key', () => {
const contract = getTestContract();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ function includeFor(
relatedTableName: relation.relatedTableName,
relatedNamespaceId: relation.relatedNamespaceId,
localTableName: relation.localTableName,
targetColumn: relation.targetColumn,
localColumn: relation.localColumn,
targetColumns: relation.targetColumns,
localColumns: relation.localColumns,
cardinality: relation.cardinality,
nested,
scalar: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe('Collection', () => {
relationName: 'posts',
relatedModelName: 'Post',
relatedTableName: 'posts',
targetColumn: 'user_id',
targetColumns: ['user_id'],
cardinality: '1:N',
});
expect(withPosts.state.includes[0]?.nested.filters).toEqual([
Expand Down Expand Up @@ -240,8 +240,8 @@ describe('Collection', () => {
relationName: 'author',
relatedModelName: 'User',
relatedTableName: 'users',
targetColumn: 'id',
localColumn: 'user_id',
targetColumns: ['id'],
localColumns: ['user_id'],
cardinality: 'N:1',
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,66 @@ describe('compileSelectWithIncludes', () => {
);
});

it('correlates a composite foreign key on every column pair', () => {
const include: IncludeExpr = {
relationName: 'posts',
relatedModelName: 'Post',
relatedNamespaceId: 'public',
relatedTableName: 'posts',
localTableName: 'users',
targetColumns: ['user_id', 'title'],
localColumns: ['id', 'email'],
cardinality: '1:N',
nested: emptyState(),
scalar: undefined,
combine: undefined,
};

const plan = compileSelectWithIncludes(baseContract, getTestAggregates(), 'public', 'users', {
...emptyState(),
includes: [include],
});

expectSelectAst(plan.ast);
const postsProjection = plan.ast.projection.find((item) => item.alias === 'posts');
expectSubqueryExpr(postsProjection?.expr);

const childRowsSource = postsProjection.expr.query.from;
expectDerivedTableSource(childRowsSource);

// Correlating on `user_id` alone would match every post sharing it,
// so both pairs of the key have to appear.
expect(childRowsSource.query.where).toEqual(
AndExpr.of([
BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')),
BinaryExpr.eq(ColumnRef.of('posts', 'title'), ColumnRef.of('users', 'email')),
]),
);
});

it('throws when an include correlating a composite key has unequal column lists', () => {
const include: IncludeExpr = {
relationName: 'posts',
relatedModelName: 'Post',
relatedNamespaceId: 'public',
relatedTableName: 'posts',
localTableName: 'users',
targetColumns: ['user_id'],
localColumns: ['id', 'email'],
cardinality: '1:N',
nested: emptyState(),
scalar: undefined,
combine: undefined,
};

expect(() =>
compileSelectWithIncludes(baseContract, getTestAggregates(), 'public', 'users', {
...emptyState(),
includes: [include],
}),
).toThrow(/incomplete join metadata/);
});

it('builds lexicographic cursor filters with distinctOn, limit, and offset', () => {
const { collection } = createCollection();
const state = collection
Expand Down Expand Up @@ -1400,8 +1460,8 @@ describe('compileSelectWithIncludes polymorphic targets', () => {
relatedTableName: relation.relatedTableName,
relatedNamespaceId: relation.relatedNamespaceId,
localTableName: relation.localTableName,
targetColumn: relation.targetColumn,
localColumn: relation.localColumn,
targetColumns: relation.targetColumns,
localColumns: relation.localColumns,
cardinality: relation.cardinality,
nested,
scalar: undefined,
Expand Down
Loading