Merge pull request #49 from directus/relational-read

Relational read
This commit is contained in:
Rijk van Zanten
2020-07-03 09:41:27 -04:00
committed by GitHub
21 changed files with 501 additions and 92 deletions

View File

@@ -1,7 +1,7 @@
import knex from 'knex';
import logger from './logger';
import logger from '../logger';
import SchemaInspector from './knex-schema-inspector/lib/index';
import SchemaInspector from '../knex-schema-inspector/lib/index';
const log = logger.child({ module: 'sql' });

266
src/database/run-ast.ts Normal file
View File

@@ -0,0 +1,266 @@
import { AST, NestedCollectionAST } from '../types/ast';
import { uniq } from 'lodash';
import database from './index';
import { Query } from '../types/query';
// const testAST: AST = {
// type: 'collection',
// name: 'articles',
// query: {},
// children: [
// {
// type: 'field',
// name: 'id'
// },
// {
// type: 'field',
// name: 'title',
// },
// {
// type: 'collection',
// name: 'authors',
// fieldKey: 'author_id',
// parentKey: 'id',
// relation: {
// id: 2,
// collection_many: 'articles',
// field_many: 'author_id',
// collection_one: 'authors',
// primary_one: 'id',
// field_one: null
// },
// query: {},
// children: [
// {
// type: 'field',
// name: 'id'
// },
// {
// type: 'field',
// name: 'name'
// },
// {
// type: 'collection',
// name: 'movies',
// fieldKey: 'movies',
// parentKey: 'id',
// relation: {
// id: 3,
// collection_many: 'movies',
// field_many: 'author_id',
// collection_one: 'authors',
// primary_one: 'id',
// field_one: 'movies'
// },
// query: {},
// children: [
// {
// type: 'field',
// name: 'id',
// },
// {
// type: 'field',
// name: 'title'
// },
// {
// type: 'collection',
// name: 'authors',
// fieldKey: 'author_id',
// parentKey: 'id',
// relation: {
// id: 4,
// collection_many: 'movies',
// field_many: 'author_id',
// collection_one: 'authors',
// primary_one: 'id',
// field_one: 'movies',
// },
// query: {},
// children: [
// {
// type: 'field',
// name: 'id',
// },
// {
// type: 'field',
// name: 'name',
// },
// {
// type: 'collection',
// name: 'movies',
// fieldKey: 'movies',
// parentKey: 'id',
// relation: {
// id: 6,
// collection_many: 'movies',
// field_many: 'author_id',
// collection_one: 'authors',
// primary_one: 'id',
// field_one: 'movies'
// },
// query: {
// sort: [
// {
// column: 'title',
// order: 'asc'
// }
// ]
// },
// children: [
// {
// type: 'field',
// name: 'id'
// },
// {
// type: 'field',
// name: 'title'
// },
// {
// type: 'field',
// name: 'author_id'
// }
// ]
// }
// ]
// }
// ]
// }
// ]
// }
// ]
// }
export default async function runAST(ast: AST, query = ast.query) {
const toplevelFields: string[] = [];
const nestedCollections: NestedCollectionAST[] = [];
for (const child of ast.children) {
if (child.type === 'field') {
toplevelFields.push(child.name);
continue;
}
const m2o = isM2O(child);
if (m2o) {
toplevelFields.push(child.relation.field_many);
}
nestedCollections.push(child);
}
const dbQuery = database.select(toplevelFields).from(ast.name);
if (query.filter) {
query.filter.forEach((filter) => {
if (filter.operator === 'in') {
dbQuery.whereIn(filter.column, filter.value as (string | number)[]);
}
if (filter.operator === 'eq') {
dbQuery.where({ [filter.column]: filter.value });
}
if (filter.operator === 'neq') {
dbQuery.whereNot({ [filter.column]: filter.value });
}
if (filter.operator === 'null') {
dbQuery.whereNull(filter.column);
}
if (filter.operator === 'nnull') {
dbQuery.whereNotNull(filter.column);
}
});
}
if (query.sort) {
dbQuery.orderBy(query.sort);
}
if (query.limit && !query.offset) {
dbQuery.limit(query.limit);
}
if (query.offset) {
dbQuery.offset(query.offset);
}
if (query.page) {
dbQuery.offset(query.limit * (query.page - 1));
}
if (query.single) {
dbQuery.limit(1).first();
}
let results = await dbQuery;
for (const batch of nestedCollections) {
const m2o = isM2O(batch);
let batchQuery: Query = {};
if (m2o) {
batchQuery = {
...batch.query,
filter: [
...(batch.query.filter || []),
{
column: 'id',
operator: 'in',
value: uniq(results.map((res) => res[batch.relation.field_many])),
},
],
};
} else {
batchQuery = {
...batch.query,
filter: [
...(batch.query.filter || []),
{
column: batch.relation.field_many,
operator: 'in',
value: uniq(results.map((res) => res[batch.parentKey])),
},
],
};
}
const nestedResults = await runAST(batch, batchQuery);
results = results.map((record) => {
if (m2o) {
return {
...record,
[batch.fieldKey]: nestedResults.find((nestedRecord) => {
return nestedRecord[batch.relation.primary_one] === record[batch.fieldKey];
}),
};
}
return {
...record,
[batch.fieldKey]: nestedResults.filter((nestedRecord) => {
/**
* @todo
* pull the name ID from somewhere real
*/
return (
nestedRecord[batch.relation.field_many] === record.id ||
nestedRecord[batch.relation.field_many]?.id === record.id
);
}),
};
});
}
return results;
}
function isM2O(child: NestedCollectionAST) {
return (
child.relation.collection_one === child.name && child.relation.field_many === child.fieldKey
);
}

View File

@@ -38,13 +38,15 @@ async function validateFields(collection: string, query: Query) {
const fieldsToCheck = new Set<string>();
if (query.fields) {
query.fields.forEach((field) => fieldsToCheck.add(field));
/** @todo support relationships in here */
// query.fields.forEach((field) => fieldsToCheck.add(field));
}
if (query.sort) {
query.sort.forEach((sort) => fieldsToCheck.add(sort.column));
}
/** @todo swap with more efficient schemaInspector version */
const fieldsExist = await hasFields(collection, Array.from(fieldsToCheck));
Array.from(fieldsToCheck).forEach((field, index) => {

View File

@@ -13,7 +13,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await readActivities(res.locals.query);
const records = await 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, res.locals.query);
const record = await readActivity(req.params.pk, req.sanitizedQuery);
return res.json({
data: record,

View File

@@ -14,7 +14,7 @@ router.post(
asyncHandler(async (req, res) => {
const record = await CollectionPresetsService.createCollectionPreset(
req.body,
res.locals.query
req.sanitizedQuery
);
ActivityService.createActivity({
@@ -36,7 +36,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await CollectionPresetsService.readCollectionPresets(res.locals.query);
const records = await CollectionPresetsService.readCollectionPresets(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -49,7 +49,7 @@ router.get(
asyncHandler(async (req, res) => {
const record = await CollectionPresetsService.readCollectionPreset(
req.params.pk,
res.locals.query
req.sanitizedQuery
);
return res.json({ data: record });
})
@@ -62,7 +62,7 @@ router.patch(
const record = await CollectionPresetsService.updateCollectionPreset(
req.params.pk,
req.body,
res.locals.query
req.sanitizedQuery
);
ActivityService.createActivity({

View File

@@ -105,7 +105,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await FilesService.readFiles(res.locals.query);
const records = await FilesService.readFiles(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -116,7 +116,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await FilesService.readFile(req.params.pk, res.locals.query);
const record = await FilesService.readFile(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);

View File

@@ -14,7 +14,7 @@ router.post(
useCollection('directus_folders'),
asyncHandler(async (req, res) => {
const payload = await PayloadService.processValues('create', req.collection, req.body);
const record = await FoldersService.createFolder(payload, res.locals.query);
const record = await FoldersService.createFolder(payload, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -35,7 +35,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await FoldersService.readFolders(res.locals.query);
const records = await FoldersService.readFolders(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -46,7 +46,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await FoldersService.readFolder(req.params.pk, res.locals.query);
const record = await FoldersService.readFolder(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);
@@ -57,7 +57,11 @@ router.patch(
asyncHandler(async (req, res) => {
const payload = await PayloadService.processValues('create', req.collection, req.body);
const record = await FoldersService.updateFolder(req.params.pk, payload, res.locals.query);
const record = await FoldersService.updateFolder(
req.params.pk,
payload,
req.sanitizedQuery
);
ActivityService.createActivity({
action: ActivityService.Action.UPDATE,

View File

@@ -40,8 +40,8 @@ router.get(
validateQuery,
asyncHandler(async (req, res) => {
const [records, meta] = await Promise.all([
readItems(req.params.collection, res.locals.query),
MetaService.getMetaForQuery(req.params.collection, res.locals.query),
readItems(req.params.collection, req.sanitizedQuery),
MetaService.getMetaForQuery(req.params.collection, req.sanitizedQuery),
]);
return res.json({

View File

@@ -12,7 +12,7 @@ router.post(
'/',
useCollection('directus_permissions'),
asyncHandler(async (req, res) => {
const item = await PermissionsService.createPermission(req.body, res.locals.query);
const item = await PermissionsService.createPermission(req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -33,7 +33,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const item = await PermissionsService.readPermissions(res.locals.query);
const item = await PermissionsService.readPermissions(req.sanitizedQuery);
return res.json({ data: item });
})
);
@@ -44,7 +44,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await PermissionsService.readPermission(req.params.pk, res.locals.query);
const record = await PermissionsService.readPermission(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);
@@ -56,7 +56,7 @@ router.patch(
const item = await PermissionsService.updatePermission(
req.params.pk,
req.body,
res.locals.query
req.sanitizedQuery
);
ActivityService.createActivity({

View File

@@ -12,7 +12,7 @@ router.post(
'/',
useCollection('directus_relations'),
asyncHandler(async (req, res) => {
const item = await RelationsService.createRelation(req.body, res.locals.query);
const item = await RelationsService.createRelation(req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -33,7 +33,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await RelationsService.readRelations(res.locals.query);
const records = await RelationsService.readRelations(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -44,7 +44,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await RelationsService.readRelation(req.params.pk, res.locals.query);
const record = await RelationsService.readRelation(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);
@@ -56,7 +56,7 @@ router.patch(
const item = await RelationsService.updateRelation(
req.params.pk,
req.body,
res.locals.query
req.sanitizedQuery
);
ActivityService.createActivity({

View File

@@ -13,7 +13,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await RevisionsService.readRevisions(res.locals.query);
const records = await RevisionsService.readRevisions(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -24,7 +24,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await RevisionsService.readRevision(req.params.pk, res.locals.query);
const record = await RevisionsService.readRevision(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);

View File

@@ -12,7 +12,7 @@ router.post(
'/',
useCollection('directus_roles'),
asyncHandler(async (req, res) => {
const item = await RolesService.createRole(req.body, res.locals.query);
const item = await RolesService.createRole(req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -33,7 +33,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await RolesService.readRoles(res.locals.query);
const records = await RolesService.readRoles(req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -44,7 +44,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const record = await RolesService.readRole(req.params.pk, res.locals.query);
const record = await RolesService.readRole(req.params.pk, req.sanitizedQuery);
return res.json({ data: record });
})
);
@@ -53,7 +53,7 @@ router.patch(
'/:pk',
useCollection('directus_roles'),
asyncHandler(async (req, res) => {
const item = await RolesService.updateRole(req.params.pk, req.body, res.locals.query);
const item = await RolesService.updateRole(req.params.pk, req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.UPDATE,

View File

@@ -13,7 +13,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const records = await SettingsService.readSettings(1, res.locals.query);
const records = await SettingsService.readSettings(1, req.sanitizedQuery);
return res.json({ data: records });
})
);
@@ -25,7 +25,7 @@ router.patch(
const records = await SettingsService.updateSettings(
req.params.pk /** @TODO Singleton */,
req.body,
res.locals.query
req.sanitizedQuery
);
return res.json({ data: records });
})

View File

@@ -14,7 +14,7 @@ router.post(
'/',
useCollection('directus_users'),
asyncHandler(async (req, res) => {
const item = await UsersService.createUser(req.body, res.locals.query);
const item = await UsersService.createUser(req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.CREATE,
@@ -35,7 +35,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const item = await UsersService.readUsers(res.locals.query);
const item = await UsersService.readUsers(req.sanitizedQuery);
return res.json({ data: item });
})
@@ -47,7 +47,7 @@ router.get(
sanitizeQuery,
validateQuery,
asyncHandler(async (req, res) => {
const items = await UsersService.readUser(req.params.pk, res.locals.query);
const items = await UsersService.readUser(req.params.pk, req.sanitizedQuery);
return res.json({ data: items });
})
);
@@ -56,7 +56,7 @@ router.patch(
'/:pk',
useCollection('directus_users'),
asyncHandler(async (req, res) => {
const item = await UsersService.updateUser(req.params.pk, req.body, res.locals.query);
const item = await UsersService.updateUser(req.params.pk, req.body, req.sanitizedQuery);
ActivityService.createActivity({
action: ActivityService.Action.UPDATE,

View File

@@ -1,5 +1,15 @@
import database, { schemaInspector } from '../database';
import { Field } from '../types/field';
import { uniq } from 'lodash';
export const fieldsInCollection = async (collection: string) => {
const [fields, columns] = await Promise.all([
database.select('field').from('directus_fields').where({ collection }),
schemaInspector.columns(collection),
]);
return uniq([...fields.map(({ field }) => field), ...columns.map(({ column }) => column)]);
};
export const readAll = async (collection?: string) => {
const fieldsQuery = database.select('*').from('directus_fields');

View File

@@ -1,5 +1,7 @@
import database, { schemaInspector } from '../database';
import { Query } from '../types/query';
import runAST from '../database/run-ast';
import getAST from '../utils/get-ast';
export const createItem = async (
collection: string,
@@ -15,54 +17,8 @@ export const readItems = async <T = Record<string, any>>(
collection: string,
query: Query = {}
): Promise<T[]> => {
const dbQuery = database.select(query?.fields || '*').from(collection);
if (query.sort) {
dbQuery.orderBy(query.sort);
}
if (query.filter) {
query.filter.forEach((filter) => {
if (filter.operator === 'eq') {
dbQuery.where({ [filter.column]: filter.value });
}
if (filter.operator === 'neq') {
dbQuery.whereNot({ [filter.column]: filter.value });
}
if (filter.operator === 'null') {
dbQuery.whereNull(filter.column);
}
if (filter.operator === 'nnull') {
dbQuery.whereNotNull(filter.column);
}
});
}
if (query.limit && !query.offset) {
dbQuery.limit(query.limit);
}
if (query.offset) {
dbQuery.offset(query.offset);
}
if (query.page) {
dbQuery.offset(query.limit * (query.page - 1));
}
if (query.single) {
dbQuery.limit(1);
}
const records = await dbQuery;
if (query.single) {
return records[0];
}
const ast = await getAST(collection, query);
const records = await runAST(ast);
return records;
};
@@ -72,11 +28,22 @@ export const readItem = async <T = any>(
query: Query = {}
): Promise<T> => {
const primaryKeyField = await schemaInspector.primary(collection);
return await database
.select('*')
.from(collection)
.where({ [primaryKeyField]: pk })
.first();
query = {
...query,
filter: [
...query.filter,
{
column: primaryKeyField,
operator: 'eq',
value: pk,
},
],
};
const ast = await getAST(collection, query);
const records = await runAST(ast);
return records[0];
};
export const updateItem = async (

View File

@@ -2,7 +2,7 @@ import { Query } from '../types/query';
import database from '../database';
export const getMetaForQuery = async (collection: string, query: Query) => {
if (!query.meta) return;
if (!query || !query.meta) return;
const results = await Promise.all(
query.meta.map((metaVal) => {

24
src/types/ast.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Query } from './query';
import { Relation } from './relation';
export type NestedCollectionAST = {
type: 'collection';
name: string;
children: (NestedCollectionAST | FieldAST)[];
query: Query;
fieldKey: string;
relation: Relation;
parentKey: string;
};
export type FieldAST = {
type: 'field';
name: string;
};
export type AST = {
type: 'collection';
name: string;
children: (NestedCollectionAST | FieldAST)[];
query: Query;
};

View File

@@ -19,7 +19,7 @@ export type Sort = {
export type Filter = {
column: string;
operator: FilterOperator;
value: null | string | number;
value: null | string | number | (string | number)[];
};
export type FilterOperator = 'eq' | 'neq' | 'in' | 'nin' | 'null' | 'nnull';

8
src/types/relation.ts Normal file
View File

@@ -0,0 +1,8 @@
export type Relation = {
id: number;
collection_many: string;
field_many: string;
collection_one: string;
field_one: string;
primary_one: string;
};

128
src/utils/get-ast.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Generate an AST based on a given collection and query
*/
import { Query } from '../types/query';
import { Relation } from '../types/relation';
import { AST, NestedCollectionAST, FieldAST } from '../types/ast';
import database from '../database';
import * as FieldsService from '../services/fields';
export default async function getAST(collection: string, query: Query): Promise<AST> {
const ast: AST = {
type: 'collection',
name: collection,
query: query,
children: [],
};
if (!query.fields) query.fields = ['*'];
/** @todo support wildcard */
const fields = query.fields;
// If no relational fields are requested, we can stop early
const hasRelations = query.fields.some((field) => field.includes('.'));
if (hasRelations === false) {
fields.forEach((field) => {
ast.children.push({
type: 'field',
name: field,
});
});
return ast;
}
// Even though we might not need all records from relations, it'll be faster to load all records
// into memory once and search through it in JS than it would be to do individual queries to fetch
// this data field by field
const relations = await database.select<Relation[]>('*').from('directus_relations');
ast.children = await parseFields(collection, query.fields);
return ast;
async function parseFields(parentCollection: string, fields: string[]) {
const children: (NestedCollectionAST | FieldAST)[] = [];
const relationalStructure: Record<string, string[]> = {};
// Swap *.* case for *,<relational-field>.*
for (let i = 0; i < fields.length; i++) {
const fieldKey = fields[i];
if (fieldKey.includes('.') === false) continue;
const parts = fieldKey.split('.');
if (parts[0] === '*') {
const availableFields = await FieldsService.fieldsInCollection(parentCollection);
fields.splice(
i,
1,
...availableFields
.filter((field) => !!getRelation(parentCollection, field))
.map((field) => `${field}.${parts.slice(1).join('.')}`)
);
fields.push('*');
}
}
for (const field of fields) {
if (field.includes('.') === false) {
children.push({ type: 'field', name: field });
} else {
// field is relational
const parts = field.split('.');
if (relationalStructure.hasOwnProperty(parts[0]) === false) {
relationalStructure[parts[0]] = [];
}
relationalStructure[parts[0]].push(parts.slice(1).join('.'));
}
}
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
const child: NestedCollectionAST = {
type: 'collection',
name: relatedCollection,
fieldKey: relationalField,
parentKey: 'id' /** @todo this needs to come from somewhere real */,
relation: getRelation(parentCollection, relationalField),
query: {} /** @todo inject nested query here */,
children: await parseFields(relatedCollection, nestedFields),
};
children.push(child);
}
return children;
}
function getRelation(collection: string, field: string) {
const relation = relations.find((relation) => {
return (
(relation.collection_many === collection && relation.field_many === field) ||
(relation.collection_one === collection && relation.field_one === field)
);
});
return relation;
}
function getRelatedCollection(collection: string, field: string) {
const relation = getRelation(collection, field);
if (relation.collection_many === collection && relation.field_many === field) {
return relation.collection_one;
}
if (relation.collection_one === collection && relation.field_one === field) {
return relation.collection_many;
}
}
}