Insights 2.0 (#14096)

* query function added to list

* dashboard reading query, adding to object

* typecasting of filter vals needed still

* numbers accepting strings too

* json-to-graphql-query => devD

* fixed unneeded return in list index.ts

* stitching and calling but not actually calling

* calls on panel change

* query object += new panel before dashboard save

* uuid generated in app not api

* fixed panel ids in query

* fixed the tests I just wrote

* passing the query data down!

* list showing data

* objDiff test moved to test

* metric bug fixes + data

* dashboard logic

* time series conversion started

* timeseries GQL query almost there

* query querying

* chart loading

* aggregate handling improved

* error handling for aggregate+filter errors

* removed query on empty queryObj

* maybe more error handling

* more error handling working

* improvements to erorr handling

* stitchGQL() error return type corrected

* added string fields to COUNT

* pushing up but needs work

* not an endless recursion

* its not pretty but it works.

* throws an error

* system collections supported

* refactor to solve some errors

* loading correct

* metric function fixed

* data loading but not blocking rendering

* removed redundant code.

* relational fields

* deep nesting relations

* options.precision has a default

* relational fields fix. (thanks azri)

* the limit

* limit and time series

* range has a default

* datat to workspace

* v-if

* panels loading

* workspaces dont get data anymore

* package.json

* requested changes

* loading

* get groups util

* timeseries => script setup

* list => script setup

* metric => script setup

* label => script setup

* declare optional props

* loadingPanels: only loading spinner on loading panels

* remove unneeded parseDate!!

* applyDataToPanels tests

* -.only

* remove unneeded steps

* processQuery tests

* tests

* removed unused var

* jest.config and some queryCaller tests

* one more test

* query tests

* typo

* clean up

* fix some but not all bugs

* bugs from merge fixed

* Start cleaning up 🧹

* Refactor custom input type

* Small tweaks in list index

* Cleanup imports

* Require Query object to be returned from query prop

* Tweak return statement

* Fix imports

* Cleanup metric watch effect

* Tweaks tweaks tweaks

* Don't rely on options, simplify fetch logic

* Add paths to validation errors

* [WIP] Start handling things in the store

* Rework query fetching logic into store

* Clean up data passing

* Use composition setup for insights store

* Remove outdated

* Fix missing return

* Allow batch updating in REST API

Allows sending an array of partial items to the endpoints, updating all to their own values

* Add batch update to graphql

* Start integrating edits

* Readd clear

* Add deletion

* Add duplication

* Finish create flow

* Resolve cache refresh on panel config

* Prevent warnings about component name

* Improve loading state

* Finalize dashboard overhaul

* Add auto-refresh sidebar detail

* Add efficient panel reloading

* Set/remove errors on succeeded requests

* Move options rendering to shared

* Fix wrong imports, render options in app

* Selectively reload panels with changed variables

* Ensure newly added panels don't lose data

* Only refresh panel if data query changed

* Never use empty filter object in metric query

* Add default value support to variable panel

* Centralize no-data state

* Only reload data on var change when query is altered

* Fix build

* Fix time series order

* Remove unused utils

* Remove no-longer-used logic

* Mark batch update result as non-nullable in GraphQL schema

* Interim flows fix

* Skip parsing undefined keys

* Refresh insights dashboard when discarding changes

* Don't submit primary key when updating batch

* Handle null prop field better

* Tweak panel padding

Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Rijk van Zanten
2022-06-27 15:26:42 -04:00
committed by GitHub
parent 6cba7fb91f
commit 32dd709778
91 changed files with 2259 additions and 1526 deletions

View File

@@ -1,3 +1,5 @@
import { BaseException } from '@directus/shared/exceptions';
import { parseJSON } from '@directus/shared/utils';
import { Router } from 'express';
import flatten from 'flat';
import jwt from 'jsonwebtoken';
@@ -5,7 +7,6 @@ import ms from 'ms';
import { Client, errors, generators, Issuer } from 'openid-client';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { BaseException } from '@directus/shared/exceptions';
import {
InvalidConfigException,
InvalidCredentialsException,
@@ -19,7 +20,6 @@ import { AuthData, AuthDriverOptions, User } from '../../types';
import asyncHandler from '../../utils/async-handler';
import { getConfigFromEnv } from '../../utils/get-config-from-env';
import { getIPFromReq } from '../../utils/get-ip-from-req';
import { parseJSON } from '../../utils/parse-json';
import { Url } from '../../utils/url';
import { LocalAuthDriver } from './local';

View File

@@ -1,3 +1,5 @@
import { BaseException } from '@directus/shared/exceptions';
import { parseJSON } from '@directus/shared/utils';
import { Router } from 'express';
import flatten from 'flat';
import jwt from 'jsonwebtoken';
@@ -5,7 +7,6 @@ import ms from 'ms';
import { Client, errors, generators, Issuer } from 'openid-client';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { BaseException } from '@directus/shared/exceptions';
import {
InvalidConfigException,
InvalidCredentialsException,
@@ -19,7 +20,6 @@ import { AuthData, AuthDriverOptions, User } from '../../types';
import asyncHandler from '../../utils/async-handler';
import { getConfigFromEnv } from '../../utils/get-config-from-env';
import { getIPFromReq } from '../../utils/get-ip-from-req';
import { parseJSON } from '../../utils/parse-json';
import { Url } from '../../utils/url';
import { LocalAuthDriver } from './local';

View File

@@ -1,3 +1,4 @@
import { parseJSON } from '@directus/shared/utils';
import chalk from 'chalk';
import { promises as fs } from 'fs';
import inquirer from 'inquirer';
@@ -10,7 +11,6 @@ import { Snapshot } from '../../../types';
import { applySnapshot, isNestedMetaUpdate } from '../../../utils/apply-snapshot';
import { getSnapshot } from '../../../utils/get-snapshot';
import { getSnapshotDiff } from '../../../utils/get-snapshot-diff';
import { parseJSON } from '../../../utils/parse-json';
export async function apply(snapshotPath: string, options?: { yes: boolean; dryRun: boolean }): Promise<void> {
const filename = path.resolve(process.cwd(), snapshotPath);

View File

@@ -1,4 +1,5 @@
import { Range } from '@directus/drive';
import { parseJSON } from '@directus/shared/utils';
import { Router } from 'express';
import helmet from 'helmet';
import { merge, pick } from 'lodash';
@@ -12,7 +13,6 @@ import { AssetsService, PayloadService } from '../services';
import { TransformationMethods, TransformationParams, TransformationPreset } from '../types/assets';
import asyncHandler from '../utils/async-handler';
import { getConfigFromEnv } from '../utils/get-config-from-env';
import { parseJSON } from '../utils/parse-json';
const router = Router();

View File

@@ -97,7 +97,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -258,7 +258,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -124,7 +124,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -105,7 +105,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -135,7 +135,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -106,7 +106,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -97,7 +97,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -97,7 +97,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -107,7 +107,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -106,7 +106,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -98,7 +98,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -200,7 +200,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -183,7 +183,9 @@ router.patch(
let keys: PrimaryKey[] = [];
if (req.body.keys) {
if (Array.isArray(req.body)) {
keys = await service.updateBatch(req.body);
} else if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);

View File

@@ -1,5 +1,5 @@
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import { parseJSON } from '../../utils/parse-json';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_relations', (table) => {

View File

@@ -1,5 +1,5 @@
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import { parseJSON } from '../../utils/parse-json';
// [before, after, after-option additions]
const changes: [string, string, Record<string, any>?][] = [

View File

@@ -1,6 +1,6 @@
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import logger from '../../logger';
import { parseJSON } from '../../utils/parse-json';
export async function up(knex: Knex): Promise<void> {
const dividerGroups = await knex.select('*').from('directus_fields').where('interface', '=', 'group-divider');

View File

@@ -1,5 +1,5 @@
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import { parseJSON } from '../../utils/parse-json';
export async function up(knex: Knex): Promise<void> {
const groups = await knex.select('*').from('directus_fields').where({ interface: 'group-standard' });

View File

@@ -1,5 +1,5 @@
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import { parseJSON } from '../../utils/parse-json';
// Change image metadata structure to match the output from 'exifr'
export async function up(knex: Knex): Promise<void> {

View File

@@ -1,7 +1,7 @@
import { Filter, LogicalFilterAND } from '@directus/shared/types';
import { parseJSON } from '@directus/shared/utils';
import { Knex } from 'knex';
import { nanoid } from 'nanoid';
import { parseJSON } from '../../utils/parse-json';
type OldFilter = {
key: string;

View File

@@ -1,7 +1,6 @@
import { parseJSON, toArray } from '@directus/shared/utils';
import { Knex } from 'knex';
import { toArray } from '@directus/shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { parseJSON } from '../../utils/parse-json';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('directus_flows', (table) => {

View File

@@ -9,7 +9,7 @@ import { clone, toNumber, toString } from 'lodash';
import path from 'path';
import { requireYAML } from './utils/require-yaml';
import { toArray } from '@directus/shared/utils';
import { parseJSON } from './utils/parse-json';
import { parseJSON } from '@directus/shared/utils';
// keeping this here for now to prevent a circular import to constants.ts
const allowedEnvironmentVars = [

View File

@@ -1,14 +1,18 @@
import * as sharedExceptions from '@directus/shared/exceptions';
import {
Accountability,
Action,
ActionHandler,
FilterHandler,
Flow,
Operation,
OperationHandler,
SchemaOverview,
Accountability,
Action,
} from '@directus/shared/types';
import { applyOptionsData } from '@directus/shared/utils';
import fastRedact from 'fast-redact';
import { Knex } from 'knex';
import { omit } from 'lodash';
import { get } from 'micromustache';
import { schedule, validate } from 'node-cron';
import getDatabase from './database';
@@ -16,18 +20,14 @@ import emitter from './emitter';
import env from './env';
import * as exceptions from './exceptions';
import logger from './logger';
import { getMessenger } from './messenger';
import * as services from './services';
import { FlowsService } from './services';
import { ActivityService } from './services/activity';
import { RevisionsService } from './services/revisions';
import { EventHandler } from './types';
import { constructFlowTree } from './utils/construct-flow-tree';
import { getSchema } from './utils/get-schema';
import { ActivityService } from './services/activity';
import { RevisionsService } from './services/revisions';
import { Knex } from 'knex';
import { omit } from 'lodash';
import { getMessenger } from './messenger';
import fastRedact from 'fast-redact';
import { applyOperationOptions } from './utils/operation-options';
import { JobQueue } from './utils/job-queue';
let flowManager: FlowManager | undefined;
@@ -376,7 +376,7 @@ class FlowManager {
const handler = this.operations[operation.type];
const options = applyOperationOptions(operation.options, keyedData);
const options = applyOptionsData(operation.options, keyedData);
try {
const result = await handler(options, {

View File

@@ -1,7 +1,7 @@
import { parseJSON } from '@directus/shared/utils';
import IORedis from 'ioredis';
import env from './env';
import { getConfigFromEnv } from './utils/get-config-from-env';
import { parseJSON } from './utils/parse-json';
export type MessengerSubscriptionCallback = (payload: Record<string, any>) => void;

View File

@@ -1,9 +1,9 @@
import { parseJSON } from '@directus/shared/utils';
import { RequestHandler } from 'express';
import { DocumentNode, getOperationAST, parse, Source } from 'graphql';
import { InvalidPayloadException, InvalidQueryException, MethodNotAllowedException } from '../exceptions';
import { GraphQLParams } from '../types';
import asyncHandler from '../utils/async-handler';
import { parseJSON } from '../utils/parse-json';
export const parseGraphQL: RequestHandler = asyncHandler(async (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'POST') {

View File

@@ -27,8 +27,10 @@ export const validateBatch = (scope: 'read' | 'update' | 'delete'): RequestHandl
batchSchema = batchSchema.xor('query', 'keys');
}
// In updates, we add a required `data` that holds the update payload
// In updates, we add a required `data` that holds the update payload if an array isn't used
if (scope === 'update') {
if (Array.isArray(req.body)) return next();
batchSchema = batchSchema.keys({
data: Joi.object().unknown().required(),
});

View File

@@ -1,8 +1,7 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { optionToObject } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {

View File

@@ -1,8 +1,7 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
import { optionToObject } from '../../utils/operation-options';
import { sanitizeQuery } from '../../utils/sanitize-query';
type Options = {

View File

@@ -1,9 +1,8 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
import { optionToObject } from '../../utils/operation-options';
import { sanitizeQuery } from '../../utils/sanitize-query';
type Options = {

View File

@@ -1,9 +1,8 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
import { optionToObject } from '../../utils/operation-options';
import { sanitizeQuery } from '../../utils/sanitize-query';
type Options = {

View File

@@ -1,6 +1,5 @@
import { defineOperationApi } from '@directus/shared/utils';
import { defineOperationApi, optionToString } from '@directus/shared/utils';
import logger from '../../logger';
import { optionToString } from '../../utils/operation-options';
type Options = {
message: unknown;

View File

@@ -1,7 +1,6 @@
import { Accountability } from '@directus/shared/types';
import { defineOperationApi } from '@directus/shared/utils';
import { defineOperationApi, optionToString } from '@directus/shared/utils';
import { NotificationsService } from '../../services';
import { optionToString } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {

View File

@@ -1,5 +1,4 @@
import { defineOperationApi } from '@directus/shared/utils';
import { parseJSON } from '../../utils/parse-json';
import { defineOperationApi, parseJSON } from '@directus/shared/utils';
type Options = {
json: string;

View File

@@ -1,6 +1,5 @@
import { defineOperationApi } from '@directus/shared/utils';
import { defineOperationApi, optionToObject } from '@directus/shared/utils';
import { getFlowManager } from '../../flows';
import { optionToObject } from '../../utils/operation-options';
type Options = {
flow: string;

View File

@@ -35,6 +35,15 @@ export class FlowsService extends ItemsService<FlowRaw> {
return result;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();
const result = await super.updateBatch(data, opts);
await flowManager.reload();
return result;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();

View File

@@ -27,6 +27,7 @@ import {
GraphQLUnionType,
InlineFragmentNode,
IntValueNode,
NoSchemaIntrospectionCustomRule,
ObjectFieldNode,
ObjectValueNode,
SelectionNode,
@@ -46,71 +47,56 @@ import {
import { Knex } from 'knex';
import { flatten, get, isObject, mapKeys, merge, omit, pick, set, transform, uniq } from 'lodash';
import ms from 'ms';
import { clearSystemCache, getCache } from '../cache';
import { DEFAULT_AUTH_PROVIDER } from '../constants';
import getDatabase from '../database';
import env from '../env';
import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions';
import { getExtensionManager } from '../extensions';
import { AbstractServiceOptions, GraphQLParams, Item } from '../types';
import { generateHash } from '../utils/generate-hash';
import { getGraphQLType } from '../utils/get-graphql-type';
import { reduceSchema } from '../utils/reduce-schema';
import { sanitizeQuery } from '../utils/sanitize-query';
import { validateQuery } from '../utils/validate-query';
import { ActivityService } from './activity';
import { AuthenticationService } from './authentication';
import { CollectionsService } from './collections';
import { FieldsService } from './fields';
import { FilesService } from './files';
import { FlowsService } from './flows';
import { FoldersService } from './folders';
import { ItemsService } from './items';
import { NotificationsService } from './notifications';
import { OperationsService } from './operations';
import { PermissionsService } from './permissions';
import { PresetsService } from './presets';
import { RelationsService } from './relations';
import { RevisionsService } from './revisions';
import { RolesService } from './roles';
import { ServerService } from './server';
import { SettingsService } from './settings';
import { SharesService } from './shares';
import { SpecificationService } from './specifications';
import { TFAService } from './tfa';
import { UsersService } from './users';
import { UtilsService } from './utils';
import { WebhooksService } from './webhooks';
import { clearSystemCache, getCache } from '../../cache';
import { DEFAULT_AUTH_PROVIDER } from '../../constants';
import getDatabase from '../../database';
import env from '../../env';
import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../../exceptions';
import { getExtensionManager } from '../../extensions';
import { AbstractServiceOptions, GraphQLParams, Item } from '../../types';
import { generateHash } from '../../utils/generate-hash';
import { getGraphQLType } from '../../utils/get-graphql-type';
import { reduceSchema } from '../../utils/reduce-schema';
import { sanitizeQuery } from '../../utils/sanitize-query';
import { validateQuery } from '../../utils/validate-query';
import { ActivityService } from '../activity';
import { AuthenticationService } from '../authentication';
import { CollectionsService } from '../collections';
import { FieldsService } from '../fields';
import { FilesService } from '../files';
import { FlowsService } from '../flows';
import { FoldersService } from '../folders';
import { ItemsService } from '../items';
import { NotificationsService } from '../notifications';
import { OperationsService } from '../operations';
import { PermissionsService } from '../permissions';
import { PresetsService } from '../presets';
import { RelationsService } from '../relations';
import { RevisionsService } from '../revisions';
import { RolesService } from '../roles';
import { ServerService } from '../server';
import { SettingsService } from '../settings';
import { SharesService } from '../shares';
import { SpecificationService } from '../specifications';
import { TFAService } from '../tfa';
import { UsersService } from '../users';
import { UtilsService } from '../utils';
import { WebhooksService } from '../webhooks';
const GraphQLVoid = new GraphQLScalarType({
name: 'Void',
import { GraphQLDate } from './types/date';
import { GraphQLGeoJSON } from './types/geojson';
import { GraphQLStringOrFloat } from './types/string-or-float';
import { GraphQLVoid } from './types/void';
description: 'Represents NULL values',
import { PrimaryKey } from '@directus/shared/types';
serialize() {
return null;
},
import { addPathToValidationError } from './utils/add-path-to-validation-error';
parseValue() {
return null;
},
const validationRules = Array.from(specifiedRules);
parseLiteral() {
return null;
},
});
export const GraphQLGeoJSON = new GraphQLScalarType({
...GraphQLJSON,
name: 'GraphQLGeoJSON',
description: 'GeoJSON value',
});
export const GraphQLDate = new GraphQLScalarType({
...GraphQLString,
name: 'Date',
description: 'ISO8601 Date values',
});
if (env.GRAPHQL_INTROSPECTION === false) {
validationRules.push(NoSchemaIntrospectionCustomRule);
}
/**
* These should be ignored in the context of GraphQL, and/or are replaced by a custom resolver (for non-standard structures)
@@ -122,6 +108,7 @@ const SYSTEM_DENY_LIST = [
'directus_migrations',
'directus_sessions',
];
const READ_ONLY = ['directus_activity', 'directus_revisions'];
export class GraphQLService {
@@ -148,18 +135,9 @@ export class GraphQLService {
}: GraphQLParams): Promise<FormattedExecutionResult> {
const schema = this.getSchema();
const validationErrors = validate(schema, document, [
...specifiedRules,
(context) => ({
Field(node) {
if (env.GRAPHQL_INTROSPECTION === false && (node.name.value === '__schema' || node.name.value === '__type')) {
context.reportError(
new GraphQLError('GraphQL introspection is not allowed. The query contained __schema or __type.', [node])
);
}
},
}),
]);
const validationErrors = validate(schema, document, validationRules).map((validationError) =>
addPathToValidationError(validationError)
);
if (validationErrors.length > 0) {
throw new GraphQLValidationException({ graphqlErrors: validationErrors });
@@ -312,6 +290,10 @@ export class GraphQLService {
`update_${collection.collection}_items`
);
acc[`update_${collectionName}_batch`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_batch`
);
acc[`update_${collectionName}_item`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_item`
);
@@ -653,32 +635,33 @@ export class GraphQLService {
},
});
// Uses StringOrFloat rather than Float to support api dynamic variables (like `$NOW`)
const NumberFilterOperators = schemaComposer.createInputTC({
name: 'number_filter_operators',
fields: {
_eq: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_neq: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_in: {
type: new GraphQLList(GraphQLFloat),
type: new GraphQLList(GraphQLStringOrFloat),
},
_nin: {
type: new GraphQLList(GraphQLFloat),
type: new GraphQLList(GraphQLStringOrFloat),
},
_gt: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_gte: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_lt: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_lte: {
type: GraphQLFloat,
type: GraphQLStringOrFloat,
},
_null: {
type: GraphQLBoolean,
@@ -1143,6 +1126,27 @@ export class GraphQLService {
await self.resolveMutation(args, info),
});
} else {
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_batch`,
type: collectionIsReadable
? new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection].getType()))
)
: GraphQLBoolean,
args: {
...(collectionIsReadable
? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs()
: {}),
data: [
toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
],
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_items`,
type: collectionIsReadable
@@ -1290,12 +1294,15 @@ export class GraphQLService {
const query = this.getQuery(args, selections || [], info.variableValues);
const singleton =
collection.endsWith('_batch') === false &&
collection.endsWith('_items') === false &&
collection.endsWith('_item') === false &&
collection in this.schema.collections;
const single = collection.endsWith('_items') === false;
const single = collection.endsWith('_items') === false && collection.endsWith('_batch') === false;
const batchUpdate = action === 'update' && collection.endsWith('_batch');
if (collection.endsWith('_batch')) collection = collection.slice(0, -6);
if (collection.endsWith('_items')) collection = collection.slice(0, -6);
if (collection.endsWith('_item')) collection = collection.slice(0, -5);
@@ -1329,7 +1336,14 @@ export class GraphQLService {
}
if (action === 'update') {
const keys = await service.updateMany(args.ids, args.data);
const keys: PrimaryKey[] = [];
if (batchUpdate) {
keys.push(...(await service.updateBatch(args.data)));
} else {
keys.push(...(await service.updateMany(args.ids, args.data)));
}
return hasQuery ? await service.readMany(keys, query) : true;
}

View File

@@ -0,0 +1,7 @@
import { GraphQLString, GraphQLScalarType } from 'graphql';
export const GraphQLDate = new GraphQLScalarType({
...GraphQLString,
name: 'Date',
description: 'ISO8601 Date values',
});

View File

@@ -0,0 +1,8 @@
import { GraphQLScalarType } from 'graphql';
import { GraphQLJSON } from 'graphql-compose';
export const GraphQLGeoJSON = new GraphQLScalarType({
...GraphQLJSON,
name: 'GraphQLGeoJSON',
description: 'GeoJSON value',
});

View File

@@ -0,0 +1,35 @@
import { GraphQLScalarType, Kind } from 'graphql';
/**
* Adopted from https://kamranicus.com/handling-multiple-scalar-types-in-graphql/
*/
export const GraphQLStringOrFloat = new GraphQLScalarType({
name: 'GraphQLStringOrFloat',
description: 'A Float or a String',
serialize(value) {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error('Value must be either a String or a Float');
}
return value;
},
parseValue(value) {
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error('Value must be either a String or a Float');
}
return value;
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.INT:
case Kind.FLOAT:
return Number(ast.value);
case Kind.STRING:
return ast.value;
default:
throw new Error('Value must be either a String or a Float');
}
},
});

View File

@@ -0,0 +1,19 @@
import { GraphQLScalarType } from 'graphql';
export const GraphQLVoid = new GraphQLScalarType({
name: 'Void',
description: 'Represents NULL values',
serialize() {
return null;
},
parseValue() {
return null;
},
parseLiteral() {
return null;
},
});

View File

@@ -0,0 +1,21 @@
import { GraphQLError, Token, locatedError } from 'graphql';
export function addPathToValidationError(validationError: GraphQLError): GraphQLError {
const token = validationError.nodes?.[0]?.loc?.startToken;
if (!token) return validationError;
let prev: Token | null = token;
const queryRegex = /query_[A-Za-z0-9]{8}/;
while (prev) {
if (prev.kind === 'Name' && prev.value && queryRegex.test(prev.value)) {
return locatedError(validationError, validationError.nodes, [prev.value]);
}
prev = prev.prev;
}
return locatedError(validationError, validationError.nodes);
}

View File

@@ -1,5 +1,5 @@
import { Accountability, Query, SchemaOverview } from '@directus/shared/types';
import { toArray } from '@directus/shared/utils';
import { parseJSON, toArray } from '@directus/shared/utils';
import { queue } from 'async';
import csv from 'csv-parser';
import destroyStream from 'destroy';
@@ -22,7 +22,6 @@ import {
import logger from '../logger';
import { AbstractServiceOptions, File } from '../types';
import { getDateFormatted } from '../utils/get-date-formatted';
import { parseJSON } from '../utils/parse-json';
import { FilesService } from './files';
import { ItemsService } from './items';
import { NotificationsService } from './notifications';

View File

@@ -1,20 +1,20 @@
import { Accountability, Action, PermissionsAction, Query, SchemaOverview } from '@directus/shared/types';
import Keyv from 'keyv';
import { Knex } from 'knex';
import { assign, clone, cloneDeep, pick, without } from 'lodash';
import { assign, clone, cloneDeep, omit, pick, without } from 'lodash';
import { getCache } from '../cache';
import getDatabase from '../database';
import runAST from '../database/run-ast';
import emitter from '../emitter';
import env from '../env';
import { ForbiddenException } from '../exceptions';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { translateDatabaseError } from '../exceptions/database/translate';
import { AbstractService, AbstractServiceOptions, Item as AnyItem, MutationOptions, PrimaryKey } from '../types';
import getASTFromQuery from '../utils/get-ast-from-query';
import { validateKeys } from '../utils/validate-keys';
import { AuthorizationService } from './authorization';
import { ActivityService, RevisionsService } from './index';
import { PayloadService } from './payload';
import { validateKeys } from '../utils/validate-keys';
export type QueryOptions = {
stripNonRequested?: boolean;
@@ -371,7 +371,31 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
}
/**
* Update many items by primary key
* Update multiple items in a single transaction
*/
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema.collections[this.collection].primary;
const keys: PrimaryKey[] = [];
await this.knex.transaction(async (trx) => {
const service = new ItemsService(this.collection, {
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
for (const item of data) {
if (!item[primaryKeyField]) throw new InvalidPayloadException(`Item in update misses primary key.`);
keys.push(await service.updateOne(item[primaryKeyField]!, omit(item, primaryKeyField), opts));
}
});
return keys;
}
/**
* Update many items by primary key, setting all items to the same change
*/
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema.collections[this.collection].primary;

View File

@@ -35,6 +35,15 @@ export class OperationsService extends ItemsService<OperationRaw> {
return result;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();
const result = await super.updateBatch(data, opts);
await flowManager.reload();
return result;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const flowManager = getFlowManager();

View File

@@ -1,5 +1,5 @@
import { Accountability, Query, SchemaOverview } from '@directus/shared/types';
import { toArray } from '@directus/shared/utils';
import { parseJSON, toArray } from '@directus/shared/utils';
import { format } from 'date-fns';
import { unflatten } from 'flat';
import Joi from 'joi';
@@ -12,7 +12,6 @@ import { getHelpers, Helpers } from '../database/helpers';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { AbstractServiceOptions, Alterations, Item, PrimaryKey } from '../types';
import { generateHash } from '../utils/generate-hash';
import { parseJSON } from '../utils/parse-json';
import { ItemsService } from './items';
type Action = 'create' | 'read' | 'update';

View File

@@ -102,6 +102,12 @@ export class PermissionsService extends ItemsService {
return res;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions) {
const res = await super.updateBatch(data, opts);
await clearSystemCache();
return res;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions) {
const res = await super.updateMany(keys, data, opts);
await clearSystemCache();

View File

@@ -79,6 +79,19 @@ export class RolesService extends ItemsService {
return super.updateOne(key, data, opts);
}
async updateBatch(data: Record<string, any>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema.collections[this.collection].primary;
const keys = data.map((item) => item[primaryKeyField]);
const setsToNoAdmin = data.some((item) => item.admin_access === false);
if (setsToNoAdmin) {
await this.checkForOtherAdminRoles(keys);
}
return super.updateBatch(data, opts);
}
async updateMany(keys: PrimaryKey[], data: Record<string, any>, opts?: MutationOptions): Promise<PrimaryKey[]> {
if ('admin_access' in data && data.admin_access === false) {
await this.checkForOtherAdminRoles(keys);

View File

@@ -182,6 +182,27 @@ export class UsersService extends ItemsService {
return key;
}
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const primaryKeyField = this.schema.collections[this.collection].primary;
const keys: PrimaryKey[] = [];
await this.knex.transaction(async (trx) => {
const service = new UsersService({
accountability: this.accountability,
knex: trx,
schema: this.schema,
});
for (const item of data) {
if (!item[primaryKeyField]) throw new InvalidPayloadException(`User in update misses primary key.`);
keys.push(await service.updateOne(item[primaryKeyField]!, item, opts));
}
});
return keys;
}
/**
* Update many users by primary key
*/

View File

@@ -1,9 +1,9 @@
import { SchemaOverview } from '@directus/schema/dist/types/overview';
import { parseJSON } from '@directus/shared/utils';
import { Column } from 'knex-schema-inspector/dist/types/column';
import env from '../env';
import logger from '../logger';
import getLocalType from './get-local-type';
import { parseJSON } from './parse-json';
export default function getDefaultValue(
column: SchemaOverview[string]['columns'][string] | Column

View File

@@ -8,7 +8,8 @@ import {
GraphQLType,
} from 'graphql';
import { GraphQLJSON } from 'graphql-compose';
import { GraphQLDate, GraphQLGeoJSON } from '../services/graphql';
import { GraphQLDate } from '../services/graphql/types/date';
import { GraphQLGeoJSON } from '../services/graphql/types/geojson';
import { Type } from '@directus/shared/types';
export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList<GraphQLType> {

View File

@@ -1,5 +1,5 @@
import { Accountability, Permission, SchemaOverview } from '@directus/shared/types';
import { deepMap, parseFilter, parsePreset } from '@directus/shared/utils';
import { deepMap, parseFilter, parseJSON, parsePreset } from '@directus/shared/utils';
import { cloneDeep } from 'lodash';
import hash from 'object-hash';
import { getCache, setSystemCache } from '../cache';
@@ -10,7 +10,6 @@ import { RolesService } from '../services/roles';
import { UsersService } from '../services/users';
import { mergePermissions } from '../utils/merge-permissions';
import { mergePermissionsForShare } from './merge-permissions-for-share';
import { parseJSON } from './parse-json';
export async function getPermissions(accountability: Accountability, schema: SchemaOverview) {
const database = getDatabase();

View File

@@ -1,6 +1,6 @@
import SchemaInspector from '@directus/schema';
import { Accountability, Filter, SchemaOverview } from '@directus/shared/types';
import { toArray } from '@directus/shared/utils';
import { parseJSON, toArray } from '@directus/shared/utils';
import { Knex } from 'knex';
import { mapValues } from 'lodash';
import { getCache, setSystemCache } from '../cache';
@@ -13,7 +13,6 @@ import logger from '../logger';
import { RelationsService } from '../services';
import getDefaultValue from './get-default-value';
import getLocalType from './get-local-type';
import { parseJSON } from './parse-json';
export async function getSchema(options?: {
accountability?: Accountability;

View File

@@ -1,51 +0,0 @@
import { renderFn, get, Scope } from 'micromustache';
import { parseJSON } from './parse-json';
type Mustacheable = string | number | boolean | null | Mustacheable[] | { [key: string]: Mustacheable };
type GenericString<T> = T extends string ? string : T;
function resolveFn(path: string, scope?: Scope): unknown {
if (!scope) return undefined;
const value = get(scope, path);
return typeof value === 'object' ? JSON.stringify(value) : value;
}
function renderMustache<T extends Mustacheable>(item: T, scope: Scope): GenericString<T> {
if (typeof item === 'string') {
return renderFn(item, resolveFn, scope, { explicit: true }) as GenericString<T>;
} else if (Array.isArray(item)) {
return item.map((element) => renderMustache(element, scope)) as GenericString<T>;
} else if (typeof item === 'object' && item !== null) {
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, renderMustache(value, scope)])
) as GenericString<T>;
} else {
return item as GenericString<T>;
}
}
export function applyOperationOptions(options: Record<string, any>, data: Record<string, any>): Record<string, any> {
return Object.fromEntries(
Object.entries(options).map(([key, value]) => {
if (typeof value === 'string') {
const single = value.match(/^\{\{\s*([^}\s]+)\s*\}\}$/);
if (single !== null) {
return [key, get(data, single[1])];
}
}
return [key, renderMustache(value, data)];
})
);
}
export function optionToObject<T>(option: T): Exclude<T, string> {
return typeof option === 'string' ? parseJSON(option) : option;
}
export function optionToString(option: unknown): string {
return typeof option === 'object' ? JSON.stringify(option) : String(option);
}

View File

@@ -1,16 +0,0 @@
/**
* Run JSON.parse, but ignore `__proto__` properties. This prevents prototype pollution attacks
*/
export function parseJSON(input: string): any {
if (String(input).includes('__proto__')) {
return JSON.parse(input, noproto);
}
return JSON.parse(input);
}
export function noproto<T>(key: string, value: T): T | void {
if (key !== '__proto__') {
return value;
}
}

View File

@@ -1,9 +1,8 @@
import { Accountability, Aggregate, Filter, Query } from '@directus/shared/types';
import { parseFilter } from '@directus/shared/utils';
import { parseFilter, parseJSON } from '@directus/shared/utils';
import { flatten, get, isPlainObject, merge, set } from 'lodash';
import logger from '../logger';
import { Meta } from '../types';
import { parseJSON } from './parse-json';
export function sanitizeQuery(rawQuery: Record<string, any>, accountability?: Accountability | null): Query {
const query: Query = {};