Skip to content

Commit b9b990d

Browse files
authored
feat(typeorm): support unique upsert targets (#39)
1 parent 49f7ad1 commit b9b990d

6 files changed

Lines changed: 169 additions & 34 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@nestm/crud-typeorm": minor
3+
---
4+
5+
Allow atomic PostgreSQL upserts to target a complete non-deferrable TypeORM
6+
unique constraint or non-partial unique index in addition to the primary
7+
identity. This supports entities with generated primary keys and domain-owned
8+
alternate identities while preserving scoped conflict-update authorization,
9+
explicit overwrite allowlists, and single-statement execution.

‎packages/crud-typeorm/README.md‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ path.
173173
## Atomic upsert
174174

175175
When a resource enables the core `upsert` operation, configure its binding with
176-
the complete TypeORM primary identity and the exact persistence fields that may
176+
a complete TypeORM conflict identity and the exact persistence fields that may
177177
change on conflict:
178178

179179
```ts
@@ -194,11 +194,17 @@ const viewerBindings = bindTypeOrmCrud({
194194
```
195195

196196
Both lists contain TypeORM entity property paths, not public CRUD field names or
197-
database column names. `conflictFields` must be non-empty, map exactly once to
198-
every primary column, and have non-null values in the final scoped insert row.
199-
`overwriteFields` must be unique, non-primary scalar columns that TypeORM permits
200-
on both insert and update. This explicit allowlist prevents an upsert from
201-
silently replacing immutable ownership or secret fields.
197+
database column names. `conflictFields` must be non-empty, have non-null values
198+
in the final scoped insert row, and map exactly once to either every primary
199+
column, every column in a non-deferrable unique constraint, or every column in a
200+
non-partial unique index. This allows a generated primary key to remain absent
201+
when an alternate domain identity is the conflict target. Deferrable unique
202+
constraints and partial unique indexes are rejected because this contract does
203+
not carry the extra PostgreSQL conflict-target semantics they require.
204+
`overwriteFields` must be unique, disjoint from the conflict fields, and map to
205+
non-primary scalar columns that TypeORM permits on both insert and update. This
206+
explicit allowlist prevents an upsert from silently replacing immutable
207+
ownership or secret fields.
202208

203209
The adapter emits one PostgreSQL `INSERT ... ON CONFLICT (...) DO UPDATE ...
204210
WHERE ... RETURNING ...` statement. The normal CRUD predicate and native

‎packages/crud-typeorm/src/bind-typeorm-crud.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ interface BindTypeOrmCrudOptionsBase<
5757
* so a field expressible there is a field a client can change.
5858
*/
5959
readonly scopeCreateFields?: ScopeCreateFields;
60-
/** Atomic upsert primary-conflict and mutable-overwrite persistence paths. */
60+
/** Atomic upsert conflict-target and mutable-overwrite persistence paths. */
6161
readonly upsert?: CrudBindingUpsertOptions;
6262
/** Standard Nest provider form for an adapter; injected repositories remain application-owned. */
6363
readonly adapter: TypeOrmCrudAdapterProvider<EntityType, RecordType>;

‎packages/crud-typeorm/src/typeorm-adapter.ts‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,32 @@ function scalarMutationColumn(
426426
return column === undefined || column.isVirtual || column.isVirtualProperty ? undefined : column;
427427
}
428428

429+
function isUpsertConflictIdentity(
430+
metadata: EntityMetadata,
431+
columns: readonly TypeOrmColumnMetadata[],
432+
): boolean {
433+
if (sameColumnSet(columns, metadata.primaryColumns)) return true;
434+
if (
435+
metadata.uniques.some(
436+
(unique) => unique.deferrable === undefined && sameColumnSet(columns, unique.columns),
437+
)
438+
) {
439+
return true;
440+
}
441+
return metadata.indices.some(
442+
(index) => index.isUnique && index.where === undefined && sameColumnSet(columns, index.columns),
443+
);
444+
}
445+
446+
function sameColumnSet(
447+
left: readonly TypeOrmColumnMetadata[],
448+
right: readonly TypeOrmColumnMetadata[],
449+
): boolean {
450+
if (left.length === 0 || left.length !== right.length) return false;
451+
const expected = new Set(right);
452+
return expected.size === right.length && left.every((column) => expected.has(column));
453+
}
454+
429455
function strongestIsolationLevel(
430456
...levels: readonly (TypeOrmCrudTransactionIsolationLevel | undefined)[]
431457
): TypeOrmCrudTransactionIsolationLevel {
@@ -1288,29 +1314,22 @@ export class TypeOrmCrudAdapter<
12881314
entity: EntityType,
12891315
propertyPaths: readonly string[],
12901316
): string[] {
1291-
const primaryColumns = repository.metadata.primaryColumns;
1292-
if (propertyPaths.length !== primaryColumns.length || propertyPaths.length === 0) {
1293-
throw new CrudAdapterError(
1294-
"unsupported",
1295-
"TypeORM CRUD upsert conflict fields must map to the complete primary identity.",
1296-
);
1297-
}
1298-
if (primaryColumns.some((column) => column.isVirtual || column.isVirtualProperty)) {
1317+
if (propertyPaths.length === 0) {
12991318
throw new CrudAdapterError(
13001319
"unsupported",
1301-
"TypeORM CRUD upsert requires a physical scalar primary identity.",
1320+
"TypeORM CRUD upsert conflict fields must map to a complete primary or unique identity.",
13021321
);
13031322
}
13041323

1305-
const expected = new Set(primaryColumns);
13061324
const seenColumns = new Set<TypeOrmColumnMetadata>();
1325+
const conflictColumns: TypeOrmColumnMetadata[] = [];
13071326
const databaseNames: string[] = [];
13081327
for (const propertyPath of propertyPaths) {
13091328
const column = scalarMutationColumn(repository.metadata, propertyPath);
1310-
if (column === undefined || !expected.has(column) || seenColumns.has(column)) {
1329+
if (column === undefined || !column.isInsert || seenColumns.has(column)) {
13111330
throw new CrudAdapterError(
13121331
"unsupported",
1313-
"TypeORM CRUD upsert conflict fields must map to the complete primary identity.",
1332+
"TypeORM CRUD upsert conflict fields must map to a complete physical primary or unique identity.",
13141333
);
13151334
}
13161335
if (column.getEntityValue(entity) === undefined || column.getEntityValue(entity) === null) {
@@ -1320,12 +1339,13 @@ export class TypeOrmCrudAdapter<
13201339
);
13211340
}
13221341
seenColumns.add(column);
1342+
conflictColumns.push(column);
13231343
databaseNames.push(column.databaseName);
13241344
}
1325-
if (seenColumns.size !== expected.size) {
1345+
if (!isUpsertConflictIdentity(repository.metadata, conflictColumns)) {
13261346
throw new CrudAdapterError(
13271347
"unsupported",
1328-
"TypeORM CRUD upsert conflict fields must map to the complete primary identity.",
1348+
"TypeORM CRUD upsert conflict fields must map to the complete primary identity, a non-deferrable unique constraint, or a non-partial unique index.",
13291349
);
13301350
}
13311351
return databaseNames;

‎packages/crud-typeorm/tests/typeorm-upsert.spec.ts‎

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,24 @@ const UPSERT_INPUT = {
6161
overwriteFields: ["name"],
6262
} as const satisfies CrudUpsertInput<DeepPartial<UpsertEntity>>;
6363

64+
const UNIQUE_UPSERT_INPUT = {
65+
conflictFields: ["tenantId", "immutable"],
66+
predicate: {
67+
kind: "and",
68+
predicates: [
69+
{ kind: "comparison", field: "tenantId", operator: "eq", value: "tenant-a" },
70+
{ kind: "comparison", field: "immutable", operator: "eq", value: "fixed" },
71+
],
72+
},
73+
values: {
74+
tenantId: "tenant-a",
75+
name: "Grace",
76+
secret: "ciphertext",
77+
immutable: "fixed",
78+
},
79+
overwriteFields: ["name"],
80+
} as const satisfies CrudUpsertInput<DeepPartial<UpsertEntity>>;
81+
6482
interface ColumnCapture {
6583
readonly propertyPath: string;
6684
readonly databaseName: string;
@@ -105,6 +123,15 @@ interface UpsertHarnessOptions {
105123
readonly treeType?: string;
106124
readonly inheritancePattern?: string;
107125
readonly childEntityCount?: number;
126+
readonly primaryFields?: readonly Extract<keyof UpsertEntity, string>[];
127+
readonly uniqueConstraints?: readonly {
128+
readonly fields: readonly Extract<keyof UpsertEntity, string>[];
129+
readonly deferrable?: string;
130+
}[];
131+
readonly uniqueIndexes?: readonly {
132+
readonly fields: readonly Extract<keyof UpsertEntity, string>[];
133+
readonly where?: string;
134+
}[];
108135
}
109136

110137
function setProperty(target: Record<string, unknown>, path: string, value: unknown): void {
@@ -126,18 +153,19 @@ function createUpsertHarness(options: UpsertHarnessOptions = {}): UpsertHarness
126153
const prepareHydratedValue = vi.fn((value: unknown, column: ColumnCapture) =>
127154
column.propertyPath === "name" ? `hydrated:${String(value)}` : value,
128155
);
156+
const primaryFields = new Set(options.primaryFields ?? ["tenantId", "id"]);
129157
const columnDefinitions = [
130-
["tenantId", "tenant_id", true, true, true],
131-
["id", "id", true, true, true],
132-
["name", "display_name", false, true, true],
133-
["secret", "secret_ciphertext", false, true, true],
134-
["immutable", "immutable_value", false, true, false],
158+
["tenantId", "tenant_id", true, true],
159+
["id", "id", true, true],
160+
["name", "display_name", true, true],
161+
["secret", "secret_ciphertext", true, true],
162+
["immutable", "immutable_value", true, false],
135163
] as const;
136164
const columns = columnDefinitions.map(
137-
([propertyPath, databaseName, isPrimary, isInsert, isUpdate]): ColumnCapture => ({
165+
([propertyPath, databaseName, isInsert, isUpdate]): ColumnCapture => ({
138166
propertyPath,
139167
databaseName,
140-
isPrimary,
168+
isPrimary: primaryFields.has(propertyPath),
141169
isInsert,
142170
isUpdate,
143171
isVirtual: false,
@@ -147,6 +175,12 @@ function createUpsertHarness(options: UpsertHarnessOptions = {}): UpsertHarness
147175
}),
148176
);
149177
const columnByPath = new Map(columns.map((column) => [column.propertyPath, column]));
178+
const metadataColumns = (fields: readonly Extract<keyof UpsertEntity, string>[]) =>
179+
fields.map((field) => {
180+
const column = columnByPath.get(field);
181+
if (column === undefined) throw new Error(`Unknown harness column '${field}'.`);
182+
return column;
183+
});
150184

151185
let repository: Repository<UpsertEntity>;
152186
const manager = {
@@ -264,6 +298,15 @@ function createUpsertHarness(options: UpsertHarnessOptions = {}): UpsertHarness
264298
tableName: "upsert_entity",
265299
columns,
266300
primaryColumns: columns.filter((column) => column.isPrimary),
301+
uniques: (options.uniqueConstraints ?? []).map((unique) => ({
302+
columns: metadataColumns(unique.fields),
303+
...(unique.deferrable === undefined ? {} : { deferrable: unique.deferrable }),
304+
})),
305+
indices: (options.uniqueIndexes ?? []).map((index) => ({
306+
isUnique: true,
307+
columns: metadataColumns(index.fields),
308+
...(index.where === undefined ? {} : { where: index.where }),
309+
})),
267310
findColumnWithPropertyPath: (path: string) => columnByPath.get(path),
268311
findColumnWithPropertyPathStrict: (path: string) => columnByPath.get(path),
269312
create: () => ({}),
@@ -352,6 +395,28 @@ describe("TypeOrmCrudAdapter atomic upsert", () => {
352395
);
353396
});
354397

398+
it.each(["constraint", "index"] as const)(
399+
"accepts a complete non-primary unique %s while the generated primary value is absent",
400+
async (kind) => {
401+
const unique = { fields: ["tenantId", "immutable"] as const };
402+
const harness = createUpsertHarness({
403+
primaryFields: ["id"],
404+
...(kind === "constraint" ? { uniqueConstraints: [unique] } : { uniqueIndexes: [unique] }),
405+
});
406+
407+
const result = await selectedAdapter(harness).upsert(UNIQUE_UPSERT_INPUT, context());
408+
409+
expect(harness.capture.inserts[0]).toEqual(
410+
expect.objectContaining({
411+
conflict: ["tenant_id", "immutable_value"],
412+
overwrite: ["display_name"],
413+
values: expect.not.objectContaining({ id: expect.anything() }),
414+
}),
415+
);
416+
expect(result).toMatchObject({ id: "item-1", name: "hydrated:Grace" });
417+
},
418+
);
419+
355420
it("returns null without hydrating when the conflicting row fails authorization", async () => {
356421
const harness = createUpsertHarness({ returned: [] });
357422
const result = await selectedAdapter(harness).upsert(UPSERT_INPUT, context());
@@ -400,7 +465,7 @@ describe("TypeOrmCrudAdapter atomic upsert", () => {
400465
});
401466
});
402467

403-
it("rejects partial, duplicated, non-primary, and absent conflict paths before DML", async () => {
468+
it("rejects partial, duplicated, non-unique, and absent conflict paths before DML", async () => {
404469
const cases: readonly CrudUpsertInput<DeepPartial<UpsertEntity>>[] = [
405470
{ ...UPSERT_INPUT, conflictFields: ["tenantId"] },
406471
{ ...UPSERT_INPUT, conflictFields: ["tenantId", "tenantId"] },
@@ -425,6 +490,31 @@ describe("TypeOrmCrudAdapter atomic upsert", () => {
425490
}
426491
});
427492

493+
it("rejects deferrable unique constraints and partial unique indexes before DML", async () => {
494+
const cases: readonly UpsertHarnessOptions[] = [
495+
{
496+
primaryFields: ["id"],
497+
uniqueConstraints: [
498+
{ fields: ["tenantId", "immutable"], deferrable: "INITIALLY DEFERRED" },
499+
],
500+
},
501+
{
502+
primaryFields: ["id"],
503+
uniqueIndexes: [
504+
{ fields: ["tenantId", "immutable"], where: '"immutable_value" IS NOT NULL' },
505+
],
506+
},
507+
];
508+
509+
for (const options of cases) {
510+
const harness = createUpsertHarness(options);
511+
await expect(
512+
selectedAdapter(harness).upsert(UNIQUE_UPSERT_INPUT, context()),
513+
).rejects.toMatchObject({ code: "unsupported" } satisfies Partial<CrudAdapterError>);
514+
expect(harness.capture.inserts).toHaveLength(0);
515+
}
516+
});
517+
428518
it("rejects primary, immutable, unknown, and duplicated overwrite paths before DML", async () => {
429519
for (const overwriteFields of [["id"], ["immutable"], ["missing"], ["name", "name"]] as const) {
430520
const harness = createUpsertHarness();

‎tests/postgres/typeorm-upsert.postgres.spec.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
DataSource,
88
Entity,
99
type Logger,
10-
PrimaryColumn,
10+
PrimaryGeneratedColumn,
1111
type QueryRunner,
12+
Unique,
1213
type ValueTransformer,
1314
} from "typeorm";
1415
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
@@ -34,14 +35,18 @@ const secretTransformer: ValueTransformer = {
3435
};
3536

3637
@Entity({ name: TABLE, synchronize: false })
38+
@Unique("crud_pg_typeorm_upsert_items_identity_unique", ["tenantId", "viewerUserId", "serverId"])
3739
class UpsertItem {
38-
@PrimaryColumn({ name: "tenant_id", type: "text" })
40+
@PrimaryGeneratedColumn("uuid", { name: "id" })
41+
readonly id!: string;
42+
43+
@Column({ name: "tenant_id", type: "text" })
3944
readonly tenantId!: string;
4045

41-
@PrimaryColumn({ name: "viewer_user_id", type: "text" })
46+
@Column({ name: "viewer_user_id", type: "text" })
4247
readonly viewerUserId!: string;
4348

44-
@PrimaryColumn({ name: "server_id", type: "text" })
49+
@Column({ name: "server_id", type: "text" })
4550
readonly serverId!: string;
4651

4752
@Column({ name: "display_name", type: "text", transformer: visibleTransformer })
@@ -72,6 +77,7 @@ class QueryCaptureLogger implements Logger {
7277
}
7378

7479
const COLUMNS = {
80+
id: "id",
7581
tenantId: "tenantId",
7682
viewerUserId: "viewerUserId",
7783
serverId: "serverId",
@@ -93,6 +99,7 @@ function selectedAdapter(authorizedTenant = "tenant-a") {
9399
repository: source().getRepository(UpsertItem),
94100
columns: COLUMNS,
95101
select: {
102+
id: true,
96103
tenantId: true,
97104
viewerUserId: true,
98105
serverId: true,
@@ -169,13 +176,15 @@ describe.skipIf(skipPostgres)("TypeORM atomic upsert", () => {
169176
await adminPool.query(`
170177
DROP TABLE IF EXISTS ${TABLE};
171178
CREATE TABLE ${TABLE} (
179+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
172180
tenant_id text NOT NULL,
173181
viewer_user_id text NOT NULL,
174182
server_id text NOT NULL,
175183
display_name text NOT NULL,
176184
allowed_tools jsonb,
177185
secret_ciphertext text NOT NULL,
178-
PRIMARY KEY (tenant_id, viewer_user_id, server_id)
186+
CONSTRAINT crud_pg_typeorm_upsert_items_identity_unique
187+
UNIQUE (tenant_id, viewer_user_id, server_id)
179188
)
180189
`);
181190
dataSource = await new DataSource({
@@ -278,6 +287,7 @@ describe.skipIf(skipPostgres)("TypeORM atomic upsert", () => {
278287
repository: source().getRepository(UpsertItem),
279288
columns: COLUMNS,
280289
select: {
290+
id: true,
281291
tenantId: true,
282292
viewerUserId: true,
283293
serverId: true,

0 commit comments

Comments
 (0)