Skip to content

Commit f55e260

Browse files
feat: Generated fields (#390)
1 parent b89fcab commit f55e260

File tree

2 files changed

+221
-35
lines changed

2 files changed

+221
-35
lines changed

src/migrations/generate.ts

Lines changed: 220 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -160,35 +160,27 @@ export class MigrationGenerator {
160160
);
161161

162162
// Update fields
163-
const existingFields = model.fields.filter((field) => {
163+
const rawExistingFields = model.fields.filter((field) => {
164+
if (!field.generateAs) {
165+
return false;
166+
}
167+
164168
const col = this.getColumn(model.name, field.kind === 'relation' ? `${field.name}Id` : field.name);
165169
if (!col) {
166170
return false;
167171
}
168172

169-
if ((!field.nonNull && !col.is_nullable) || (field.nonNull && col.is_nullable)) {
173+
if (col.generation_expression !== field.generateAs) {
170174
return true;
171175
}
172176

173-
if (!field.kind || field.kind === 'primitive') {
174-
if (field.type === 'Int') {
175-
if (col.data_type !== 'integer') {
176-
return true;
177-
}
178-
}
179-
if (field.type === 'Float') {
180-
if (field.double) {
181-
if (col.data_type !== 'double precision') {
182-
return true;
183-
}
184-
} else if (col.data_type !== 'numeric') {
185-
return true;
186-
}
187-
}
188-
}
189-
190-
return false;
177+
return this.hasChanged(model, field);
191178
});
179+
if (rawExistingFields.length) {
180+
this.updateFieldsRaw(model, rawExistingFields, up, down);
181+
}
182+
183+
const existingFields = model.fields.filter((field) => !field.generateAs && this.hasChanged(model, field));
192184
this.updateFields(model, existingFields, up, down);
193185
}
194186

@@ -375,6 +367,10 @@ export class MigrationGenerator {
375367
for (const field of fields) {
376368
alter.push(() => this.column(field, { setNonNull: field.defaultValue !== undefined }));
377369

370+
if (field.generateAs) {
371+
continue;
372+
}
373+
378374
// If the field is not nullable but has no default, write placeholder code
379375
if (field.nonNull && field.defaultValue === undefined) {
380376
updates.push(() => this.writer.write(`${field.name}: 'TODO',`).newLine());
@@ -405,13 +401,63 @@ export class MigrationGenerator {
405401

406402
down.push(() => {
407403
this.alterTable(model.name, () => {
408-
for (const { kind, name } of fields) {
404+
for (const { kind, name } of fields.toReversed()) {
409405
this.dropColumn(kind === 'relation' ? `${name}Id` : name);
410406
}
411407
});
412408
});
413409
}
414410

411+
private updateFieldsRaw(model: EntityModel, fields: EntityField[], up: Callbacks, down: Callbacks) {
412+
if (!fields.length) {
413+
return;
414+
}
415+
416+
up.push(() => {
417+
this.alterTableRaw(model.name, () => {
418+
for (const [index, field] of fields.entries()) {
419+
this.columnRaw(field, { alter: true }, index);
420+
}
421+
});
422+
});
423+
424+
down.push(() => {
425+
this.alterTableRaw(model.name, () => {
426+
for (const [index, field] of fields.entries()) {
427+
this.columnRaw(field, { alter: true }, index);
428+
}
429+
});
430+
});
431+
432+
if (isUpdatableModel(model)) {
433+
const updatableFields = fields.filter(isUpdatableField);
434+
if (!updatableFields.length) {
435+
return;
436+
}
437+
438+
up.push(() => {
439+
this.alterTable(`${model.name}Revision`, () => {
440+
for (const [index, field] of updatableFields.entries()) {
441+
this.columnRaw(field, { alter: true }, index);
442+
}
443+
});
444+
});
445+
446+
down.push(() => {
447+
this.alterTable(`${model.name}Revision`, () => {
448+
for (const [index, field] of updatableFields.entries()) {
449+
this.columnRaw(
450+
field,
451+
{ alter: true },
452+
index,
453+
summonByName(this.columns[model.name], field.kind === 'relation' ? `${field.name}Id` : field.name),
454+
);
455+
}
456+
});
457+
});
458+
}
459+
}
460+
415461
private updateFields(model: EntityModel, fields: EntityField[], up: Callbacks, down: Callbacks) {
416462
if (!fields.length) {
417463
return;
@@ -572,6 +618,12 @@ export class MigrationGenerator {
572618
.blankLine();
573619
}
574620

621+
private alterTableRaw(table: string, block: () => void) {
622+
this.writer.write(`await knex.raw('ALTER TABLE "${table}"`);
623+
block();
624+
this.writer.write(`');`).newLine().blankLine();
625+
}
626+
575627
private alterTable(table: string, block: () => void) {
576628
return this.writer
577629
.write(`await knex.schema.alterTable('${table}', (table) => `)
@@ -605,29 +657,125 @@ export class MigrationGenerator {
605657
return value;
606658
}
607659

660+
private columnRaw(
661+
{ name, ...field }: EntityField,
662+
{ setNonNull = true, alter = false } = {},
663+
index: number,
664+
toColumn?: Column,
665+
) {
666+
const nonNull = () => {
667+
if (setNonNull) {
668+
if (toColumn) {
669+
if (toColumn.is_nullable) {
670+
return false;
671+
}
672+
673+
return true;
674+
}
675+
if (field.nonNull) {
676+
return true;
677+
}
678+
679+
return false;
680+
}
681+
};
682+
const kind = field.kind;
683+
if (field.generateAs) {
684+
let type = '';
685+
switch (kind) {
686+
case undefined:
687+
case 'primitive':
688+
switch (field.type) {
689+
case 'Float':
690+
type = `decimal(${field.precision ?? 'undefined'}, ${field.scale ?? 'undefined'})`;
691+
break;
692+
default:
693+
throw new Error(`Generated columns of kind ${kind} and type ${field.type} are not supported yet.`);
694+
}
695+
break;
696+
default:
697+
throw new Error(`Generated columns of kind ${kind} are not supported yet.`);
698+
}
699+
if (index) {
700+
this.writer.write(`,`);
701+
}
702+
if (alter) {
703+
this.writer.write(` ALTER COLUMN "${name}" TYPE ${type}`);
704+
if (setNonNull) {
705+
if (nonNull()) {
706+
this.writer.write(`, ALTER COLUMN "${name}" SET NOT NULL`);
707+
} else {
708+
this.writer.write(`, ALTER COLUMN "${name}" DROP NOT NULL`);
709+
}
710+
}
711+
this.writer.write(`, ALTER COLUMN "${name}" SET EXPRESSION AS (${field.generateAs})`);
712+
} else {
713+
this.writer.write(
714+
`${alter ? 'ALTER' : 'ADD'} COLUMN "${name}" ${type}${nonNull() ? ' not null' : ''} GENERATED ALWAYS AS (${field.generateAs}) STORED`,
715+
);
716+
}
717+
718+
return;
719+
}
720+
721+
throw new Error(`Only generated columns can be created with columnRaw`);
722+
}
723+
608724
private column(
609725
{ name, primary, list, ...field }: EntityField,
610726
{ setUnique = true, setNonNull = true, alter = false, foreign = true, setDefault = true } = {},
611727
toColumn?: Column,
612728
) {
613-
const col = (what?: string) => {
614-
if (what) {
615-
this.writer.write(what);
616-
}
729+
const nonNull = () => {
617730
if (setNonNull) {
618731
if (toColumn) {
619732
if (toColumn.is_nullable) {
620-
this.writer.write(`.nullable()`);
621-
} else {
622-
this.writer.write('.notNullable()');
623-
}
624-
} else {
625-
if (field.nonNull) {
626-
this.writer.write(`.notNullable()`);
627-
} else {
628-
this.writer.write('.nullable()');
733+
return false;
629734
}
735+
736+
return true;
630737
}
738+
if (field.nonNull) {
739+
return true;
740+
}
741+
742+
return false;
743+
}
744+
};
745+
const kind = field.kind;
746+
if (field.generateAs) {
747+
let type = '';
748+
switch (kind) {
749+
case undefined:
750+
case 'primitive':
751+
switch (field.type) {
752+
case 'Float':
753+
type = `decimal(${field.precision ?? 'undefined'}, ${field.scale ?? 'undefined'})`;
754+
break;
755+
default:
756+
throw new Error(`Generated columns of kind ${kind} and type ${field.type} are not supported yet.`);
757+
}
758+
break;
759+
default:
760+
throw new Error(`Generated columns of kind ${kind} are not supported yet.`);
761+
}
762+
this.writer.write(
763+
`table.specificType('${name}', '${type}${nonNull() ? ' not null' : ''} GENERATED ALWAYS AS (${field.generateAs}) STORED')`,
764+
);
765+
if (alter) {
766+
this.writer.write('.alter()');
767+
}
768+
this.writer.write(';').newLine();
769+
770+
return;
771+
}
772+
773+
const col = (what?: string) => {
774+
if (what) {
775+
this.writer.write(what);
776+
}
777+
if (setNonNull) {
778+
this.writer.write(nonNull() ? '.notNullable()' : '.nullable()');
631779
}
632780
if (setDefault && field.defaultValue !== undefined) {
633781
this.writer.write(`.defaultTo(${this.value(field.defaultValue)})`);
@@ -642,7 +790,6 @@ export class MigrationGenerator {
642790
}
643791
this.writer.write(';').newLine();
644792
};
645-
const kind = field.kind;
646793
switch (kind) {
647794
case undefined:
648795
case 'primitive':
@@ -716,6 +863,44 @@ export class MigrationGenerator {
716863
private getColumn(tableName: string, columnName: string) {
717864
return this.columns[tableName].find((col) => col.name === columnName);
718865
}
866+
867+
private hasChanged(model: EntityModel, field: EntityField) {
868+
const col = this.getColumn(model.name, field.kind === 'relation' ? `${field.name}Id` : field.name);
869+
if (!col) {
870+
return false;
871+
}
872+
873+
if (field.generateAs) {
874+
if (col.generation_expression !== field.generateAs) {
875+
throw new Error(
876+
`Column ${col.name} has specific type ${col.generation_expression} but expected ${field.generateAs}`,
877+
);
878+
}
879+
}
880+
881+
if ((!field.nonNull && !col.is_nullable) || (field.nonNull && col.is_nullable)) {
882+
return true;
883+
}
884+
885+
if (!field.kind || field.kind === 'primitive') {
886+
if (field.type === 'Int') {
887+
if (col.data_type !== 'integer') {
888+
return true;
889+
}
890+
}
891+
if (field.type === 'Float') {
892+
if (field.double) {
893+
if (col.data_type !== 'double precision') {
894+
return true;
895+
}
896+
} else if (col.data_type !== 'numeric') {
897+
return true;
898+
}
899+
}
900+
}
901+
902+
return false;
903+
}
719904
}
720905

721906
export const getMigrationDate = () => {

src/models/model-definitions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export type EntityFieldDefinition = FieldDefinitionBase &
9090
indent?: boolean;
9191
// If true the field is hidden in the admin interface
9292
hidden?: boolean;
93+
generateAs?: string;
9394

9495
// Temporary fields for the generation of migrations
9596
deleted?: true;

0 commit comments

Comments
 (0)