Skip to content

Commit

Permalink
feat(postgres): allow defining deferred FK constraints (#5384)
Browse files Browse the repository at this point in the history
This PR adds the option to specify constraints as deferrable for
relations.

On OneToOne and ManyToOne relations you can now specify a `deferMode`
property, which can be either `not deferrable` (default), `immediate` or
`deferred`. Those are also covered by a new `DeferMode` enum.

Closes #5306

---------

Co-authored-by: Martin Adámek <banan23@gmail.com>
  • Loading branch information
DASPRiD and B4nan committed Mar 27, 2024
1 parent 791d7d8 commit f42d171
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 7 deletions.
3 changes: 2 additions & 1 deletion packages/core/src/decorators/ManyToOne.ts
@@ -1,7 +1,7 @@
import type { ReferenceOptions } from './Property';
import { MetadataStorage, MetadataValidator } from '../metadata';
import { Utils } from '../utils';
import { ReferenceKind } from '../enums';
import { type DeferMode, ReferenceKind } from '../enums';
import type { AnyEntity, AnyString, EntityKey, EntityName, EntityProperty } from '../typings';

export function ManyToOne<T extends object, O>(
Expand Down Expand Up @@ -30,4 +30,5 @@ export interface ManyToOneOptions<Owner, Target> extends ReferenceOptions<Owner,
referencedColumnNames?: string[];
deleteRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
updateRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
deferMode?: DeferMode;
}
3 changes: 2 additions & 1 deletion packages/core/src/decorators/OneToOne.ts
@@ -1,4 +1,4 @@
import { ReferenceKind } from '../enums';
import { type DeferMode, ReferenceKind } from '../enums';
import { createOneToDecorator, type OneToManyOptions } from './OneToMany';
import type { AnyString, EntityName } from '../typings';

Expand All @@ -20,4 +20,5 @@ export interface OneToOneOptions<Owner, Target> extends Partial<Omit<OneToManyOp
mapToPk?: boolean;
deleteRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
updateRule?: 'cascade' | 'no action' | 'set null' | 'set default' | AnyString;
deferMode?: DeferMode;
}
5 changes: 5 additions & 0 deletions packages/core/src/enums.ts
Expand Up @@ -205,3 +205,8 @@ export interface TransactionOptions {

export abstract class PlainObject {
}

export enum DeferMode {
INITIALLY_IMMEDIATE = 'immediate',
INITIALLY_DEFERRED = 'deferred',
}
10 changes: 9 additions & 1 deletion packages/core/src/typings.ts
@@ -1,5 +1,12 @@
import type { Transaction } from './connections';
import { type Cascade, type EventType, type LoadStrategy, type QueryOrderMap, ReferenceKind } from './enums';
import {
type DeferMode,
type Cascade,
type EventType,
type LoadStrategy,
type QueryOrderMap,
ReferenceKind,
} from './enums';
import {
type AssignOptions,
type Collection,
Expand Down Expand Up @@ -483,6 +490,7 @@ export interface EntityProperty<Owner = any, Target = any> {
userDefined?: boolean;
optional?: boolean; // for ts-morph
ignoreSchemaChanges?: ('type' | 'extra')[];
deferMode?: DeferMode;
}

export class EntityMetadata<T = any> {
Expand Down
6 changes: 6 additions & 0 deletions packages/knex/src/schema/DatabaseTable.ts
Expand Up @@ -158,6 +158,10 @@ export class DatabaseTable {
if (prop.updateRule || prop.cascade.includes(Cascade.PERSIST) || prop.cascade.includes(Cascade.ALL)) {
this.foreignKeys[constraintName].updateRule = prop.updateRule || 'cascade';
}

if (prop.deferMode) {
this.foreignKeys[constraintName].deferMode = prop.deferMode;
}
}

if (prop.index) {
Expand Down Expand Up @@ -599,6 +603,7 @@ export class DatabaseTable {
fkOptions.referencedColumnNames = fk.referencedColumnNames;
fkOptions.updateRule = fk.updateRule?.toLowerCase();
fkOptions.deleteRule = fk.deleteRule?.toLowerCase();
fkOptions.deferMode = fk.deferMode;
fkOptions.columnTypes = fk.columnNames.map(c => this.getColumn(c)!.type);

const columnOptions: Partial<EntityProperty> = {};
Expand Down Expand Up @@ -662,6 +667,7 @@ export class DatabaseTable {
fkOptions.referencedColumnNames = fk.referencedColumnNames;
fkOptions.updateRule = fk.updateRule?.toLowerCase();
fkOptions.deleteRule = fk.deleteRule?.toLowerCase();
fkOptions.deferMode = fk.deferMode;
}

return {
Expand Down
4 changes: 4 additions & 0 deletions packages/knex/src/schema/SchemaComparator.ts
Expand Up @@ -434,6 +434,10 @@ export class SchemaComparator {
return true;
}

if (key1.deferMode !== key2.deferMode) {
return true;
}

const defaultRule = ['restrict', 'no action'];
const rule = (key: ForeignKey, method: 'updateRule' | 'deleteRule') => {
return (key[method] ?? defaultRule[0]).toLowerCase().replace(defaultRule[1], defaultRule[0]);
Expand Down
1 change: 1 addition & 0 deletions packages/knex/src/schema/SchemaHelper.ts
Expand Up @@ -256,6 +256,7 @@ export abstract class SchemaHelper {
referencedColumnNames: [fk.referenced_column_name],
updateRule: fk.update_rule.toLowerCase(),
deleteRule: fk.delete_rule.toLowerCase(),
deferMode: fk.defer_mode,
};
}

Expand Down
4 changes: 4 additions & 0 deletions packages/knex/src/schema/SqlSchemaGenerator.ts
Expand Up @@ -352,6 +352,10 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator<AbstractSqlDrive
if (foreignKey.deleteRule) {
builder.onDelete(foreignKey.deleteRule);
}

if (foreignKey.deferMode) {
builder.deferrable(foreignKey.deferMode);
}
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/knex/src/typings.ts
@@ -1,5 +1,5 @@
import type { Knex } from 'knex';
import type {
DeferMode,
CheckCallback,
Dictionary,
EntityProperty,
Expand All @@ -12,6 +12,7 @@ import type {
AnyEntity,
EntityName,
} from '@mikro-orm/core';
import type { Knex } from 'knex';
import type { JoinType, QueryType } from './query/enums';
import type { DatabaseSchema, DatabaseTable } from './schema';

Expand Down Expand Up @@ -78,6 +79,7 @@ export interface ForeignKey {
referencedColumnNames: string[];
updateRule?: string;
deleteRule?: string;
deferMode?: DeferMode;
}

export interface IndexDef {
Expand Down
8 changes: 6 additions & 2 deletions packages/postgresql/src/PostgreSqlSchemaHelper.ts
@@ -1,4 +1,4 @@
import { BigIntType, EnumType, Type, Utils, type Dictionary } from '@mikro-orm/core';
import { BigIntType, EnumType, Type, Utils, type Dictionary, DeferMode } from '@mikro-orm/core';
import {
SchemaHelper,
type AbstractSqlConnection,
Expand Down Expand Up @@ -209,7 +209,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
async getAllForeignKeys(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<Dictionary<ForeignKey>>> {
const sql = `select nsp1.nspname schema_name, cls1.relname table_name, nsp2.nspname referenced_schema_name,
cls2.relname referenced_table_name, a.attname column_name, af.attname referenced_column_name, conname constraint_name,
confupdtype update_rule, confdeltype delete_rule, array_position(con.conkey,a.attnum) as ord
confupdtype update_rule, confdeltype delete_rule, array_position(con.conkey,a.attnum) as ord, condeferrable, condeferred
from pg_attribute a
join pg_constraint con on con.conrelid = a.attrelid AND a.attnum = ANY (con.conkey)
join pg_attribute af on af.attnum = con.confkey[array_position(con.conkey,a.attnum)] AND af.attrelid = con.confrelid
Expand Down Expand Up @@ -239,6 +239,10 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
fk.update_rule = mapReferencialIntegrity(fk.update_rule);
fk.delete_rule = mapReferencialIntegrity(fk.delete_rule);

if (fk.condeferrable) {
fk.defer_mode = fk.condeferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
}

const key = this.getTableKey(fk);
ret[key] ??= [];
ret[key].push(fk);
Expand Down
@@ -0,0 +1,52 @@
import { DeferMode, Entity, ManyToOne, PrimaryKey, MikroORM, Ref, Reference } from '@mikro-orm/postgresql';

@Entity()
class Parent {

@PrimaryKey()
id!: number;

}

@Entity()
class Child {

@PrimaryKey()
id!: number;

@ManyToOne(() => Parent, { ref: true, deferMode: DeferMode.INITIALLY_DEFERRED })
parent!: Ref<Parent>;

}

describe('deferrable constraints in postgres', () => {

let orm: MikroORM;

beforeAll(async () => {
orm = await MikroORM.init({
entities: [Parent, Child],
dbName: `mikro_orm_test_deferrable`,
});
await orm.schema.refreshDatabase();
});
beforeEach(async () => orm.schema.clearDatabase());
afterAll(async () => {
await orm.schema.dropDatabase();
await orm.close(true);
});

test('insert deferred', async () => {
await orm.em.transactional(async em => {
const parent = new Parent();
parent.id = 1;
const child = new Child();
child.id = 1;
child.parent = Reference.createFromPK(Parent, 1);

await em.persistAndFlush(child);
await em.persistAndFlush(parent);
});
});

});
Expand Up @@ -53,3 +53,27 @@ alter table "book" drop column "based_on_id";
"
`;

exports[`updating tables with FKs in postgres schema generator updates foreign keys on deferrable change 1`] = `
"alter table "book" drop constraint "book_author1_pk_foreign";
alter table "book" add constraint "book_author1_pk_foreign" foreign key ("author1_pk") references "author" ("pk") on update cascade deferrable initially deferred ;
"
`;

exports[`updating tables with FKs in postgres schema generator updates foreign keys on deferrable change 2`] = `
"alter table "book" drop constraint "book_author1_pk_foreign";
alter table "book" add constraint "book_author1_pk_foreign" foreign key ("author1_pk") references "author" ("pk") on update cascade deferrable initially immediate ;
"
`;

exports[`updating tables with FKs in postgres schema generator updates foreign keys on deferrable change 3`] = `
"alter table "book" drop constraint "book_author1_pk_foreign";
alter table "book" add constraint "book_author1_pk_foreign" foreign key ("author1_pk") references "author" ("pk") on update cascade;
"
`;
74 changes: 73 additions & 1 deletion tests/features/schema-generator/fk-diffing.postgres.test.ts
@@ -1,4 +1,4 @@
import { Entity, ManyToOne, MikroORM, PrimaryKey, Property } from '@mikro-orm/core';
import { DeferMode, Entity, ManyToOne, MikroORM, PrimaryKey, Property } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';

@Entity({ tableName: 'author' })
Expand Down Expand Up @@ -96,6 +96,39 @@ export class Book3 {

}

@Entity({ tableName: 'book' })
export class Book4 {

@PrimaryKey()
id!: number;

@ManyToOne(() => Author1)
author1!: Author1;

}

@Entity({ tableName: 'book' })
export class Book41 {

@PrimaryKey()
id!: number;

@ManyToOne(() => Author1, { deferMode: DeferMode.INITIALLY_DEFERRED })
author1!: Author1;

}

@Entity({ tableName: 'book' })
export class Book42 {

@PrimaryKey()
id!: number;

@ManyToOne(() => Author1, { deferMode: DeferMode.INITIALLY_IMMEDIATE })
author1!: Author1;

}

describe('dropping tables with FKs in postgres', () => {

test('schema generator removes stale FKs on target table dropping 1', async () => {
Expand Down Expand Up @@ -154,3 +187,42 @@ describe('dropping tables with FKs in postgres', () => {
});

});

describe('updating tables with FKs in postgres', () => {

test('schema generator updates foreign keys on deferrable change', async () => {
const orm = await MikroORM.init({
entities: [Author1, Book3],
dbName: `mikro_orm_test_fk_diffing`,
driver: PostgreSqlDriver,
});
await orm.schema.ensureDatabase();
await orm.schema.execute('drop table if exists author cascade');
await orm.schema.execute('drop table if exists book cascade');
await orm.schema.createSchema();

orm.getMetadata().reset('Book3');
orm.discoverEntity([Book41]);
const diff1 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff1).toMatchSnapshot();
await orm.schema.execute(diff1);
await expect(orm.schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('');

orm.getMetadata().reset('Book41');
orm.discoverEntity([Book42]);
const diff2 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff2).toMatchSnapshot();
await orm.schema.execute(diff2);
await expect(orm.schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('');

orm.getMetadata().reset('Book42');
orm.discoverEntity([Book4]);
const diff3 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff3).toMatchSnapshot();
await orm.schema.execute(diff3);
await expect(orm.schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('');

await orm.close(true);
});

});

0 comments on commit f42d171

Please sign in to comment.