Add native schema migration capabilities (#7939)

* Add snapshot creation command

* Read and start diffing snapshot

* Add apply snapshot functionality

* Fix cli invocation

* Add log messages

* Fix duplicated if check

* Add (minimal) docs on schema migrations

* Fix missing import

* Update api/src/utils/apply-snapshot.ts

Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>

* Appease to Nicola's programming professor

Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>
This commit is contained in:
Rijk van Zanten
2021-09-13 17:15:04 -04:00
committed by GitHub
parent 185e8b5db7
commit ce104b6a9c
18 changed files with 603 additions and 7 deletions

View File

@@ -0,0 +1,112 @@
import { Snapshot, SnapshotDiff, SchemaOverview, Relation } from '../types';
import { getSnapshot } from './get-snapshot';
import { getSnapshotDiff } from './get-snapshot-diff';
import { Knex } from 'knex';
import getDatabase from '../database';
import { getSchema } from './get-schema';
import { CollectionsService, FieldsService, RelationsService } from '../services';
import { set } from 'lodash';
import { DiffNew } from 'deep-diff';
import { Field } from '@directus/shared/types';
export async function applySnapshot(
snapshot: Snapshot,
options?: { database?: Knex; schema?: SchemaOverview; current?: Snapshot; diff?: SnapshotDiff }
): Promise<void> {
const database = options?.database ?? getDatabase();
const schema = options?.schema ?? (await getSchema({ database }));
const current = options?.current ?? (await getSnapshot({ database, schema }));
const snapshotDiff = options?.diff ?? getSnapshotDiff(current, snapshot);
await database.transaction(async (trx) => {
const collectionsService = new CollectionsService({ knex: trx, schema });
for (const { collection, diff } of snapshotDiff.collections) {
if (diff?.[0].kind === 'D') {
await collectionsService.deleteOne(collection);
}
if (diff?.[0].kind === 'N' && diff[0].rhs) {
// We'll nest the to-be-created fields in the same collection creation, to prevent
// creating a collection without a primary key
const fields = snapshotDiff.fields
.filter((fieldDiff) => fieldDiff.collection === collection)
.map((fieldDiff) => (fieldDiff.diff[0] as DiffNew<Field>).rhs);
await collectionsService.createOne({
...diff[0].rhs,
fields,
});
// Now that the fields are in for this collection, we can strip them from the field
// edits
snapshotDiff.fields = snapshotDiff.fields.filter((fieldDiff) => fieldDiff.collection !== collection);
}
if (diff?.[0].kind === 'E') {
const updates = diff.reduce((acc, edit) => {
if (edit.kind !== 'E') return acc;
set(acc, edit.path!, edit.rhs);
return acc;
}, {});
await collectionsService.updateOne(collection, updates);
}
}
const fieldsService = new FieldsService({ knex: trx, schema: await getSchema({ database: trx }) });
for (const { collection, field, diff } of snapshotDiff.fields) {
if (diff?.[0].kind === 'N') {
await fieldsService.createField(collection, (diff[0] as DiffNew<Field>).rhs);
}
if (diff?.[0].kind === 'E') {
const updates = diff.reduce((acc, edit) => {
if (edit.kind !== 'E') return acc;
set(acc, edit.path!, edit.rhs);
return acc;
}, {});
await fieldsService.updateField(collection, {
field,
type: 'unknown', // If the type was updated, the updates spread will overwrite it
...updates,
});
}
if (diff?.[0].kind === 'D') {
await fieldsService.deleteField(collection, field);
// Field deletion also cleans up the relationship. We should ignore any relationship
// changes attached to this now non-existing field
snapshotDiff.relations = snapshotDiff.relations.filter(
(relation) => (relation.collection === collection && relation.field === field) === false
);
}
}
const relationsService = new RelationsService({ knex: trx, schema: await getSchema({ database: trx }) });
for (const { collection, field, diff } of snapshotDiff.relations) {
if (diff?.[0].kind === 'N') {
await relationsService.createOne((diff[0] as DiffNew<Relation>).rhs);
}
if (diff?.[0].kind === 'E') {
const updates = diff.reduce((acc, edit) => {
if (edit.kind !== 'E') return acc;
set(acc, edit.path!, edit.rhs);
return acc;
}, {});
await relationsService.updateOne(collection, field, updates);
}
if (diff?.[0].kind === 'D') {
await relationsService.deleteOne(collection, field);
}
}
});
}

View File

@@ -0,0 +1,116 @@
import { Snapshot, SnapshotDiff } from '../types';
import { diff } from 'deep-diff';
import { orderBy } from 'lodash';
export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDiff {
const diffedSnapshot: SnapshotDiff = {
collections: orderBy(
[
...current.collections.map((currentCollection) => {
const afterCollection = after.collections.find(
(afterCollection) => afterCollection.collection === currentCollection.collection
);
return {
collection: currentCollection.collection,
diff: diff(currentCollection, afterCollection),
};
}),
...after.collections
.filter((afterCollection) => {
const currentCollection = current.collections.find(
(currentCollection) => currentCollection.collection === afterCollection.collection
);
return !!currentCollection === false;
})
.map((afterCollection) => ({
collection: afterCollection.collection,
diff: diff(undefined, afterCollection),
})),
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['collections'],
'collection'
),
fields: orderBy(
[
...current.fields.map((currentField) => {
const afterField = after.fields.find(
(afterField) => afterField.collection === currentField.collection && afterField.field === currentField.field
);
return {
collection: currentField.collection,
field: currentField.field,
diff: diff(currentField, afterField),
};
}),
...after.fields
.filter((afterField) => {
const currentField = current.fields.find(
(currentField) =>
currentField.collection === afterField.collection && afterField.field === currentField.field
);
return !!currentField === false;
})
.map((afterField) => ({
collection: afterField.collection,
field: afterField.field,
diff: diff(undefined, afterField),
})),
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['fields'],
['collection']
),
relations: orderBy(
[
...current.relations.map((currentRelation) => {
const afterRelation = after.relations.find(
(afterRelation) =>
afterRelation.collection === currentRelation.collection && afterRelation.field === currentRelation.field
);
return {
collection: currentRelation.collection,
field: currentRelation.field,
related_collection: currentRelation.related_collection,
diff: diff(currentRelation, afterRelation),
};
}),
...after.relations
.filter((afterRelation) => {
const currentRelation = current.relations.find(
(currentRelation) =>
currentRelation.collection === afterRelation.collection && afterRelation.field === currentRelation.field
);
return !!currentRelation === false;
})
.map((afterRelation) => ({
collection: afterRelation.collection,
field: afterRelation.field,
related_collection: afterRelation.related_collection,
diff: diff(undefined, afterRelation),
})),
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['relations'],
['collection']
),
};
/**
* When you delete a collection, we don't have to individually drop all the fields/relations as well
*/
const deletedCollections = diffedSnapshot.collections
.filter((collection) => collection.diff?.[0].kind === 'D')
.map(({ collection }) => collection);
diffedSnapshot.fields = diffedSnapshot.fields.filter(
(field) => deletedCollections.includes(field.collection) === false
);
diffedSnapshot.relations = diffedSnapshot.relations.filter(
(relation) => deletedCollections.includes(relation.collection) === false
);
return diffedSnapshot;
}

View File

@@ -0,0 +1,34 @@
import getDatabase from '../database';
import { getSchema } from './get-schema';
import { CollectionsService, FieldsService, RelationsService } from '../services';
import { version } from '../../package.json';
import { SchemaOverview, Snapshot } from '../types';
import { Knex } from 'knex';
export async function getSnapshot(options?: { database?: Knex; schema?: SchemaOverview }): Promise<Snapshot> {
const database = options?.database ?? getDatabase();
const schema = options?.schema ?? (await getSchema({ database }));
const collectionsService = new CollectionsService({ knex: database, schema });
const fieldsService = new FieldsService({ knex: database, schema });
const relationsService = new RelationsService({ knex: database, schema });
const [collections, fields, relations] = await Promise.all([
collectionsService.readByQuery(),
fieldsService.readAll(),
relationsService.readAll(),
]);
return {
version: 1,
directus: version,
collections: collections.filter((item: any) => excludeSystem(item)),
fields: fields.filter((item: any) => excludeSystem(item)),
relations: relations.filter((item: any) => excludeSystem(item)),
};
}
function excludeSystem(item: { meta?: { system?: boolean } }) {
if (item?.meta?.system === true) return false;
return true;
}