@@ -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+
6482interface 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
110137function 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 ( ) ;
0 commit comments