Skip to content

Commit

Permalink
feat(schema): add ability to ignore specific column changes (#3503)
Browse files Browse the repository at this point in the history
```ts
@Property({
  columnType: 'timestamp',
  extra: 'VIRTUAL GENERATED',
  ignoreSchemaChanges: ['type', 'extra'],
})
changingField!: Date;
```

This is useful for situations such as #1904, where `knex` is unable to properly diff the column.

Closes #1904
  • Loading branch information
PenguinToast committed Sep 14, 2022
1 parent 0af0d58 commit 05fb1ce
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 2 deletions.
74 changes: 74 additions & 0 deletions docs/docs/defining-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,80 @@ export abstract class CustomBaseEntity {
}
```

## SQL Generated columns

Knex currently does not support generated columns, so the schema generator
cannot properly diff them. To work around this, we can set `ignoreSchemaChanges`
on a property to avoid a perpetual diff from the schema generator

<Tabs
groupId="entity-def"
defaultValue="reflect-metadata"
values={[
{label: 'reflect-metadata', value: 'reflect-metadata'},
{label: 'ts-morph', value: 'ts-morph'},
{label: 'EntitySchema', value: 'entity-schema'},
]
}>
<TabItem value="reflect-metadata">

```ts title="./entities/Book.ts"
@Entity
export class Book {

@Property()
title!: string;

@Property({
columnType: 'VARCHAR GENERATED ALWAYS AS (LOWER(`title`)) VIRTUAL',
ignoreSchemaChanges: ['type', 'extra'],
})
titleLower!: string;

}
```

</TabItem>
<TabItem value="ts-morph">

```ts title="./entities/Book.ts"
@Entity
export class Book {

@Property()
title!: string;

@Property({
columnType: 'VARCHAR GENERATED ALWAYS AS (LOWER(`title`)) VIRTUAL',
ignoreSchemaChanges: ['type', 'extra'],
})
titleLower!: string;

}
```

</TabItem>
<TabItem value="entity-schema">

```ts title="./entities/Book.ts"
export interface IBook {
title: string;
titleLower: string;
}

export const Book = new EntitySchema<IBook>({
name: 'Book',
properties: {
title: { type: String },
titleLower: { type: String, columnType: 'VARCHAR GENERATED ALWAYS AS (LOWER(`title`)) VIRTUAL', ignoreSchemaChanges: ['type', 'extra'] },
},
});
```

</TabItem>
</Tabs>
```
## Examples of entity definition with various primary keys
### Using id as primary key (SQL drivers)
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/schema-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ Then run this script via `ts-node` (or compile it to plain JS and use `node`):
$ ts-node create-schema
```

## Ignoring specific column changes

When using generated columns, we'll get a perpetual diff on every `SchemaGenerator` run unless we set `ignoreSchemaChanges` to ignore changes to `type` and `extra`.

See the [SQL Generated columns](defining-entities.md#SQL Generated columns) section for more details.

## Limitations of SQLite

There are limitations of SQLite database because of which it behaves differently
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/decorators/Property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type PropertyOptions<T> = {
comment?: string;
/** mysql only */
extra?: string;
ignoreSchemaChanges?: ('type' | 'extra')[];
};

export interface ReferenceOptions<T, O> extends PropertyOptions<O> {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ export interface EntityProperty<T = any> {
extra?: string;
userDefined?: boolean;
optional?: boolean; // for ts-morph
ignoreSchemaChanges?: ('type' | 'extra')[];
}

export class EntityMetadata<T = any> {
Expand Down
1 change: 1 addition & 0 deletions packages/knex/src/schema/DatabaseTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class DatabaseTable {
enumItems: prop.items?.every(Utils.isString) ? prop.items as string[] : undefined,
comment: prop.comment,
extra: prop.extra,
ignoreSchemaChanges: prop.ignoreSchemaChanges,
};
this.columns[field].unsigned ||= this.columns[field].autoincrement;
const defaultValue = this.platform.getSchemaHelper()!.normalizeDefaultValue(prop.defaultRaw!, prop.length);
Expand Down
16 changes: 14 additions & 2 deletions packages/knex/src/schema/SchemaComparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,13 @@ export class SchemaComparator {
}
};

if (columnType1 !== columnType2) {
if (
columnType1 !== columnType2 &&
!(
column1.ignoreSchemaChanges?.includes('type') ||
column2.ignoreSchemaChanges?.includes('type')
)
) {
log(`'type' changed for column ${tableName}.${column1.name}`, { columnType1, columnType2 });
changedProperties.add('type');
}
Expand Down Expand Up @@ -443,7 +449,13 @@ export class SchemaComparator {
changedProperties.add('enumItems');
}

if ((column1.extra || '').toLowerCase() !== (column2.extra || '').toLowerCase()) {
if (
(column1.extra || '').toLowerCase() !== (column2.extra || '').toLowerCase() &&
!(
column1.ignoreSchemaChanges?.includes('extra') ||
column2.ignoreSchemaChanges?.includes('extra')
)
) {
log(`'extra' changed for column ${tableName}.${column1.name}`, { column1, column2 });
changedProperties.add('extra');
}
Expand Down
1 change: 1 addition & 0 deletions packages/knex/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface Column {
unique?: boolean;
/** mysql only */
extra?: string;
ignoreSchemaChanges?: ('type' | 'extra')[];
}

export interface ForeignKey {
Expand Down
114 changes: 114 additions & 0 deletions tests/features/schema-generator/GH1904.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Entity, MikroORM, PrimaryKey, Property } from '@mikro-orm/core';
import type { MySqlDriver } from '@mikro-orm/mysql';

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

@PrimaryKey()
id!: number;

@Property({ columnType: 'int' })
changingField!: number;

}

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

@PrimaryKey()
id!: number;

@Property({ columnType: 'timestamp', ignoreSchemaChanges: ['type'] })
changingField!: Date;

}

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

@PrimaryKey()
id!: number;

@Property({
columnType: 'int',
extra: 'VIRTUAL GENERATED',
ignoreSchemaChanges: ['extra'],
})
changingField!: number;

}

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

@PrimaryKey()
id!: number;

@Property({
columnType: 'timestamp',
extra: 'VIRTUAL GENERATED',
ignoreSchemaChanges: ['extra', 'type'],
})
changingField!: Date;

}

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

@PrimaryKey()
id!: number;

@Property({ columnType: 'timestamp' })
changingField!: Date;

}

describe('ignore specific schema changes (GH 1904)', () => {
let orm: MikroORM<MySqlDriver>;

beforeEach(async () => {
orm = await MikroORM.init({
entities: [Book1],
dbName: `mikro_orm_test_gh_1904`,
type: 'mysql',
port: 3308,
});
await orm.schema.refreshDatabase();
});

afterEach(() => orm.close(true));

test('schema generator respects ignoreSchemaChanges for `type`', async () => {
const diff0 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff0).toBe('');
await orm.discoverEntity(Book2);
orm.getMetadata().reset('Book1');
const diff1 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff1).toBe('');

// Once we remove ignoreSchemaChanges, we should see a diff again.
await orm.discoverEntity(Book5);
orm.getMetadata().reset('Book2');
const diff2 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff2).toBe('alter table `book` modify `changing_field` timestamp not null;\n\n');
});

test('schema generator respects ignoreSchemaChanges for `extra`', async () => {
const diff0 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff0).toBe('');
await orm.discoverEntity(Book3);
orm.getMetadata().reset('Book1');
const diff1 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff1).toBe('');
});

test('schema generator respects ignoreSchemaChanges for `extra` and `type`', async () => {
const diff0 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff0).toBe('');
await orm.discoverEntity(Book4);
orm.getMetadata().reset('Book1');
const diff1 = await orm.schema.getUpdateSchemaSQL({ wrap: false });
expect(diff1).toBe('');
});
});

0 comments on commit 05fb1ce

Please sign in to comment.