mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Expose schema endpoints (#14713)
* Expose schema endpoints * respond with 204 * fix payload for export flag * allow export as YAML * use SchemaService * move getStringFromStream to utils * clearer exceptions with logs * check empty request body * specs * add bypassCache flag and remove flushCaches calls * Identify integer typed fields in Oracle * Fix uuid foreign keys type for Oracle * Detect autoincrement for CockroachDB * Bypass cache for fields and relations services * Add same DB tests * Test if working for all vendors * Add schema snapshot testing into sequential flow * Fix schema re-apply attempts when it's a different database vendor (#14816) * prevent diffing on certain properties * fix apply-snapshot test * prevent updates to autoincrement fields' schema * sanitize relations * remove `colleciton.schema.sql` parsing in test It is now being sanitized by `sanitizeCollection()` function in get-snapshot-diff.ts * Change concurrency group to not cancel test running on main * remove multipart for apply & add hash checking * check incoming version & add force query param * refine error message & stub for nested hash * add `vendor` top-level property as an additional safety net for cross db vendor applies * sanitize generated schema snapshots * snapshot joi validation * diff joi validation * minor cleanup * extract applyDiff & use deep-diff applyChange * use applyDiff in schema service * Mark vendor as optional * Update tests to apply diffs * move force flag check into service * Patch mssql vendor typo * Set relation no action in cyclic relations * Update mysql timestamp default value * Oracle cannot define no action * Update oracle timestamp type default values * add hash checking for outdated diffs * fix diff service & endpoint * Add hashes when returning after * Fix self referencing relations for Oracle * Add temp fix for CURRENT_TIMESTAMP defaults in Oracle * clean up driver and database client types * only require diff hash if not kind N * update hash comparison logic for create/delete * Set no action constraint for self referencing M2M in MSSQL * Add basic hash check tests * omit default value when hashing auto increments Specifically for CockroachDB with differing sequences * add vendor check * update specs * Validate vendors with type definition * Spread the vendors input array * re-add Export component * re-add js-yaml to root * Propagate mutation options for schema apply * Verify that snapshots differ and clear cache before hash tests * Fix unit test * Revert temp fix for CURRENT_TIMESTAMP defaults in Oracle * Define and reuse type for export format * Define and reuse list of database clients * change `were` to `was` * change `where` to `were` * add some empty lines for readability * tweak exception message * fix test * use object-hash * use boolean to check whether file is included * simplify request content type check * throw error when multiple files were uploaded * use nullish coalesce instead of short circuit * Update api/src/services/schema.ts Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> * Remove unnecessary `Omit` on `SnapshotWithHash` type * Revert "Remove unnecessary `Omit` on `SnapshotWithHash` type" This reverts commitd22ac771ec. * check empty snapshot earlier * use allow-list logic via pick instead of omit * Update api/src/services/schema.ts Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch> * Move to own /schema endpoint controller * Fix refs to schema endpoint * move streamToString to utils package * move get-versioned-hash and add test * extract kind into an enum * Fix mysql5 timestamp value * Fix test collection existing on rerun * resolve TODO comment in blackbox test * Drop deep level hashes in diff These hashes are used only for more accurate error reporting but are otherwise superfluous, since changes are already detected by the top level hash. Therefore we remove them in favor of a simpler diff format and implementation. * Revert schema "fix" for createItem, add comment * Strict diff schema validation * Revert CrDB auto-increment detection patch in816c998* Clear systemCache to expose newly added fields * Use DiffKind constants * Extract diff & snapshot validation into own tested utils * Apply suggestions from @azrikahar * Update knex-schema-inspector to 3.0.1 Includes the fix for CrDB auto-increment detection (knex/knex-schema-inspector#135) * Update knex-schema-inspector in packages * Update lock file * add test for schema service * add test for export service * add relevant tests to modified util functions * fix csv test to account for os end of line * fix files controller test * dedupe test data for schema service * Align schema specs with docs * Update api/src/controllers/schema.ts * Revert testing for all vendors --------- Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> Co-authored-by: ian <licitdev@gmail.com> Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
This commit is contained in:
28
api/src/utils/apply-diff.test.ts
Normal file
28
api/src/utils/apply-diff.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Diff } from 'deep-diff';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SnapshotField } from '../types/snapshot';
|
||||
|
||||
import { isNestedMetaUpdate } from './apply-diff';
|
||||
|
||||
describe('isNestedMetaUpdate', () => {
|
||||
it.each([
|
||||
{ kind: 'E', path: ['meta', 'options', 'option_a'], rhs: {} },
|
||||
{ kind: 'A', path: ['meta', 'options', 'option_a'], rhs: [] },
|
||||
] as Diff<SnapshotField>[])('Returns false when diff is kind $kind', (diff) => {
|
||||
expect(isNestedMetaUpdate(diff)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: 'N', path: ['schema', 'default_value'], rhs: {} },
|
||||
{ kind: 'D', path: ['schema'], lhs: {} },
|
||||
] as Diff<SnapshotField>[])('Returns false when diff path is not nested in meta', (diff) => {
|
||||
expect(isNestedMetaUpdate(diff)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: 'N', path: ['meta', 'options', 'option_a'], rhs: { test: 'value' } },
|
||||
{ kind: 'D', path: ['meta', 'options', 'option_b'], lhs: {} },
|
||||
] as Diff<SnapshotField>[])('Returns true when diff path is nested in meta', (diff) => {
|
||||
expect(isNestedMetaUpdate(diff)).toBe(true);
|
||||
});
|
||||
});
|
||||
317
api/src/utils/apply-diff.ts
Normal file
317
api/src/utils/apply-diff.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Field, Relation, SchemaOverview } from '@directus/shared/types';
|
||||
import { Knex } from 'knex';
|
||||
import { CollectionsService, FieldsService, RelationsService } from '../services';
|
||||
import {
|
||||
ActionEventParams,
|
||||
Collection,
|
||||
DiffKind,
|
||||
MutationOptions,
|
||||
Snapshot,
|
||||
SnapshotDiff,
|
||||
SnapshotField,
|
||||
} from '../types';
|
||||
import { getSchema } from './get-schema';
|
||||
import getDatabase from '../database';
|
||||
import { applyChange, Diff, DiffDeleted, DiffNew } from 'deep-diff';
|
||||
import { cloneDeep, merge, set } from 'lodash';
|
||||
import logger from '../logger';
|
||||
import emitter from '../emitter';
|
||||
import { clearSystemCache } from '../cache';
|
||||
|
||||
type CollectionDelta = {
|
||||
collection: string;
|
||||
diff: Diff<Collection | undefined>[];
|
||||
};
|
||||
|
||||
export async function applyDiff(
|
||||
currentSnapshot: Snapshot,
|
||||
snapshotDiff: SnapshotDiff,
|
||||
options?: { database?: Knex; schema?: SchemaOverview }
|
||||
): Promise<void> {
|
||||
const database = options?.database ?? getDatabase();
|
||||
const schema = options?.schema ?? (await getSchema({ database, bypassCache: true }));
|
||||
|
||||
const nestedActionEvents: ActionEventParams[] = [];
|
||||
const mutationOptions: MutationOptions = {
|
||||
autoPurgeSystemCache: false,
|
||||
bypassEmitAction: (params) => nestedActionEvents.push(params),
|
||||
};
|
||||
|
||||
await database.transaction(async (trx) => {
|
||||
const collectionsService = new CollectionsService({ knex: trx, schema });
|
||||
|
||||
const getNestedCollectionsToCreate = (currentLevelCollection: string) =>
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => (diff[0] as DiffNew<Collection>).rhs?.meta?.group === currentLevelCollection
|
||||
) as CollectionDelta[];
|
||||
|
||||
const getNestedCollectionsToDelete = (currentLevelCollection: string) =>
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => (diff[0] as DiffDeleted<Collection>).lhs?.meta?.group === currentLevelCollection
|
||||
) as CollectionDelta[];
|
||||
|
||||
const createCollections = async (collections: CollectionDelta[]) => {
|
||||
for (const { collection, diff } of collections) {
|
||||
if (diff?.[0].kind === DiffKind.NEW && 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)
|
||||
.map((fieldDiff) => {
|
||||
// Casts field type to UUID when applying non-PostgreSQL schema onto PostgreSQL database.
|
||||
// This is needed because they snapshots UUID fields as char with length 36.
|
||||
if (
|
||||
String(fieldDiff.schema?.data_type).toLowerCase() === 'char' &&
|
||||
fieldDiff.schema?.max_length === 36 &&
|
||||
(fieldDiff.schema?.is_primary_key ||
|
||||
(fieldDiff.schema?.foreign_key_table && fieldDiff.schema?.foreign_key_column))
|
||||
) {
|
||||
return merge(fieldDiff, { type: 'uuid', schema: { data_type: 'uuid', max_length: null } });
|
||||
} else {
|
||||
return fieldDiff;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await collectionsService.createOne(
|
||||
{
|
||||
...diff[0].rhs,
|
||||
fields,
|
||||
},
|
||||
mutationOptions
|
||||
);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to create collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
await createCollections(getNestedCollectionsToCreate(collection));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCollections = async (collections: CollectionDelta[]) => {
|
||||
for (const { collection, diff } of collections) {
|
||||
if (diff?.[0].kind === DiffKind.DELETE) {
|
||||
const relations = schema.relations.filter(
|
||||
(r) => r.related_collection === collection || r.collection === collection
|
||||
);
|
||||
|
||||
if (relations.length > 0) {
|
||||
const relationsService = new RelationsService({ knex: trx, schema });
|
||||
|
||||
for (const relation of relations) {
|
||||
try {
|
||||
await relationsService.deleteOne(relation.collection, relation.field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to delete collection "${collection}" due to relation "${relation.collection}.${relation.field}"`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// clean up deleted relations from existing schema
|
||||
schema.relations = schema.relations.filter(
|
||||
(r) => r.related_collection !== collection && r.collection !== collection
|
||||
);
|
||||
}
|
||||
|
||||
await deleteCollections(getNestedCollectionsToDelete(collection));
|
||||
|
||||
try {
|
||||
await collectionsService.deleteOne(collection, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Finds all collections that need to be created
|
||||
const filterCollectionsForCreation = ({ diff }: { collection: string; diff: Diff<Collection | undefined>[] }) => {
|
||||
// Check new collections only
|
||||
const isNewCollection = diff[0].kind === DiffKind.NEW;
|
||||
if (!isNewCollection) return false;
|
||||
|
||||
// Create now if no group
|
||||
const groupName = (diff[0] as DiffNew<Collection>).rhs.meta?.group;
|
||||
if (!groupName) return true;
|
||||
|
||||
// Check if parent collection already exists in schema
|
||||
const parentExists = currentSnapshot.collections.find((c) => c.collection === groupName) !== undefined;
|
||||
// If this is a new collection and the parent collection doesn't exist in current schema ->
|
||||
// Check if the parent collection will be created as part of applying this snapshot ->
|
||||
// If yes -> this collection will be created recursively
|
||||
// If not -> create now
|
||||
// (ex.)
|
||||
// TopLevelCollection - I exist in current schema
|
||||
// NestedCollection - I exist in snapshotDiff as a new collection
|
||||
// TheCurrentCollectionInIteration - I exist in snapshotDiff as a new collection but will be created as part of NestedCollection
|
||||
const parentWillBeCreatedInThisApply =
|
||||
snapshotDiff.collections.filter(
|
||||
({ collection, diff }) => diff[0].kind === DiffKind.NEW && collection === groupName
|
||||
).length > 0;
|
||||
// Has group, but parent is not new, parent is also not being created in this snapshot apply
|
||||
if (parentExists && !parentWillBeCreatedInThisApply) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Create top level collections (no group, or highest level in existing group) first,
|
||||
// then continue with nested collections recursively
|
||||
await createCollections(snapshotDiff.collections.filter(filterCollectionsForCreation));
|
||||
|
||||
// delete top level collections (no group) first, then continue with nested collections recursively
|
||||
await deleteCollections(
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => diff[0].kind === DiffKind.DELETE && (diff[0] as DiffDeleted<Collection>).lhs.meta?.group === null
|
||||
)
|
||||
);
|
||||
|
||||
for (const { collection, diff } of snapshotDiff.collections) {
|
||||
if (diff?.[0].kind === DiffKind.EDIT || diff?.[0].kind === DiffKind.ARRAY) {
|
||||
const currentCollection = currentSnapshot.collections.find((field) => {
|
||||
return field.collection === collection;
|
||||
});
|
||||
|
||||
if (currentCollection) {
|
||||
try {
|
||||
const newValues = diff.reduce((acc, currentDiff) => {
|
||||
applyChange(acc, undefined, currentDiff);
|
||||
return acc;
|
||||
}, cloneDeep(currentCollection));
|
||||
|
||||
await collectionsService.updateOne(collection, newValues, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
schema: await getSchema({ database: trx, bypassCache: true }),
|
||||
});
|
||||
|
||||
for (const { collection, field, diff } of snapshotDiff.fields) {
|
||||
if (diff?.[0].kind === DiffKind.NEW && !isNestedMetaUpdate(diff?.[0])) {
|
||||
try {
|
||||
await fieldsService.createField(collection, (diff[0] as DiffNew<Field>).rhs, undefined, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === DiffKind.EDIT || diff?.[0].kind === DiffKind.ARRAY || isNestedMetaUpdate(diff?.[0])) {
|
||||
const currentField = currentSnapshot.fields.find((snapshotField) => {
|
||||
return snapshotField.collection === collection && snapshotField.field === field;
|
||||
});
|
||||
|
||||
if (currentField) {
|
||||
try {
|
||||
const newValues = diff.reduce((acc, currentDiff) => {
|
||||
applyChange(acc, undefined, currentDiff);
|
||||
return acc;
|
||||
}, cloneDeep(currentField));
|
||||
await fieldsService.updateField(collection, newValues, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === DiffKind.DELETE && !isNestedMetaUpdate(diff?.[0])) {
|
||||
try {
|
||||
await fieldsService.deleteField(collection, field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 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, bypassCache: true }),
|
||||
});
|
||||
|
||||
for (const { collection, field, diff } of snapshotDiff.relations) {
|
||||
const structure = {};
|
||||
|
||||
for (const diffEdit of diff) {
|
||||
set(structure, diffEdit.path!, undefined);
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === DiffKind.NEW) {
|
||||
try {
|
||||
await relationsService.createOne((diff[0] as DiffNew<Relation>).rhs, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === DiffKind.EDIT || diff?.[0].kind === DiffKind.ARRAY) {
|
||||
const currentRelation = currentSnapshot.relations.find((relation) => {
|
||||
return relation.collection === collection && relation.field === field;
|
||||
});
|
||||
|
||||
if (currentRelation) {
|
||||
try {
|
||||
const newValues = diff.reduce((acc, currentDiff) => {
|
||||
applyChange(acc, undefined, currentDiff);
|
||||
return acc;
|
||||
}, cloneDeep(currentRelation));
|
||||
await relationsService.updateOne(collection, field, newValues, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === DiffKind.DELETE) {
|
||||
try {
|
||||
await relationsService.deleteOne(collection, field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
if (nestedActionEvents.length > 0) {
|
||||
const updatedSchema = await getSchema({ database, bypassCache: true });
|
||||
|
||||
for (const nestedActionEvent of nestedActionEvents) {
|
||||
nestedActionEvent.context.schema = updatedSchema;
|
||||
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isNestedMetaUpdate(diff: Diff<SnapshotField | undefined>): boolean {
|
||||
if (!diff) return false;
|
||||
if (diff.kind !== DiffKind.NEW && diff.kind !== DiffKind.DELETE) return false;
|
||||
if (!diff.path || diff.path.length < 2 || diff.path[0] !== 'meta') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ describe('applySnapshot', () => {
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { comment: null, name: 'test_table_2', schema: 'public' },
|
||||
schema: { name: 'test_table_2' },
|
||||
fields: [
|
||||
{
|
||||
collection: 'test_table_2',
|
||||
@@ -76,11 +76,9 @@ describe('applySnapshot', () => {
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
comment: null,
|
||||
data_type: 'uuid',
|
||||
default_value: null,
|
||||
foreign_key_column: null,
|
||||
foreign_key_schema: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: false,
|
||||
@@ -92,7 +90,6 @@ describe('applySnapshot', () => {
|
||||
name: 'id',
|
||||
numeric_precision: null,
|
||||
numeric_scale: null,
|
||||
schema: 'public',
|
||||
table: 'test_table_2',
|
||||
},
|
||||
type: 'uuid',
|
||||
@@ -134,7 +131,7 @@ describe('applySnapshot', () => {
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { comment: null, name: 'test_table_2', schema: 'public' },
|
||||
schema: { name: 'test_table_2' },
|
||||
fields: [
|
||||
{
|
||||
collection: 'test_table_2',
|
||||
@@ -160,11 +157,9 @@ describe('applySnapshot', () => {
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
comment: null,
|
||||
data_type: 'uuid',
|
||||
default_value: null,
|
||||
foreign_key_column: null,
|
||||
foreign_key_schema: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: false,
|
||||
@@ -176,7 +171,6 @@ describe('applySnapshot', () => {
|
||||
name: 'id',
|
||||
numeric_precision: null,
|
||||
numeric_scale: null,
|
||||
schema: 'public',
|
||||
table: 'test_table_2',
|
||||
},
|
||||
type: 'uuid',
|
||||
@@ -211,11 +205,9 @@ describe('applySnapshot', () => {
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
comment: null,
|
||||
data_type: 'uuid',
|
||||
default_value: null,
|
||||
foreign_key_column: null,
|
||||
foreign_key_schema: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: false,
|
||||
@@ -227,7 +219,6 @@ describe('applySnapshot', () => {
|
||||
name: 'id',
|
||||
numeric_precision: null,
|
||||
numeric_scale: null,
|
||||
schema: 'public',
|
||||
table: 'test_table_3',
|
||||
},
|
||||
type: 'uuid',
|
||||
@@ -244,7 +235,7 @@ describe('applySnapshot', () => {
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { comment: null, name: 'test_table_3', schema: 'public' },
|
||||
schema: { name: 'test_table_3' },
|
||||
};
|
||||
|
||||
// Stop call to db later on in apply-snapshot
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { Field, Relation, SchemaOverview } from '@directus/shared/types';
|
||||
import { Diff, DiffDeleted, DiffNew } from 'deep-diff';
|
||||
import { SchemaOverview } from '@directus/shared/types';
|
||||
import { Knex } from 'knex';
|
||||
import { merge, set } from 'lodash';
|
||||
import { getCache } from '../cache';
|
||||
import getDatabase from '../database';
|
||||
import logger from '../logger';
|
||||
import { CollectionsService, FieldsService, RelationsService } from '../services';
|
||||
import { ActionEventParams, Collection, MutationOptions, Snapshot, SnapshotDiff, SnapshotField } from '../types';
|
||||
import { Snapshot, SnapshotDiff } from '../types';
|
||||
import { applyDiff } from './apply-diff';
|
||||
import { getSchema } from './get-schema';
|
||||
import { getSnapshot } from './get-snapshot';
|
||||
import { getSnapshotDiff } from './get-snapshot-diff';
|
||||
import { getCache } from '../cache';
|
||||
import emitter from '../emitter';
|
||||
|
||||
type CollectionDelta = {
|
||||
collection: string;
|
||||
diff: Diff<Collection | undefined>[];
|
||||
};
|
||||
|
||||
export async function applySnapshot(
|
||||
snapshot: Snapshot,
|
||||
@@ -28,279 +19,7 @@ export async function applySnapshot(
|
||||
const current = options?.current ?? (await getSnapshot({ database, schema }));
|
||||
const snapshotDiff = options?.diff ?? getSnapshotDiff(current, snapshot);
|
||||
|
||||
const nestedActionEvents: ActionEventParams[] = [];
|
||||
const mutationOptions: MutationOptions = {
|
||||
autoPurgeSystemCache: false,
|
||||
bypassEmitAction: (params) => nestedActionEvents.push(params),
|
||||
};
|
||||
|
||||
await database.transaction(async (trx) => {
|
||||
const collectionsService = new CollectionsService({ knex: trx, schema });
|
||||
|
||||
const getNestedCollectionsToCreate = (currentLevelCollection: string) =>
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => (diff[0] as DiffNew<Collection>).rhs?.meta?.group === currentLevelCollection
|
||||
) as CollectionDelta[];
|
||||
|
||||
const getNestedCollectionsToDelete = (currentLevelCollection: string) =>
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => (diff[0] as DiffDeleted<Collection>).lhs?.meta?.group === currentLevelCollection
|
||||
) as CollectionDelta[];
|
||||
|
||||
const createCollections = async (collections: CollectionDelta[]) => {
|
||||
for (const { collection, diff } of collections) {
|
||||
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)
|
||||
.map((fieldDiff) => {
|
||||
// Casts field type to UUID when applying non-PostgreSQL schema onto PostgreSQL database.
|
||||
// This is needed because they snapshots UUID fields as char with length 36.
|
||||
if (
|
||||
fieldDiff.schema?.data_type === 'char' &&
|
||||
fieldDiff.schema?.max_length === 36 &&
|
||||
(fieldDiff.schema?.is_primary_key ||
|
||||
(fieldDiff.schema?.foreign_key_table && fieldDiff.schema?.foreign_key_column))
|
||||
) {
|
||||
return merge(fieldDiff, { type: 'uuid', schema: { data_type: 'uuid', max_length: null } });
|
||||
} else {
|
||||
return fieldDiff;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await collectionsService.createOne(
|
||||
{
|
||||
...diff[0].rhs,
|
||||
fields,
|
||||
},
|
||||
mutationOptions
|
||||
);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to create collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
await createCollections(getNestedCollectionsToCreate(collection));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCollections = async (collections: CollectionDelta[]) => {
|
||||
for (const { collection, diff } of collections) {
|
||||
if (diff?.[0].kind === 'D') {
|
||||
const relations = schema.relations.filter(
|
||||
(r) => r.related_collection === collection || r.collection === collection
|
||||
);
|
||||
|
||||
if (relations.length > 0) {
|
||||
const relationsService = new RelationsService({ knex: trx, schema });
|
||||
|
||||
for (const relation of relations) {
|
||||
try {
|
||||
await relationsService.deleteOne(relation.collection, relation.field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to delete collection "${collection}" due to relation "${relation.collection}.${relation.field}"`
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// clean up deleted relations from existing schema
|
||||
schema.relations = schema.relations.filter(
|
||||
(r) => r.related_collection !== collection && r.collection !== collection
|
||||
);
|
||||
}
|
||||
|
||||
await deleteCollections(getNestedCollectionsToDelete(collection));
|
||||
|
||||
try {
|
||||
await collectionsService.deleteOne(collection, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Finds all collections that need to be created
|
||||
const filterCollectionsForCreation = ({ diff }: { collection: string; diff: Diff<Collection | undefined>[] }) => {
|
||||
// Check new collections only
|
||||
const isNewCollection = diff[0].kind === 'N';
|
||||
if (!isNewCollection) return false;
|
||||
|
||||
// Create now if no group
|
||||
const groupName = (diff[0] as DiffNew<Collection>).rhs.meta?.group;
|
||||
if (!groupName) return true;
|
||||
|
||||
// Check if parent collection already exists in schema
|
||||
const parentExists = current.collections.find((c) => c.collection === groupName) !== undefined;
|
||||
// If this is a new collection and the parent collection doesn't exist in current schema ->
|
||||
// Check if the parent collection will be created as part of applying this snapshot ->
|
||||
// If yes -> this collection will be created recursively
|
||||
// If not -> create now
|
||||
// (ex.)
|
||||
// TopLevelCollection - I exist in current schema
|
||||
// NestedCollection - I exist in snapshotDiff as a new collection
|
||||
// TheCurrentCollectionInIteration - I exist in snapshotDiff as a new collection but will be created as part of NestedCollection
|
||||
const parentWillBeCreatedInThisApply =
|
||||
snapshotDiff.collections.filter(({ collection, diff }) => diff[0].kind === 'N' && collection === groupName)
|
||||
.length > 0;
|
||||
// Has group, but parent is not new, parent is also not being created in this snapshot apply
|
||||
if (parentExists && !parentWillBeCreatedInThisApply) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Create top level collections (no group, or highest level in existing group) first,
|
||||
// then continue with nested collections recursively
|
||||
await createCollections(snapshotDiff.collections.filter(filterCollectionsForCreation));
|
||||
|
||||
// delete top level collections (no group) first, then continue with nested collections recursively
|
||||
await deleteCollections(
|
||||
snapshotDiff.collections.filter(
|
||||
({ diff }) => diff[0].kind === 'D' && (diff[0] as DiffDeleted<Collection>).lhs.meta?.group === null
|
||||
)
|
||||
);
|
||||
|
||||
for (const { collection, diff } of snapshotDiff.collections) {
|
||||
if (diff?.[0].kind === 'E' || diff?.[0].kind === 'A') {
|
||||
const newValues = snapshot.collections.find((field) => {
|
||||
return field.collection === collection;
|
||||
});
|
||||
|
||||
if (newValues) {
|
||||
try {
|
||||
await collectionsService.updateOne(collection, newValues, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update collection "${collection}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
schema: await getSchema({ database: trx, bypassCache: true }),
|
||||
});
|
||||
|
||||
for (const { collection, field, diff } of snapshotDiff.fields) {
|
||||
if (diff?.[0].kind === 'N' && !isNestedMetaUpdate(diff?.[0])) {
|
||||
try {
|
||||
await fieldsService.createField(collection, (diff[0] as DiffNew<Field>).rhs, undefined, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === 'E' || diff?.[0].kind === 'A' || isNestedMetaUpdate(diff?.[0])) {
|
||||
const newValues = snapshot.fields.find((snapshotField) => {
|
||||
return snapshotField.collection === collection && snapshotField.field === field;
|
||||
});
|
||||
|
||||
if (newValues) {
|
||||
try {
|
||||
await fieldsService.updateField(
|
||||
collection,
|
||||
{
|
||||
...newValues,
|
||||
},
|
||||
mutationOptions
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === 'D' && !isNestedMetaUpdate(diff?.[0])) {
|
||||
try {
|
||||
await fieldsService.deleteField(collection, field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete field "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 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, bypassCache: true }),
|
||||
});
|
||||
|
||||
for (const { collection, field, diff } of snapshotDiff.relations) {
|
||||
const structure = {};
|
||||
|
||||
for (const diffEdit of diff) {
|
||||
set(structure, diffEdit.path!, undefined);
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === 'N') {
|
||||
try {
|
||||
await relationsService.createOne((diff[0] as DiffNew<Relation>).rhs, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === 'E' || diff?.[0].kind === 'A') {
|
||||
const newValues = snapshot.relations.find((relation) => {
|
||||
return relation.collection === collection && relation.field === field;
|
||||
});
|
||||
|
||||
if (newValues) {
|
||||
try {
|
||||
await relationsService.updateOne(collection, field, newValues, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diff?.[0].kind === 'D') {
|
||||
try {
|
||||
await relationsService.deleteOne(collection, field, mutationOptions);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete relation "${collection}.${field}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await applyDiff(current, snapshotDiff, { database, schema });
|
||||
|
||||
await systemCache?.clear();
|
||||
|
||||
if (nestedActionEvents.length > 0) {
|
||||
const updatedSchema = await getSchema({ database, bypassCache: true });
|
||||
|
||||
for (const nestedActionEvent of nestedActionEvents) {
|
||||
nestedActionEvent.context.schema = updatedSchema;
|
||||
emitter.emitAction(nestedActionEvent.event, nestedActionEvent.meta, nestedActionEvent.context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isNestedMetaUpdate(diff: Diff<SnapshotField | undefined>): boolean {
|
||||
if (!diff) return false;
|
||||
if (diff.kind !== 'N' && diff.kind !== 'D') return false;
|
||||
if (!diff.path || diff.path.length < 2 || diff.path[0] !== 'meta') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Snapshot, SnapshotDiff } from '../types';
|
||||
import { diff } from 'deep-diff';
|
||||
import { orderBy } from 'lodash';
|
||||
import { Snapshot, SnapshotDiff, DiffKind } from '../types';
|
||||
import { sanitizeCollection, sanitizeField, sanitizeRelation } from './sanitize-schema';
|
||||
|
||||
export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDiff {
|
||||
const diffedSnapshot: SnapshotDiff = {
|
||||
@@ -13,7 +14,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
|
||||
return {
|
||||
collection: currentCollection.collection,
|
||||
diff: diff(currentCollection, afterCollection),
|
||||
diff: diff(sanitizeCollection(currentCollection), sanitizeCollection(afterCollection)),
|
||||
};
|
||||
}),
|
||||
...after.collections
|
||||
@@ -26,7 +27,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
})
|
||||
.map((afterCollection) => ({
|
||||
collection: afterCollection.collection,
|
||||
diff: diff(undefined, afterCollection),
|
||||
diff: diff(undefined, sanitizeCollection(afterCollection)),
|
||||
})),
|
||||
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['collections'],
|
||||
'collection'
|
||||
@@ -38,10 +39,16 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
(afterField) => afterField.collection === currentField.collection && afterField.field === currentField.field
|
||||
);
|
||||
|
||||
const isAutoIncrementPrimaryKey =
|
||||
!!currentField.schema?.is_primary_key && !!currentField.schema?.has_auto_increment;
|
||||
|
||||
return {
|
||||
collection: currentField.collection,
|
||||
field: currentField.field,
|
||||
diff: diff(currentField, afterField),
|
||||
diff: diff(
|
||||
sanitizeField(currentField, isAutoIncrementPrimaryKey),
|
||||
sanitizeField(afterField, isAutoIncrementPrimaryKey)
|
||||
),
|
||||
};
|
||||
}),
|
||||
...after.fields
|
||||
@@ -56,7 +63,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
.map((afterField) => ({
|
||||
collection: afterField.collection,
|
||||
field: afterField.field,
|
||||
diff: diff(undefined, afterField),
|
||||
diff: diff(undefined, sanitizeField(afterField)),
|
||||
})),
|
||||
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['fields'],
|
||||
['collection']
|
||||
@@ -73,7 +80,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
collection: currentRelation.collection,
|
||||
field: currentRelation.field,
|
||||
related_collection: currentRelation.related_collection,
|
||||
diff: diff(currentRelation, afterRelation),
|
||||
diff: diff(sanitizeRelation(currentRelation), sanitizeRelation(afterRelation)),
|
||||
};
|
||||
}),
|
||||
...after.relations
|
||||
@@ -89,7 +96,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
collection: afterRelation.collection,
|
||||
field: afterRelation.field,
|
||||
related_collection: afterRelation.related_collection,
|
||||
diff: diff(undefined, afterRelation),
|
||||
diff: diff(undefined, sanitizeRelation(afterRelation)),
|
||||
})),
|
||||
].filter((obj) => Array.isArray(obj.diff)) as SnapshotDiff['relations'],
|
||||
['collection']
|
||||
@@ -101,7 +108,7 @@ export function getSnapshotDiff(current: Snapshot, after: Snapshot): SnapshotDif
|
||||
*/
|
||||
|
||||
const deletedCollections = diffedSnapshot.collections
|
||||
.filter((collection) => collection.diff?.[0].kind === 'D')
|
||||
.filter((collection) => collection.diff?.[0].kind === DiffKind.DELETE)
|
||||
.map(({ collection }) => collection);
|
||||
|
||||
diffedSnapshot.fields = diffedSnapshot.fields.filter(
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import getDatabase from '../database';
|
||||
import getDatabase, { getDatabaseClient } from '../database';
|
||||
import { getSchema } from './get-schema';
|
||||
import { CollectionsService, FieldsService, RelationsService } from '../services';
|
||||
import { version } from '../../package.json';
|
||||
import { Snapshot, SnapshotField, SnapshotRelation } from '../types';
|
||||
import { Collection, Snapshot, SnapshotField, SnapshotRelation } from '../types';
|
||||
import { Knex } from 'knex';
|
||||
import { omit, sortBy, toPairs, fromPairs, mapValues, isPlainObject, isArray } from 'lodash';
|
||||
import { SchemaOverview } from '@directus/shared/types';
|
||||
import { sanitizeCollection, sanitizeField, sanitizeRelation } from './sanitize-schema';
|
||||
|
||||
export async function getSnapshot(options?: { database?: Knex; schema?: SchemaOverview }): Promise<Snapshot> {
|
||||
const database = options?.database ?? getDatabase();
|
||||
const vendor = getDatabaseClient(database);
|
||||
const schema = options?.schema ?? (await getSchema({ database, bypassCache: true }));
|
||||
|
||||
const collectionsService = new CollectionsService({ knex: database, schema });
|
||||
@@ -32,9 +34,10 @@ export async function getSnapshot(options?: { database?: Knex; schema?: SchemaOv
|
||||
return {
|
||||
version: 1,
|
||||
directus: version,
|
||||
collections: collectionsSorted,
|
||||
fields: fieldsSorted,
|
||||
relations: relationsSorted,
|
||||
vendor,
|
||||
collections: collectionsSorted.map((collection) => sanitizeCollection(collection)) as Collection[],
|
||||
fields: fieldsSorted.map((field) => sanitizeField(field)) as SnapshotField[],
|
||||
relations: relationsSorted.map((relation) => sanitizeRelation(relation)) as SnapshotRelation[],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
118
api/src/utils/get-versioned-hash.test.ts
Normal file
118
api/src/utils/get-versioned-hash.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { test, expect, describe, vi } from 'vitest';
|
||||
|
||||
import { getVersionedHash } from './get-versioned-hash';
|
||||
|
||||
vi.mock('../../package.json', () => ({
|
||||
version: '10.10.10',
|
||||
}));
|
||||
|
||||
describe('getVersionedHash', () => {
|
||||
test.each([
|
||||
{
|
||||
input: {
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
archive_app_filter: true,
|
||||
archive_field: 'status',
|
||||
archive_value: 'archived',
|
||||
collapse: 'open',
|
||||
collection: 'test',
|
||||
color: null,
|
||||
display_template: null,
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
sort: null,
|
||||
sort_field: null,
|
||||
translations: null,
|
||||
unarchive_value: 'draft',
|
||||
},
|
||||
schema: { name: 'test' },
|
||||
},
|
||||
expected: '8acdde88d26ea4142e224fe0b4bfdaab9ac9c923',
|
||||
},
|
||||
{
|
||||
input: {
|
||||
collection: 'test',
|
||||
field: 'id',
|
||||
meta: {
|
||||
collection: 'test',
|
||||
conditions: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
field: 'id',
|
||||
group: null,
|
||||
hidden: true,
|
||||
interface: 'input',
|
||||
note: null,
|
||||
options: null,
|
||||
readonly: true,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translations: null,
|
||||
validation: null,
|
||||
validation_message: null,
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
comment: null,
|
||||
data_type: 'integer',
|
||||
default_value: "nextval('test_id_seq'::regclass)",
|
||||
foreign_key_column: null,
|
||||
foreign_key_schema: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: true,
|
||||
is_generated: false,
|
||||
is_nullable: false,
|
||||
is_primary_key: true,
|
||||
is_unique: true,
|
||||
max_length: null,
|
||||
name: 'id',
|
||||
numeric_precision: 32,
|
||||
numeric_scale: 0,
|
||||
schema: 'public',
|
||||
table: 'test',
|
||||
},
|
||||
type: 'integer',
|
||||
},
|
||||
expected: '60f0d169cfa32799cae884e1bf33e0e49c3ff383',
|
||||
},
|
||||
{
|
||||
input: {
|
||||
collection: 'test_example',
|
||||
field: 'm2m',
|
||||
related_collection: 'test',
|
||||
meta: {
|
||||
id: 1,
|
||||
junction_field: 'example_id',
|
||||
many_collection: 'test_example',
|
||||
many_field: 'test_id',
|
||||
one_allowed_collections: null,
|
||||
one_collection: 'test',
|
||||
one_collection_field: null,
|
||||
one_deselect_action: 'nullify',
|
||||
one_field: 'm2m',
|
||||
sort_field: null,
|
||||
},
|
||||
schema: {
|
||||
table: 'test_example',
|
||||
column: 'test_id',
|
||||
foreign_key_table: 'test',
|
||||
foreign_key_column: 'id',
|
||||
constraint_name: 'test_example_test_id_foreign',
|
||||
on_update: 'NO ACTION',
|
||||
on_delete: 'SET NULL',
|
||||
},
|
||||
},
|
||||
expected: 'fa17767ef6646a72a6cfc211d36886d06896d0fc',
|
||||
},
|
||||
])('should return $expected', ({ input, expected }) => {
|
||||
expect(getVersionedHash(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
6
api/src/utils/get-versioned-hash.ts
Normal file
6
api/src/utils/get-versioned-hash.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import hash from 'object-hash';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
export function getVersionedHash(item: Record<string, any>): string {
|
||||
return hash({ item, version });
|
||||
}
|
||||
347
api/src/utils/sanitize-schema.test.ts
Normal file
347
api/src/utils/sanitize-schema.test.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { Field, Relation } from '@directus/shared/types';
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import { Collection } from '../types';
|
||||
|
||||
import { sanitizeCollection, sanitizeField, sanitizeRelation } from './sanitize-schema';
|
||||
|
||||
describe('sanitizeCollection', () => {
|
||||
test.each([
|
||||
// Not supported in SQLite + comment in MSSQL
|
||||
{
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { comment: null, name: 'test', schema: 'public' },
|
||||
},
|
||||
// MySQL Only
|
||||
{
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { collation: 'latin1_swedish_ci', name: 'test', engine: 'InnoDB' },
|
||||
},
|
||||
// Postgres Only
|
||||
{
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { name: 'test', owner: 'postgres' },
|
||||
},
|
||||
// SQLite Only
|
||||
{
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { name: 'test', sql: 'CREATE TABLE `test` (`id` integer not null primary key autoincrement)' },
|
||||
},
|
||||
// MSSQL only
|
||||
{
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { name: 'test', catalog: 'test-db' },
|
||||
},
|
||||
] satisfies Collection[])('should only contain name property in collection schema', (testCollection) => {
|
||||
const result = sanitizeCollection(testCollection);
|
||||
|
||||
expect(result).toEqual({
|
||||
collection: 'test',
|
||||
meta: {
|
||||
accountability: 'all',
|
||||
collection: 'test',
|
||||
group: null,
|
||||
hidden: false,
|
||||
icon: null,
|
||||
item_duplication_fields: null,
|
||||
note: null,
|
||||
singleton: false,
|
||||
translations: {},
|
||||
},
|
||||
schema: { name: 'test' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeField', () => {
|
||||
test('should only contain certain properties in field schema when sanitizeAllSchema is false', () => {
|
||||
const testField = {
|
||||
collection: 'test',
|
||||
field: 'id',
|
||||
name: 'id',
|
||||
meta: {
|
||||
id: 1,
|
||||
collection: 'test',
|
||||
conditions: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
field: 'id',
|
||||
group: null,
|
||||
hidden: true,
|
||||
interface: 'input',
|
||||
note: null,
|
||||
options: null,
|
||||
readonly: true,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translations: null,
|
||||
validation: null,
|
||||
validation_message: null,
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
comment: null,
|
||||
data_type: 'integer',
|
||||
default_value: "nextval('test_id_seq'::regclass)",
|
||||
foreign_key_column: null,
|
||||
foreign_key_schema: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: true,
|
||||
is_generated: false,
|
||||
is_nullable: false,
|
||||
is_primary_key: true,
|
||||
is_unique: true,
|
||||
max_length: null,
|
||||
name: 'id',
|
||||
numeric_precision: 32,
|
||||
numeric_scale: 0,
|
||||
schema: 'public',
|
||||
table: 'test',
|
||||
},
|
||||
type: 'integer',
|
||||
} satisfies Field;
|
||||
|
||||
const result = sanitizeField(testField);
|
||||
|
||||
expect(result).toEqual({
|
||||
collection: 'test',
|
||||
field: 'id',
|
||||
name: 'id',
|
||||
meta: {
|
||||
id: 1,
|
||||
collection: 'test',
|
||||
conditions: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
field: 'id',
|
||||
group: null,
|
||||
hidden: true,
|
||||
interface: 'input',
|
||||
note: null,
|
||||
options: null,
|
||||
readonly: true,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translations: null,
|
||||
validation: null,
|
||||
validation_message: null,
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
data_type: 'integer',
|
||||
default_value: "nextval('test_id_seq'::regclass)",
|
||||
foreign_key_column: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: true,
|
||||
is_generated: false,
|
||||
is_nullable: false,
|
||||
is_primary_key: true,
|
||||
is_unique: true,
|
||||
max_length: null,
|
||||
name: 'id',
|
||||
numeric_precision: 32,
|
||||
numeric_scale: 0,
|
||||
|
||||
table: 'test',
|
||||
},
|
||||
type: 'integer',
|
||||
});
|
||||
});
|
||||
|
||||
test('should not contain field schema when sanitizeAllSchema is true', () => {
|
||||
const testField = {
|
||||
collection: 'test',
|
||||
field: 'id',
|
||||
name: 'id',
|
||||
meta: {
|
||||
id: 1,
|
||||
collection: 'test',
|
||||
conditions: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
field: 'id',
|
||||
group: null,
|
||||
hidden: true,
|
||||
interface: 'input',
|
||||
note: null,
|
||||
options: null,
|
||||
readonly: true,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translations: null,
|
||||
validation: null,
|
||||
validation_message: null,
|
||||
width: 'full',
|
||||
},
|
||||
schema: {
|
||||
data_type: 'integer',
|
||||
default_value: "nextval('test_id_seq'::regclass)",
|
||||
foreign_key_column: null,
|
||||
foreign_key_table: null,
|
||||
generation_expression: null,
|
||||
has_auto_increment: true,
|
||||
is_generated: false,
|
||||
is_nullable: false,
|
||||
is_primary_key: true,
|
||||
is_unique: true,
|
||||
max_length: null,
|
||||
name: 'id',
|
||||
numeric_precision: 32,
|
||||
numeric_scale: 0,
|
||||
table: 'test',
|
||||
},
|
||||
type: 'integer',
|
||||
} satisfies Field;
|
||||
|
||||
const result = sanitizeField(testField, true);
|
||||
|
||||
expect(result).toEqual({
|
||||
collection: 'test',
|
||||
field: 'id',
|
||||
name: 'id',
|
||||
meta: {
|
||||
id: 1,
|
||||
collection: 'test',
|
||||
conditions: null,
|
||||
display: null,
|
||||
display_options: null,
|
||||
field: 'id',
|
||||
group: null,
|
||||
hidden: true,
|
||||
interface: 'input',
|
||||
note: null,
|
||||
options: null,
|
||||
readonly: true,
|
||||
required: false,
|
||||
sort: null,
|
||||
special: null,
|
||||
translations: null,
|
||||
validation: null,
|
||||
validation_message: null,
|
||||
width: 'full',
|
||||
},
|
||||
type: 'integer',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeRelation', () => {
|
||||
test.each([
|
||||
// Postgres + MSSSQL
|
||||
{
|
||||
collection: 'test_example',
|
||||
field: 'm2m',
|
||||
related_collection: 'test',
|
||||
meta: {
|
||||
id: 1,
|
||||
junction_field: 'example_id',
|
||||
many_collection: 'test_example',
|
||||
many_field: 'test_id',
|
||||
one_allowed_collections: null,
|
||||
one_collection: 'test',
|
||||
one_collection_field: null,
|
||||
one_deselect_action: 'nullify',
|
||||
one_field: 'm2m',
|
||||
sort_field: null,
|
||||
},
|
||||
schema: {
|
||||
table: 'test_example',
|
||||
column: 'test_id',
|
||||
foreign_key_table: 'test',
|
||||
foreign_key_column: 'id',
|
||||
foreign_key_schema: 'public',
|
||||
constraint_name: 'test_example_test_id_foreign',
|
||||
on_update: 'NO ACTION',
|
||||
on_delete: 'SET NULL',
|
||||
},
|
||||
},
|
||||
] satisfies Relation[])('should only contain certain properties in relation schema', (testRelation) => {
|
||||
const result = sanitizeRelation(testRelation);
|
||||
|
||||
expect(result).toEqual({
|
||||
collection: 'test_example',
|
||||
field: 'm2m',
|
||||
related_collection: 'test',
|
||||
meta: {
|
||||
id: 1,
|
||||
junction_field: 'example_id',
|
||||
many_collection: 'test_example',
|
||||
many_field: 'test_id',
|
||||
one_allowed_collections: null,
|
||||
one_collection: 'test',
|
||||
one_collection_field: null,
|
||||
one_deselect_action: 'nullify',
|
||||
one_field: 'm2m',
|
||||
sort_field: null,
|
||||
},
|
||||
schema: {
|
||||
table: 'test_example',
|
||||
column: 'test_id',
|
||||
foreign_key_table: 'test',
|
||||
foreign_key_column: 'id',
|
||||
constraint_name: 'test_example_test_id_foreign',
|
||||
on_update: 'NO ACTION',
|
||||
on_delete: 'SET NULL',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
81
api/src/utils/sanitize-schema.ts
Normal file
81
api/src/utils/sanitize-schema.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Field, Relation } from '@directus/shared/types';
|
||||
import { pick } from 'lodash';
|
||||
import { Collection } from '../types';
|
||||
|
||||
/**
|
||||
* Pick certain database vendor specific collection properties that should be compared when performing diff
|
||||
*
|
||||
* @param collection collection to sanitize
|
||||
* @returns sanitized collection
|
||||
*
|
||||
* @see {@link https://github.com/knex/knex-schema-inspector/blob/master/lib/types/table.ts}
|
||||
*/
|
||||
|
||||
export function sanitizeCollection(collection: Collection | undefined) {
|
||||
if (!collection) return collection;
|
||||
|
||||
return pick(collection, ['collection', 'fields', 'meta', 'schema.name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick certain database vendor specific field properties that should be compared when performing diff
|
||||
*
|
||||
* @param field field to sanitize
|
||||
* @param sanitizeAllSchema Whether or not the whole field schema should be sanitized. Mainly used to prevent modifying autoincrement fields
|
||||
* @returns sanitized field
|
||||
*
|
||||
* @see {@link https://github.com/knex/knex-schema-inspector/blob/master/lib/types/column.ts}
|
||||
*/
|
||||
export function sanitizeField(field: Field | undefined, sanitizeAllSchema = false) {
|
||||
if (!field) return field;
|
||||
|
||||
const defaultPaths = ['collection', 'field', 'type', 'meta', 'name', 'children'];
|
||||
const pickedPaths = sanitizeAllSchema
|
||||
? defaultPaths
|
||||
: [
|
||||
...defaultPaths,
|
||||
'schema.name',
|
||||
'schema.table',
|
||||
'schema.data_type',
|
||||
'schema.default_value',
|
||||
'schema.max_length',
|
||||
'schema.numeric_precision',
|
||||
'schema.numeric_scale',
|
||||
'schema.is_nullable',
|
||||
'schema.is_unique',
|
||||
'schema.is_primary_key',
|
||||
'schema.is_generated',
|
||||
'schema.generation_expression',
|
||||
'schema.has_auto_increment',
|
||||
'schema.foreign_key_table',
|
||||
'schema.foreign_key_column',
|
||||
];
|
||||
|
||||
return pick(field, pickedPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick certain database vendor specific relation properties that should be compared when performing diff
|
||||
*
|
||||
* @param relation relation to sanitize
|
||||
* @returns sanitized relation
|
||||
*
|
||||
* @see {@link https://github.com/knex/knex-schema-inspector/blob/master/lib/types/foreign-key.ts}
|
||||
*/
|
||||
export function sanitizeRelation(relation: Relation | undefined) {
|
||||
if (!relation) return relation;
|
||||
|
||||
return pick(relation, [
|
||||
'collection',
|
||||
'field',
|
||||
'related_collection',
|
||||
'meta',
|
||||
'schema.table',
|
||||
'schema.column',
|
||||
'schema.foreign_key_table',
|
||||
'schema.foreign_key_column',
|
||||
'schema.constraint_name',
|
||||
'schema.on_update',
|
||||
'schema.on_delete',
|
||||
]);
|
||||
}
|
||||
152
api/src/utils/validate-diff.test.ts
Normal file
152
api/src/utils/validate-diff.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Collection } from '../types/collection';
|
||||
import {
|
||||
Snapshot,
|
||||
SnapshotDiff,
|
||||
SnapshotDiffWithHash,
|
||||
SnapshotField,
|
||||
SnapshotRelation,
|
||||
SnapshotWithHash,
|
||||
} from '../types/snapshot';
|
||||
import { validateApplyDiff } from './validate-diff';
|
||||
|
||||
test('should fail on invalid diff schema', () => {
|
||||
const diff = {} as SnapshotDiffWithHash;
|
||||
const snapshot = {} as SnapshotWithHash;
|
||||
|
||||
expect(() => validateApplyDiff(diff, snapshot)).toThrowError('"hash" is required');
|
||||
});
|
||||
|
||||
test('should fail on invalid hash', () => {
|
||||
const diff = {
|
||||
hash: 'abc',
|
||||
diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] },
|
||||
} as SnapshotDiffWithHash;
|
||||
const snapshot = { hash: 'xyz' } as SnapshotWithHash;
|
||||
|
||||
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
|
||||
"Provided hash does not match the current instance's schema hash"
|
||||
);
|
||||
});
|
||||
|
||||
describe('should throw accurate error', () => {
|
||||
const baseDiff = (partialDiff: Partial<SnapshotDiff>): SnapshotDiffWithHash => {
|
||||
return {
|
||||
hash: 'abc',
|
||||
diff: {
|
||||
fields: [],
|
||||
collections: [],
|
||||
relations: [],
|
||||
...partialDiff,
|
||||
},
|
||||
};
|
||||
};
|
||||
const baseSnapshot = (partialSnapshot?: Partial<Snapshot>) => {
|
||||
return {
|
||||
hash: 'xyz',
|
||||
collections: [] as Collection[],
|
||||
fields: [] as SnapshotField[],
|
||||
relations: [] as SnapshotRelation[],
|
||||
...partialSnapshot,
|
||||
} as SnapshotWithHash;
|
||||
};
|
||||
|
||||
test('creating collection which already exists', () => {
|
||||
const diff = baseDiff({
|
||||
collections: [{ collection: 'test', diff: [{ kind: 'N', rhs: {} as Collection }] }],
|
||||
});
|
||||
const snapshot = baseSnapshot({ collections: [{ collection: 'test' } as Collection] });
|
||||
|
||||
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
|
||||
'Provided diff is trying to create collection "test" but it already exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting collection which does not exist', () => {
|
||||
const diff = baseDiff({
|
||||
collections: [{ collection: 'test', diff: [{ kind: 'D', lhs: {} as Collection }] }],
|
||||
});
|
||||
|
||||
expect(() => validateApplyDiff(diff, baseSnapshot())).toThrowError(
|
||||
'Provided diff is trying to delete collection "test" but it does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('creating field which already exists', () => {
|
||||
const diff = baseDiff({
|
||||
fields: [{ collection: 'test', field: 'test', diff: [{ kind: 'N', rhs: {} as SnapshotField }] }],
|
||||
});
|
||||
const snapshot = baseSnapshot({ fields: [{ collection: 'test', field: 'test' } as SnapshotField] });
|
||||
|
||||
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
|
||||
'Provided diff is trying to create field "test.test" but it already exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting field which does not exist', () => {
|
||||
const diff = baseDiff({
|
||||
fields: [{ collection: 'test', field: 'test', diff: [{ kind: 'D', lhs: {} as SnapshotField }] }],
|
||||
});
|
||||
|
||||
expect(() => validateApplyDiff(diff, baseSnapshot())).toThrowError(
|
||||
'Provided diff is trying to delete field "test.test" but it does not exist'
|
||||
);
|
||||
});
|
||||
|
||||
test('creating relation which already exists', () => {
|
||||
const diff = baseDiff({
|
||||
relations: [
|
||||
{
|
||||
collection: 'test',
|
||||
field: 'test',
|
||||
related_collection: 'relation',
|
||||
diff: [{ kind: 'N', rhs: {} as SnapshotRelation }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const snapshot = baseSnapshot({
|
||||
relations: [{ collection: 'test', field: 'test', related_collection: 'relation' } as SnapshotRelation],
|
||||
});
|
||||
|
||||
expect(() => validateApplyDiff(diff, snapshot)).toThrowError(
|
||||
'Provided diff is trying to create relation "test.test-> relation" but it already exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('deleting relation which does not exist', () => {
|
||||
const diff = baseDiff({
|
||||
relations: [
|
||||
{
|
||||
collection: 'test',
|
||||
field: 'test',
|
||||
related_collection: 'relation',
|
||||
diff: [{ kind: 'D', lhs: {} as SnapshotRelation }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => validateApplyDiff(diff, baseSnapshot())).toThrowError(
|
||||
'Provided diff is trying to delete relation "test.test-> relation" but it does not exist'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should detect empty diff', () => {
|
||||
const diff = {
|
||||
hash: 'abc',
|
||||
diff: { collections: [], fields: [], relations: [] },
|
||||
};
|
||||
const snapshot = {} as SnapshotWithHash;
|
||||
|
||||
expect(validateApplyDiff(diff, snapshot)).toBe(false);
|
||||
});
|
||||
|
||||
test('should pass on valid diff', () => {
|
||||
const diff = {
|
||||
hash: 'abc',
|
||||
diff: { collections: [{ collection: 'test', diff: [] }], fields: [], relations: [] },
|
||||
};
|
||||
const snapshot = { hash: 'abc' } as SnapshotWithHash;
|
||||
|
||||
expect(validateApplyDiff(diff, snapshot)).toBe(true);
|
||||
});
|
||||
150
api/src/utils/validate-diff.ts
Normal file
150
api/src/utils/validate-diff.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import Joi from 'joi';
|
||||
import { InvalidPayloadException } from '../index';
|
||||
import { DiffKind, SnapshotDiffWithHash, SnapshotWithHash } from '../types/snapshot';
|
||||
|
||||
const deepDiffSchema = Joi.object({
|
||||
kind: Joi.string()
|
||||
.valid(...Object.values(DiffKind))
|
||||
.required(),
|
||||
path: Joi.array().items(Joi.string()),
|
||||
lhs: Joi.object().when('kind', { is: [DiffKind.DELETE, DiffKind.EDIT], then: Joi.required() }),
|
||||
rhs: Joi.object().when('kind', { is: [DiffKind.NEW, DiffKind.EDIT], then: Joi.required() }),
|
||||
index: Joi.number().when('kind', { is: DiffKind.ARRAY, then: Joi.required() }),
|
||||
item: Joi.link('/').when('kind', { is: DiffKind.ARRAY, then: Joi.required() }),
|
||||
});
|
||||
|
||||
const applyJoiSchema = Joi.object({
|
||||
hash: Joi.string().required(),
|
||||
diff: Joi.object({
|
||||
collections: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
collection: Joi.string().required(),
|
||||
diff: Joi.array().items(deepDiffSchema).required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
fields: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
collection: Joi.string().required(),
|
||||
field: Joi.string().required(),
|
||||
diff: Joi.array().items(deepDiffSchema).required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
relations: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
collection: Joi.string().required(),
|
||||
field: Joi.string().required(),
|
||||
related_collection: Joi.string(),
|
||||
diff: Joi.array().items(deepDiffSchema).required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
}).required(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates the diff against the current schema snapshot.
|
||||
*
|
||||
* @returns True if the diff can be applied (valid & not empty).
|
||||
*/
|
||||
export function validateApplyDiff(applyDiff: SnapshotDiffWithHash, currentSnapshotWithHash: SnapshotWithHash) {
|
||||
const { error } = applyJoiSchema.validate(applyDiff);
|
||||
if (error) throw new InvalidPayloadException(error.message);
|
||||
|
||||
// No changes to apply
|
||||
if (
|
||||
applyDiff.diff.collections.length === 0 &&
|
||||
applyDiff.diff.fields.length === 0 &&
|
||||
applyDiff.diff.relations.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Diff can be applied due to matching hash
|
||||
if (applyDiff.hash === currentSnapshotWithHash.hash) return true;
|
||||
|
||||
for (const diffCollection of applyDiff.diff.collections) {
|
||||
const collection = diffCollection.collection;
|
||||
|
||||
if (diffCollection.diff[0]?.kind === DiffKind.NEW) {
|
||||
const existingCollection = currentSnapshotWithHash.collections.find(
|
||||
(c) => c.collection === diffCollection.collection
|
||||
);
|
||||
|
||||
if (existingCollection) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to create collection "${collection}" but it already exists. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
} else if (diffCollection.diff[0]?.kind === DiffKind.DELETE) {
|
||||
const existingCollection = currentSnapshotWithHash.collections.find(
|
||||
(c) => c.collection === diffCollection.collection
|
||||
);
|
||||
|
||||
if (!existingCollection) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to delete collection "${collection}" but it does not exist. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const diffField of applyDiff.diff.fields) {
|
||||
const field = `${diffField.collection}.${diffField.field}`;
|
||||
|
||||
if (diffField.diff[0]?.kind === DiffKind.NEW) {
|
||||
const existingField = currentSnapshotWithHash.fields.find(
|
||||
(f) => f.collection === diffField.collection && f.field === diffField.field
|
||||
);
|
||||
|
||||
if (existingField) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to create field "${field}" but it already exists. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
} else if (diffField.diff[0]?.kind === DiffKind.DELETE) {
|
||||
const existingField = currentSnapshotWithHash.fields.find(
|
||||
(f) => f.collection === diffField.collection && f.field === diffField.field
|
||||
);
|
||||
|
||||
if (!existingField) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to delete field "${field}" but it does not exist. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const diffRelation of applyDiff.diff.relations) {
|
||||
let relation = `${diffRelation.collection}.${diffRelation.field}`;
|
||||
if (diffRelation.related_collection) relation += `-> ${diffRelation.related_collection}`;
|
||||
|
||||
if (diffRelation.diff[0]?.kind === DiffKind.NEW) {
|
||||
const existingRelation = currentSnapshotWithHash.relations.find(
|
||||
(r) => r.collection === diffRelation.collection && r.field === diffRelation.field
|
||||
);
|
||||
|
||||
if (existingRelation) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to create relation "${relation}" but it already exists. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
} else if (diffRelation.diff[0]?.kind === DiffKind.DELETE) {
|
||||
const existingRelation = currentSnapshotWithHash.relations.find(
|
||||
(r) => r.collection === diffRelation.collection && r.field === diffRelation.field
|
||||
);
|
||||
|
||||
if (!existingRelation) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided diff is trying to delete relation "${relation}" but it does not exist. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidPayloadException(
|
||||
`Provided hash does not match the current instance's schema hash, indicating the schema has changed after this diff was generated. Please generate a new diff and try again.`
|
||||
);
|
||||
}
|
||||
24
api/src/utils/validate-query.test.ts
Normal file
24
api/src/utils/validate-query.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { validateQuery } from './validate-query';
|
||||
|
||||
vi.mock('../env', async () => {
|
||||
const actual = (await vi.importActual('../env')) as { default: Record<string, any> };
|
||||
const MOCK_ENV = {
|
||||
...actual.default,
|
||||
MAX_QUERY_LIMIT: 100,
|
||||
};
|
||||
return {
|
||||
default: MOCK_ENV,
|
||||
getEnv: () => MOCK_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
test.each(['csv', 'json', 'xml', 'yaml'])('should accept format %i', (format) => {
|
||||
expect(() => validateQuery({ export: format } as any)).not.toThrowError();
|
||||
});
|
||||
|
||||
test('should error with invalid-format', () => {
|
||||
expect(() => validateQuery({ export: 'invalid-format' } as any)).toThrowError('"export" must be one of');
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const querySchema = Joi.object({
|
||||
page: Joi.number().integer().min(0),
|
||||
meta: Joi.array().items(Joi.string().valid('total_count', 'filter_count')),
|
||||
search: Joi.string(),
|
||||
export: Joi.string().valid('json', 'csv', 'xml'),
|
||||
export: Joi.string().valid('csv', 'json', 'xml', 'yaml'),
|
||||
aggregate: Joi.object(),
|
||||
deep: Joi.object(),
|
||||
alias: Joi.object(),
|
||||
|
||||
55
api/src/utils/validate-snapshot.test.ts
Normal file
55
api/src/utils/validate-snapshot.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { Snapshot } from '../types/snapshot';
|
||||
import { validateSnapshot } from './validate-snapshot';
|
||||
|
||||
vi.mock('../../package.json', () => ({
|
||||
version: '9.22.4',
|
||||
}));
|
||||
|
||||
vi.mock('../database', () => ({
|
||||
getDatabaseClient: () => 'sqlite',
|
||||
}));
|
||||
|
||||
describe('should fail on invalid snapshot schema', () => {
|
||||
test('empty snapshot', () => {
|
||||
const snapshot = {} as Snapshot;
|
||||
|
||||
expect(() => validateSnapshot(snapshot)).toThrowError('"version" is required');
|
||||
});
|
||||
|
||||
test('invalid version', () => {
|
||||
const snapshot = { version: 0 } as Snapshot;
|
||||
|
||||
expect(() => validateSnapshot(snapshot)).toThrowError('"version" must be [1]');
|
||||
});
|
||||
|
||||
test('invalid schema', () => {
|
||||
const snapshot = { version: 1, directus: '9.22.4', collections: {} } as Snapshot;
|
||||
|
||||
expect(() => validateSnapshot(snapshot)).toThrowError('"collections" must be an array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('should require force option on version / vendor mismatch', () => {
|
||||
test('directus version mismatch', () => {
|
||||
const snapshot = { version: 1, directus: '9.22.3' } as Snapshot;
|
||||
|
||||
expect(() => validateSnapshot(snapshot)).toThrowError(
|
||||
"Provided snapshot's directus version 9.22.3 does not match the current instance's version 9.22.4"
|
||||
);
|
||||
});
|
||||
|
||||
test('db vendor mismatch', () => {
|
||||
const snapshot = { version: 1, directus: '9.22.4', vendor: 'postgres' } as Snapshot;
|
||||
|
||||
expect(() => validateSnapshot(snapshot)).toThrowError(
|
||||
"Provided snapshot's vendor postgres does not match the current instance's vendor sqlite."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow bypass on version / vendor mismatch via force option ', () => {
|
||||
const snapshot = { version: 1, directus: '9.22.3', vendor: 'postgres' } as Snapshot;
|
||||
|
||||
expect(validateSnapshot(snapshot, true)).toBeUndefined();
|
||||
});
|
||||
80
api/src/utils/validate-snapshot.ts
Normal file
80
api/src/utils/validate-snapshot.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { version as currentDirectusVersion } from '../../package.json';
|
||||
import { InvalidPayloadException } from '../exceptions';
|
||||
import { getDatabaseClient } from '../database';
|
||||
import Joi from 'joi';
|
||||
import { TYPES } from '@directus/shared/constants';
|
||||
import { ALIAS_TYPES } from '../constants';
|
||||
import { DatabaseClients, Snapshot } from '../types';
|
||||
|
||||
const snapshotJoiSchema = Joi.object({
|
||||
version: Joi.number().valid(1).required(),
|
||||
directus: Joi.string().required(),
|
||||
vendor: Joi.string()
|
||||
.valid(...DatabaseClients)
|
||||
.optional(),
|
||||
collections: Joi.array().items(
|
||||
Joi.object({
|
||||
collection: Joi.string(),
|
||||
meta: Joi.any(),
|
||||
schema: Joi.object({
|
||||
name: Joi.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
fields: Joi.array().items(
|
||||
Joi.object({
|
||||
collection: Joi.string(),
|
||||
field: Joi.string(),
|
||||
meta: Joi.any(),
|
||||
schema: Joi.object({
|
||||
default_value: Joi.any(),
|
||||
max_length: [Joi.number(), Joi.string(), Joi.valid(null)],
|
||||
is_nullable: Joi.bool(),
|
||||
})
|
||||
.unknown()
|
||||
.allow(null),
|
||||
type: Joi.string()
|
||||
.valid(...TYPES, ...ALIAS_TYPES)
|
||||
.allow(null),
|
||||
})
|
||||
),
|
||||
relations: Joi.array().items(
|
||||
Joi.object({
|
||||
collection: Joi.string(),
|
||||
field: Joi.string(),
|
||||
meta: Joi.any(),
|
||||
related_collection: Joi.any(),
|
||||
schema: Joi.any(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates the snapshot against the current instance.
|
||||
**/
|
||||
export function validateSnapshot(snapshot: Snapshot, force = false) {
|
||||
const { error } = snapshotJoiSchema.validate(snapshot);
|
||||
if (error) throw new InvalidPayloadException(error.message);
|
||||
|
||||
// Bypass checks when "force" option is enabled
|
||||
if (force) return;
|
||||
|
||||
if (snapshot.directus !== currentDirectusVersion) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided snapshot's directus version ${snapshot.directus} does not match the current instance's version ${currentDirectusVersion}. You can bypass this check by passing the "force" query parameter.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.vendor) {
|
||||
throw new InvalidPayloadException(
|
||||
'Provided snapshot does not contain the "vendor" property. You can bypass this check by passing the "force" query parameter.'
|
||||
);
|
||||
}
|
||||
|
||||
const currentVendor = getDatabaseClient();
|
||||
if (snapshot.vendor !== currentVendor) {
|
||||
throw new InvalidPayloadException(
|
||||
`Provided snapshot's vendor ${snapshot.vendor} does not match the current instance's vendor ${currentVendor}. You can bypass this check by passing the "force" query parameter.`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user