Skip to content

Commit 8baf866

Browse files
committed
feat(sql-orm-client): disable nested create on required-payload M:N relations at the type level
Nested `.create` on an N:M relation whose junction carries a required non-FK payload column now resolves its argument to `never`, surfacing a compile-time error instead of only the runtime guard in `mutation-executor.ts`. `connect`/`disconnect` typing is unchanged. The derivation resolves the relation `through` to its junction model, treats any non-join column as a payload column, and disables create when any payload column is required (not nullable, no default). `User.roles` (junction `user_roles` with required `level`) disables create; `User.tags` (pure junction) keeps it. Signed-off-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
1 parent f643512 commit 8baf866

2 files changed

Lines changed: 180 additions & 4 deletions

File tree

packages/3-extensions/sql-orm-client/src/types.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -914,12 +914,104 @@ export type RelationMutation<TContract extends Contract<SqlStorage>, ModelName e
914914
| RelationMutationConnect<TContract, ModelName>
915915
| RelationMutationDisconnect<TContract, ModelName>;
916916

917-
export interface RelationMutator<TContract extends Contract<SqlStorage>, ModelName extends string> {
917+
type RelationThrough<
918+
TContract extends Contract<SqlStorage>,
919+
ModelName extends string,
920+
RelName extends string,
921+
> =
922+
RelationsOf<TContract, ModelName> extends infer Rels extends Record<string, unknown>
923+
? RelName extends keyof Rels
924+
? Rels[RelName] extends {
925+
readonly through: infer Through extends {
926+
readonly table: string;
927+
readonly parentColumns: readonly string[];
928+
readonly childColumns: readonly string[];
929+
};
930+
}
931+
? Through
932+
: never
933+
: never
934+
: never;
935+
936+
/**
937+
* Resolves a storage table name to its owning domain model by scanning the
938+
* model map for the model whose `storage.table` matches. Junction tables
939+
* (e.g. `user_roles`) surface their generated model (e.g. `UserRole`) so the
940+
* junction's own field nullability/defaults can be inspected.
941+
*/
942+
type ModelNameForTable<TContract extends Contract<SqlStorage>, TableName extends string> = {
943+
[M in keyof ModelsOf<TContract> & string]: ModelsOf<TContract>[M] extends {
944+
readonly storage: { readonly table: TableName };
945+
}
946+
? M
947+
: never;
948+
}[keyof ModelsOf<TContract> & string];
949+
950+
/**
951+
* A junction field is a *payload* field when its backing column is neither a
952+
* parent-side nor a child-side foreign-key column of the join. Those payload
953+
* fields are the ones the relation API can't populate from `create`/`connect`.
954+
*/
955+
type JunctionPayloadFieldNames<
956+
TContract extends Contract<SqlStorage>,
957+
JunctionModel extends string,
958+
JoinColumns extends string,
959+
> = {
960+
[F in CreateFieldNames<TContract, JunctionModel>]: FieldColumnName<
961+
TContract,
962+
JunctionModel,
963+
F
964+
> extends JoinColumns
965+
? never
966+
: F;
967+
}[CreateFieldNames<TContract, JunctionModel>];
968+
969+
/**
970+
* True when the relation's junction carries at least one required payload
971+
* column — a non-join column that is not nullable and has no default. Such a
972+
* relation can't be populated through nested `create`, so its create input is
973+
* disabled at the type level (mirroring the runtime guard in
974+
* `mutation-executor.ts`).
975+
*/
976+
type HasRequiredJunctionPayload<
977+
TContract extends Contract<SqlStorage>,
978+
ModelName extends string,
979+
RelName extends string,
980+
> =
981+
RelationThrough<TContract, ModelName, RelName> extends infer Through extends {
982+
readonly table: string;
983+
readonly parentColumns: readonly string[];
984+
readonly childColumns: readonly string[];
985+
}
986+
? ModelNameForTable<TContract, Through['table']> extends infer JunctionModel extends string
987+
? {
988+
[F in JunctionPayloadFieldNames<
989+
TContract,
990+
JunctionModel,
991+
Through['parentColumns'][number] | Through['childColumns'][number]
992+
>]: IsOptionalCreateField<TContract, JunctionModel, F> extends true ? never : F;
993+
}[JunctionPayloadFieldNames<
994+
TContract,
995+
JunctionModel,
996+
Through['parentColumns'][number] | Through['childColumns'][number]
997+
>] extends never
998+
? false
999+
: true
1000+
: false
1001+
: false;
1002+
1003+
export interface RelationMutator<
1004+
TContract extends Contract<SqlStorage>,
1005+
ModelName extends string,
1006+
CreateDisabled extends boolean = false,
1007+
> {
9181008
create(
919-
data: MutationCreateInput<TContract, ModelName>,
1009+
data: CreateDisabled extends true ? never : MutationCreateInput<TContract, ModelName>,
9201010
): RelationMutationCreate<TContract, ModelName>;
9211011
create(
922-
data: readonly MutationCreateInput<TContract, ModelName>[],
1012+
data: CreateDisabled extends true
1013+
? never
1014+
: readonly MutationCreateInput<TContract, ModelName>[],
9231015
): RelationMutationCreate<TContract, ModelName>;
9241016
connect(
9251017
criterion: RelationConnectCriterion<TContract, ModelName>,
@@ -938,7 +1030,11 @@ type RelationMutationCallback<
9381030
ModelName extends string,
9391031
RelName extends RelationNames<TContract, ModelName>,
9401032
> = (
941-
mutator: RelationMutator<TContract, RelatedModelName<TContract, ModelName, RelName> & string>,
1033+
mutator: RelationMutator<
1034+
TContract,
1035+
RelatedModelName<TContract, ModelName, RelName> & string,
1036+
HasRequiredJunctionPayload<TContract, ModelName, RelName>
1037+
>,
9421038
) => RelationMutation<TContract, RelatedModelName<TContract, ModelName, RelName> & string>;
9431039

9441040
type RelationMutationFields<
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { expectTypeOf, test } from 'vitest';
2+
import type { MutationCreateInput } from '../src/types';
3+
import type { Contract } from './fixtures/generated/contract';
4+
5+
type RoleCreate = MutationCreateInput<Contract, 'Role'>;
6+
type TagCreate = MutationCreateInput<Contract, 'Tag'>;
7+
8+
const roleCreate = { id: 'admin', name: 'Admin' } as RoleCreate;
9+
const tagCreate = { id: 'featured', name: 'Featured' } as TagCreate;
10+
const roleCriterion = { id: 'admin' } as { readonly id: NonNullable<RoleCreate['id']> };
11+
const tagCriterion = { id: 'featured' } as { readonly id: NonNullable<TagCreate['id']> };
12+
13+
test('nested create on a relation whose junction has a required payload column is a type error', () => {
14+
type Input = MutationCreateInput<Contract, 'User'>;
15+
16+
const input: Input = {
17+
name: 'Alice',
18+
email: 'alice@test.com',
19+
roles: (mutator) =>
20+
// @ts-expect-error - User.roles junction `user_roles` carries required column `level` the relation API can't populate, so nested create is disabled
21+
mutator.create(roleCreate),
22+
};
23+
24+
expectTypeOf(input).toExtend<Input>();
25+
});
26+
27+
test('connect remains available on a required-payload junction relation', () => {
28+
type Input = MutationCreateInput<Contract, 'User'>;
29+
30+
const input: Input = {
31+
name: 'Alice',
32+
email: 'alice@test.com',
33+
roles: (mutator) => mutator.connect(roleCriterion),
34+
};
35+
36+
expectTypeOf(input).toExtend<Input>();
37+
});
38+
39+
test('disconnect remains available on a required-payload junction relation', () => {
40+
type Input = MutationCreateInput<Contract, 'User'>;
41+
42+
const input: Input = {
43+
name: 'Alice',
44+
email: 'alice@test.com',
45+
roles: (mutator) => mutator.disconnect([roleCriterion]),
46+
};
47+
48+
expectTypeOf(input).toExtend<Input>();
49+
});
50+
51+
test('nested create on a pure junction relation is allowed', () => {
52+
type Input = MutationCreateInput<Contract, 'User'>;
53+
54+
const input: Input = {
55+
name: 'Alice',
56+
email: 'alice@test.com',
57+
tags: (mutator) => mutator.create(tagCreate),
58+
};
59+
60+
expectTypeOf(input).toExtend<Input>();
61+
});
62+
63+
test('connect and disconnect remain available on a pure junction relation', () => {
64+
type Input = MutationCreateInput<Contract, 'User'>;
65+
66+
const connectInput: Input = {
67+
name: 'Alice',
68+
email: 'alice@test.com',
69+
tags: (mutator) => mutator.connect(tagCriterion),
70+
};
71+
72+
const disconnectInput: Input = {
73+
name: 'Alice',
74+
email: 'alice@test.com',
75+
tags: (mutator) => mutator.disconnect([tagCriterion]),
76+
};
77+
78+
expectTypeOf(connectInput).toExtend<Input>();
79+
expectTypeOf(disconnectInput).toExtend<Input>();
80+
});

0 commit comments

Comments
 (0)