Merge pull request #54 from directus/relational-write

Add relational write
This commit is contained in:
Rijk van Zanten
2020-07-08 12:45:49 -04:00
committed by GitHub
16 changed files with 246 additions and 80 deletions

View File

@@ -2,7 +2,7 @@ import express from 'express';
import asyncHandler from 'express-async-handler';
import sanitizeQuery from '../middleware/sanitize-query';
import validateQuery from '../middleware/validate-query';
import { readActivities, readActivity } from '../services/activity';
import * as ActivityService from '../services/activity';
import useCollection from '../middleware/use-collection';
const router = express.Router();
@@ -13,7 +13,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await readActivities(req.sanitizedQuery);
const records = await ActivityService.readActivities(req.sanitizedQuery);
return res.json({
data: records,
});
@@ -26,7 +26,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await readActivity(req.params.pk, req.sanitizedQuery);
const record = await ActivityService.readActivity(req.params.pk, req.sanitizedQuery);
return res.json({
data: record,

View File

@@ -1,6 +1,6 @@
import express from 'express';
import asyncHandler from 'express-async-handler';
import { createItem, readItems, readItem, updateItem, deleteItem } from '../services/items';
import * as ItemsService from '../services/items';
import sanitizeQuery from '../middleware/sanitize-query';
import validateCollection from '../middleware/validate-collection';
import validateSingleton from '../middleware/validate-singleton';
@@ -14,8 +14,11 @@ router.post(
'/:collection',
validateCollection,
validateSingleton,
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const item = await createItem(req.params.collection, req.body);
const primaryKey = await ItemsService.createItem(req.collection, req.body);
const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -38,8 +41,8 @@ router.get(
validateQuery,
asyncHandler(async (req, res) => {
const [records, meta] = await Promise.all([
readItems(req.params.collection, req.sanitizedQuery),
MetaService.getMetaForQuery(req.params.collection, req.sanitizedQuery),
ItemsService.readItems(req.collection, req.sanitizedQuery),
MetaService.getMetaForQuery(req.collection, req.sanitizedQuery),
]);
return res.json({
@@ -52,8 +55,14 @@ router.get(
router.get(
'/:collection/:pk',
validateCollection,
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await readItem(req.params.collection, req.params.pk);
const record = await ItemsService.readItem(
req.collection,
req.params.pk,
req.sanitizedQuery
);
return res.json({
data: record,
@@ -64,8 +73,11 @@ router.get(
router.patch(
'/:collection/:pk',
validateCollection,
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const item = await updateItem(req.params.collection, req.params.pk, req.body);
const primaryKey = await ItemsService.updateItem(req.collection, req.params.pk, req.body);
const item = await ItemsService.readItem(req.collection, primaryKey, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.UPDATE,
@@ -84,7 +96,7 @@ router.delete(
'/:collection/:pk',
validateCollection,
asyncHandler(async (req, res) => {
await deleteItem(req.params.collection, req.params.pk);
await ItemsService.deleteItem(req.collection, req.params.pk);
ActivityService.createActivity({
action: ActivityService.Action.DELETE,

View File

@@ -12,7 +12,8 @@ export enum Action {
}
export const createActivity = async (data: Record<string, any>, query?: Query) => {
return await ItemsService.createItem('directus_activity', data, query);
const primaryKey = await ItemsService.createItem('directus_activity', data);
return await ItemsService.readItem('directus_activity', primaryKey, query);
};
export const readActivities = async (query?: Query) => {

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createCollectionPreset = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_collection_presets', data, query);
const primaryKey = await ItemsService.createItem('directus_collection_presets', data);
return await ItemsService.readItem('directus_collection_presets', primaryKey, query);
};
export const readCollectionPresets = async (query: Query) => {
@@ -18,7 +19,8 @@ export const updateCollectionPreset = async (
data: Record<string, any>,
query: Query
) => {
return await ItemsService.updateItem('directus_collection_presets', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_collection_presets', pk, data);
return await ItemsService.readItem('directus_collection_presets', primaryKey, query);
};
export const deleteCollectionPreset = async (pk: string | number) => {

View File

@@ -36,7 +36,7 @@ export const create = async (payload: any) => {
});
});
const collection = await ItemsService.createItem('directus_collections', {
const primaryKey = await ItemsService.createItem('directus_collections', {
collection: payload.collection,
hidden: payload.hidden || false,
single: payload.single || false,
@@ -45,6 +45,8 @@ export const create = async (payload: any) => {
translation: payload.translation || null,
});
const collection = await ItemsService.createItem('directus_collections', primaryKey);
/**
* @TODO make this flexible and based on payload
*/

View File

@@ -51,7 +51,8 @@ export const createFile = async (
}
await storage.disk(data.storage).put(payload.filename_disk, stream.pipe(pipeline));
return await ItemsService.createItem('directus_files', payload, query);
const primaryKey = await ItemsService.createItem('directus_files', payload);
return await ItemsService.readItem('directus_files', primaryKey, query);
};
export const readFiles = async (query: Query) => {
@@ -90,7 +91,8 @@ export const updateFile = async (
await storage.disk(file.storage).put(file.filename_disk, stream as any);
}
return await ItemsService.updateItem('directus_files', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_files', pk, data);
return await ItemsService.readItem('directus_files', primaryKey, query);
};
export const deleteFile = async (pk: string | number) => {

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createFolder = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_folders', data, query);
const primaryKey = await ItemsService.createItem('directus_folders', data);
return await ItemsService.readItem('directus_folders', primaryKey, query);
};
export const readFolders = async (query: Query) => {
@@ -18,7 +19,8 @@ export const updateFolder = async (
data: Record<string, any>,
query: Query
) => {
return await ItemsService.updateItem('directus_folders', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_folders', pk, data);
return await ItemsService.readItem('directus_folders', primaryKey, query);
};
export const deleteFolder = async (pk: string | number) => {

View File

@@ -3,16 +3,32 @@ import { Query } from '../types/query';
import runAST from '../database/run-ast';
import getAST from '../utils/get-ast';
import * as PayloadService from './payload';
import { pick } from 'lodash';
export const createItem = async (collection: string, data: Record<string, any>) => {
let payload = await PayloadService.processValues('create', collection, data);
payload = await PayloadService.processM2O(collection, payload);
export const createItem = async (
collection: string,
data: Record<string, any>,
query: Query = {}
) => {
const payload = await PayloadService.processValues('create', collection, data);
const primaryKeyField = await schemaInspector.primary(collection);
const result = await database(collection).insert(payload).returning(primaryKeyField);
return readItem(collection, result[0], query);
// Only insert the values that actually save to an existing column. This ensures we ignore aliases etc
const columns = await schemaInspector.columns(collection);
const primaryKeys = await database(collection)
.insert(
pick(
payload,
columns.map(({ column }) => column)
)
)
.returning(primaryKeyField);
// This allows the o2m values to be populated correctly
payload[primaryKeyField] = primaryKeys[0];
await PayloadService.processO2M(collection, payload);
return primaryKeys[0];
};
export const readItems = async <T = Record<string, any>>(
@@ -51,16 +67,27 @@ export const readItem = async <T = any>(
export const updateItem = async (
collection: string,
pk: number | string,
data: Record<string, any>,
query: Query = {}
data: Record<string, any>
) => {
const payload = await PayloadService.processValues('create', collection, data);
let payload = await PayloadService.processValues('create', collection, data);
payload = await PayloadService.processM2O(collection, payload);
const primaryKeyField = await schemaInspector.primary(collection);
const result = await database(collection)
.update(payload)
// Only insert the values that actually save to an existing column. This ensures we ignore aliases etc
const columns = await schemaInspector.columns(collection);
const primaryKeys = await database(collection)
.update(
pick(
payload,
columns.map(({ column }) => column)
)
)
.where({ [primaryKeyField]: pk })
.returning('id');
return readItem(collection, result[0], query);
.returning(primaryKeyField);
return primaryKeys[0];
};
export const deleteItem = async (collection: string, pk: number | string) => {

View File

@@ -9,9 +9,51 @@ import { v4 as uuidv4 } from 'uuid';
import database from '../database';
import { clone } from 'lodash';
import { File } from '../types/files';
import { Relation } from '../types/relation';
import * as ItemsService from './items';
type Operation = 'create' | 'read' | 'update';
type Transformers = {
[type: string]: (
operation: Operation,
value: any,
payload: Record<string, any>
) => Promise<any>;
};
/**
* @todo allow this to be extended
*/
const transformers: Transformers = {
async hash(operation, value) {
if (!value) return;
if (operation === 'create' || operation === 'update') {
return await argon2.hash(String(value));
}
return value;
},
async uuid(operation, value) {
if (operation === 'create' && !value) {
return uuidv4();
}
return value;
},
async 'file-info'(operation, value, payload: File) {
if (operation === 'read' && payload) {
return {
asset_url: new URL(`/assets/${payload.id}`, process.env.PUBLIC_URL),
};
}
// This is an non-existing column, so there isn't any data to save
return undefined;
},
};
/**
* Process and update all the special fields in the given payload
*
@@ -47,8 +89,6 @@ export const processValues = async (
})
);
console.log(processedPayload);
/** @TODO
*
* - Make config.ts file in root
@@ -69,49 +109,113 @@ async function processField(
payload: Record<string, any>,
operation: Operation
) {
switch (field.special) {
case 'hash':
return await genHash(operation, payload[field.field], payload);
case 'uuid':
return await genUUID(operation, payload[field.field], payload);
case 'file-links':
// This is a system special type that only works on directus_files
return await genFileLinks(operation, payload[field.field], payload as File);
default:
return payload[field.field];
if (transformers.hasOwnProperty(field.special)) {
return await transformers[field.special](operation, payload[field.field], payload);
}
return payload[field.field];
}
/**
* @note The following functions can be called _a lot_. Make sure to utilize some form of caching
* if you have to rely on heavy operations
* Recursively checks for nested relational items, and saves them bottom up, to ensure we have IDs etc ready
*/
export const processM2O = async (collection: string, payload: Record<string, any>) => {
const payloadClone = clone(payload);
async function genHash(operation: Operation, value: any, payload: Record<string, any>) {
if (!value) return;
const relations = await database
.select<Relation[]>('*')
.from('directus_relations')
.where({ collection_many: collection });
if (operation === 'create' || operation === 'update') {
return await argon2.hash(String(value));
}
// Only process related records that are actually in the payload
const relationsToProcess = relations.filter((relation) => {
return (
payloadClone.hasOwnProperty(relation.field_many) &&
typeof payloadClone[relation.field_many] === 'object'
);
});
return value;
}
// Save all nested m2o records
await Promise.all(
relationsToProcess.map(async (relation) => {
const relatedRecord = payloadClone[relation.field_many];
const hasPrimaryKey = relatedRecord.hasOwnProperty(relation.primary_one);
async function genUUID(operation: Operation, value: any, payload: Record<string, any>) {
if (operation === 'create' && !value) {
return uuidv4();
}
let relatedPrimaryKey: string | number;
return value;
}
if (hasPrimaryKey) {
relatedPrimaryKey = relatedRecord[relation.primary_one];
await ItemsService.updateItem(
relation.collection_one,
relatedPrimaryKey,
relatedRecord
);
} else {
relatedPrimaryKey = await ItemsService.createItem(
relation.collection_one,
relatedRecord
);
}
async function genFileLinks(operation: Operation, value: undefined, payload: File) {
if (operation === 'read' && payload) {
return {
asset_url: new URL(`/assets/${payload.id}`, process.env.PUBLIC_URL),
};
}
// Overwrite the nested object with just the primary key, so the parent level can be saved correctly
payloadClone[relation.field_many] = relatedPrimaryKey;
})
);
// This is an non-existing column, so there isn't any data to save
return undefined;
}
return payloadClone;
};
export const processO2M = async (collection: string, payload: Record<string, any>) => {
const payloadClone = clone(payload);
const relations = await database
.select<Relation[]>('*')
.from('directus_relations')
.where({ collection_one: collection });
// Only process related records that are actually in the payload
const relationsToProcess = relations.filter((relation) => {
return (
payloadClone.hasOwnProperty(relation.field_one) &&
Array.isArray(payloadClone[relation.field_one])
);
});
// Save all nested o2m records
await Promise.all(
relationsToProcess.map(async (relation) => {
const relatedRecords = payloadClone[relation.field_one];
await Promise.all(
relatedRecords.map(async (relatedRecord: any, index: number) => {
relatedRecord[relation.field_many] = payloadClone[relation.primary_one];
const hasPrimaryKey = relatedRecord.hasOwnProperty(relation.primary_many);
let relatedPrimaryKey: string | number;
if (hasPrimaryKey) {
relatedPrimaryKey = relatedRecord[relation.primary_many];
await ItemsService.updateItem(
relation.collection_many,
relatedPrimaryKey,
relatedRecord
);
} else {
relatedPrimaryKey = await ItemsService.createItem(
relation.collection_many,
relatedRecord
);
}
relatedRecord[relation.primary_many] = relatedPrimaryKey;
payloadClone[relation.field_one][index] = relatedRecord;
})
);
})
);
return payloadClone;
};

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createPermission = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_permissions', data, query);
const primaryKey = await ItemsService.createItem('directus_permissions', data);
return await ItemsService.readItem('directus_permissions', primaryKey, query);
};
export const readPermissions = async (query: Query) => {
@@ -18,7 +19,8 @@ export const updatePermission = async (
data: Record<string, any>,
query: Query
) => {
return await ItemsService.updateItem('directus_permissions', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_permissions', pk, data);
return await ItemsService.readItem('directus_permissions', primaryKey, query);
};
export const deletePermission = async (pk: string | number) => {

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createRelation = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_relations', data, query);
const primaryKey = await ItemsService.createItem('directus_relations', data);
return ItemsService.readItem('directus_relations', primaryKey, query);
};
export const readRelations = async (query: Query) => {
@@ -18,7 +19,8 @@ export const updateRelation = async (
data: Record<string, any>,
query: Query
) => {
return await ItemsService.updateItem('directus_relations', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_relations', pk, data);
return ItemsService.readItem('directus_relations', primaryKey, query);
};
export const deleteRelation = async (pk: string | number) => {

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createRole = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_roles', data, query);
const primaryKey = await ItemsService.createItem('directus_roles', data);
return await ItemsService.readItem('directus_roles', primaryKey, query);
};
export const readRoles = async (query: Query) => {
@@ -14,7 +15,8 @@ export const readRole = async (pk: string | number, query: Query) => {
};
export const updateRole = async (pk: string | number, data: Record<string, any>, query: Query) => {
return await ItemsService.updateItem('directus_roles', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_roles', pk, data);
return await ItemsService.readItem('directus_roles', primaryKey, query);
};
export const deleteRole = async (pk: string | number) => {

View File

@@ -16,5 +16,6 @@ export const updateSettings = async (
query: Query
) => {
/** @NOTE I guess this can technically update _all_ items, as we expect there to only be one */
return await ItemsService.updateItem('directus_settings', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_settings', pk, data);
return await ItemsService.readItem('directus_settings', primaryKey, query);
};

View File

@@ -7,7 +7,8 @@ import argon2 from 'argon2';
import { InvalidPayloadException } from '../exceptions';
export const createUser = async (data: Record<string, any>, query?: Query) => {
return await ItemsService.createItem('directus_users', data, query);
const primaryKey = await ItemsService.createItem('directus_users', data);
return await ItemsService.readItem('directus_user', primaryKey, query);
};
export const readUsers = async (query?: Query) => {
@@ -25,7 +26,8 @@ export const updateUser = async (pk: string | number, data: Record<string, any>,
*
* Maybe make this an option?
*/
return await ItemsService.updateItem('directus_users', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_users', pk, data);
return await ItemsService.readItem('directus_user', primaryKey, query);
};
export const deleteUser = async (pk: string | number) => {

View File

@@ -2,7 +2,8 @@ import { Query } from '../types/query';
import * as ItemsService from './items';
export const createWebhook = async (data: Record<string, any>, query: Query) => {
return await ItemsService.createItem('directus_webhooks', data, query);
const primaryKey = await ItemsService.createItem('directus_webhooks', data);
return await ItemsService.readItem('directus_webhooks', primaryKey, query);
};
export const readWebhooks = async (query: Query) => {
@@ -18,7 +19,8 @@ export const updateWebhook = async (
data: Record<string, any>,
query: Query
) => {
return await ItemsService.updateItem('directus_webhooks', pk, data, query);
const primaryKey = await ItemsService.updateItem('directus_webhooks', pk, data);
return await ItemsService.readItem('directus_webhooks', primaryKey, query);
};
export const deleteWebhook = async (pk: string | number) => {

View File

@@ -1,7 +1,10 @@
export type Relation = {
id: number;
collection_many: string;
field_many: string;
primary_many: string;
collection_one: string;
field_one: string;
primary_one: string;