@directus/api 10.2.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +4 -3
- package/dist/cli/utils/create-env/env-stub.liquid +3 -0
- package/dist/constants.d.ts +0 -1
- package/dist/constants.js +0 -1
- package/dist/controllers/files.js +19 -1
- package/dist/controllers/permissions.js +7 -4
- package/dist/controllers/translations.d.ts +2 -0
- package/dist/controllers/translations.js +149 -0
- package/dist/controllers/users.js +1 -1
- package/dist/database/migrations/20230525A-add-preview-settings.d.ts +3 -0
- package/dist/database/migrations/20230525A-add-preview-settings.js +10 -0
- package/dist/database/migrations/20230526A-migrate-translation-strings.d.ts +3 -0
- package/dist/database/migrations/20230526A-migrate-translation-strings.js +54 -0
- package/dist/database/system-data/app-access-permissions/app-access-permissions.yaml +3 -0
- package/dist/database/system-data/collections/collections.yaml +23 -0
- package/dist/database/system-data/fields/collections.yaml +16 -0
- package/dist/database/system-data/fields/settings.yaml +0 -5
- package/dist/database/system-data/fields/translations.yaml +27 -0
- package/dist/env.js +14 -0
- package/dist/exceptions/content-too-large.d.ts +4 -0
- package/dist/exceptions/content-too-large.js +6 -0
- package/dist/extensions.js +13 -11
- package/dist/flows.d.ts +1 -1
- package/dist/flows.js +20 -19
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +6 -6
- package/dist/server.js +0 -11
- package/dist/services/collections.js +8 -7
- package/dist/services/fields.js +4 -4
- package/dist/services/files.d.ts +2 -2
- package/dist/services/files.js +4 -9
- package/dist/services/graphql/index.js +0 -46
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/items.js +10 -9
- package/dist/services/revisions.d.ts +6 -1
- package/dist/services/revisions.js +24 -0
- package/dist/services/server.js +0 -18
- package/dist/services/specifications.d.ts +2 -2
- package/dist/services/specifications.js +6 -5
- package/dist/services/translations.d.ts +10 -0
- package/dist/services/translations.js +36 -0
- package/dist/synchronization.d.ts +7 -0
- package/dist/synchronization.js +120 -0
- package/dist/types/events.d.ts +2 -2
- package/dist/utils/apply-query.d.ts +9 -2
- package/dist/utils/apply-query.js +41 -14
- package/dist/utils/md.js +1 -1
- package/dist/utils/redact.d.ts +11 -0
- package/dist/utils/redact.js +75 -0
- package/dist/utils/schedule.d.ts +5 -0
- package/dist/utils/schedule.js +27 -0
- package/dist/utils/should-clear-cache.d.ts +10 -0
- package/dist/utils/should-clear-cache.js +18 -0
- package/dist/utils/should-skip-cache.js +18 -2
- package/package.json +48 -52
- package/dist/utils/get-os-info.d.ts +0 -9
- package/dist/utils/get-os-info.js +0 -40
|
@@ -17,4 +17,28 @@ export class RevisionsService extends ItemsService {
|
|
|
17
17
|
});
|
|
18
18
|
await service.updateOne(revision['item'], revision['data']);
|
|
19
19
|
}
|
|
20
|
+
setDefaultOptions(opts) {
|
|
21
|
+
if (!opts) {
|
|
22
|
+
return { autoPurgeCache: false, bypassLimits: true };
|
|
23
|
+
}
|
|
24
|
+
if (!('autoPurgeCache' in opts)) {
|
|
25
|
+
opts.autoPurgeCache = false;
|
|
26
|
+
}
|
|
27
|
+
if (!('bypassLimits' in opts)) {
|
|
28
|
+
opts.bypassLimits = true;
|
|
29
|
+
}
|
|
30
|
+
return opts;
|
|
31
|
+
}
|
|
32
|
+
async createOne(data, opts) {
|
|
33
|
+
return super.createOne(data, this.setDefaultOptions(opts));
|
|
34
|
+
}
|
|
35
|
+
async createMany(data, opts) {
|
|
36
|
+
return super.createMany(data, this.setDefaultOptions(opts));
|
|
37
|
+
}
|
|
38
|
+
async updateOne(key, data, opts) {
|
|
39
|
+
return super.updateOne(key, data, this.setDefaultOptions(opts));
|
|
40
|
+
}
|
|
41
|
+
async updateMany(keys, data, opts) {
|
|
42
|
+
return super.updateMany(keys, data, this.setDefaultOptions(opts));
|
|
43
|
+
}
|
|
20
44
|
}
|
package/dist/services/server.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { toArray } from '@directus/utils';
|
|
2
2
|
import { merge } from 'lodash-es';
|
|
3
3
|
import { Readable } from 'node:stream';
|
|
4
|
-
import os from 'os';
|
|
5
4
|
import { performance } from 'perf_hooks';
|
|
6
5
|
import { getCache } from '../cache.js';
|
|
7
6
|
import getDatabase, { hasDatabaseConnection } from '../database/index.js';
|
|
@@ -12,7 +11,6 @@ import { rateLimiterGlobal } from '../middleware/rate-limiter-global.js';
|
|
|
12
11
|
import { rateLimiter } from '../middleware/rate-limiter-ip.js';
|
|
13
12
|
import { SERVER_ONLINE } from '../server.js';
|
|
14
13
|
import { getStorage } from '../storage/index.js';
|
|
15
|
-
import { getOSInfo } from '../utils/get-os-info.js';
|
|
16
14
|
import { version } from '../utils/package.js';
|
|
17
15
|
import { SettingsService } from './settings.js';
|
|
18
16
|
export class ServerService {
|
|
@@ -69,22 +67,6 @@ export class ServerService {
|
|
|
69
67
|
max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
|
-
if (this.accountability?.admin === true) {
|
|
73
|
-
const { osType, osVersion } = getOSInfo();
|
|
74
|
-
info['directus'] = {
|
|
75
|
-
version,
|
|
76
|
-
};
|
|
77
|
-
info['node'] = {
|
|
78
|
-
version: process.versions.node,
|
|
79
|
-
uptime: Math.round(process.uptime()),
|
|
80
|
-
};
|
|
81
|
-
info['os'] = {
|
|
82
|
-
type: osType,
|
|
83
|
-
version: osVersion,
|
|
84
|
-
uptime: Math.round(os.uptime()),
|
|
85
|
-
totalmem: os.totalmem(),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
70
|
return info;
|
|
89
71
|
}
|
|
90
72
|
async health() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Knex } from 'knex';
|
|
2
|
-
import type { OpenAPIObject } from 'openapi3-ts';
|
|
3
1
|
import type { Accountability, SchemaOverview } from '@directus/types';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
import type { OpenAPIObject } from 'openapi3-ts/oas30';
|
|
4
4
|
import type { AbstractServiceOptions } from '../types/index.js';
|
|
5
5
|
import { CollectionsService } from './collections.js';
|
|
6
6
|
import { FieldsService } from './fields.js';
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
import formatTitle from '@directus/format-title';
|
|
1
2
|
import { spec } from '@directus/specs';
|
|
2
3
|
import { cloneDeep, mergeWith } from 'lodash-es';
|
|
3
|
-
import { version } from '../utils/package.js';
|
|
4
4
|
import { OAS_REQUIRED_SCHEMAS } from '../constants.js';
|
|
5
5
|
import getDatabase from '../database/index.js';
|
|
6
6
|
import env from '../env.js';
|
|
7
7
|
import { getRelationType } from '../utils/get-relation-type.js';
|
|
8
|
+
import { version } from '../utils/package.js';
|
|
8
9
|
import { CollectionsService } from './collections.js';
|
|
9
10
|
import { FieldsService } from './fields.js';
|
|
10
11
|
import { GraphQLService } from './graphql/index.js';
|
|
11
12
|
import { RelationsService } from './relations.js';
|
|
12
|
-
import formatTitle from '@directus/format-title';
|
|
13
13
|
export class SpecificationService {
|
|
14
14
|
accountability;
|
|
15
15
|
knex;
|
|
@@ -148,7 +148,8 @@ class OASSpecsService {
|
|
|
148
148
|
const listBase = cloneDeep(spec.paths['/items/{collection}']);
|
|
149
149
|
const detailBase = cloneDeep(spec.paths['/items/{collection}/{id}']);
|
|
150
150
|
const collection = tag['x-collection'];
|
|
151
|
-
|
|
151
|
+
const methods = ['post', 'get', 'patch', 'delete'];
|
|
152
|
+
for (const method of methods) {
|
|
152
153
|
const hasPermission = this.accountability?.admin === true ||
|
|
153
154
|
!!permissions.find((permission) => permission.collection === collection && permission.action === this.getActionForMethod(method));
|
|
154
155
|
if (hasPermission) {
|
|
@@ -156,7 +157,7 @@ class OASSpecsService {
|
|
|
156
157
|
paths[`/items/${collection}`] = {};
|
|
157
158
|
if (!paths[`/items/${collection}/{id}`])
|
|
158
159
|
paths[`/items/${collection}/{id}`] = {};
|
|
159
|
-
if (listBase[method]) {
|
|
160
|
+
if (listBase?.[method]) {
|
|
160
161
|
paths[`/items/${collection}`][method] = mergeWith(cloneDeep(listBase[method]), {
|
|
161
162
|
description: listBase[method].description.replace('item', collection + ' item'),
|
|
162
163
|
tags: [tag.name],
|
|
@@ -208,7 +209,7 @@ class OASSpecsService {
|
|
|
208
209
|
return undefined;
|
|
209
210
|
});
|
|
210
211
|
}
|
|
211
|
-
if (detailBase[method]) {
|
|
212
|
+
if (detailBase?.[method]) {
|
|
212
213
|
paths[`/items/${collection}/{id}`][method] = mergeWith(cloneDeep(detailBase[method]), {
|
|
213
214
|
description: detailBase[method].description.replace('item', collection + ' item'),
|
|
214
215
|
tags: [tag.name],
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Item, PrimaryKey } from '@directus/types';
|
|
2
|
+
import { ItemsService } from './items.js';
|
|
3
|
+
import type { AbstractServiceOptions } from '../types/services.js';
|
|
4
|
+
import type { MutationOptions } from '../types/items.js';
|
|
5
|
+
export declare class TranslationsService extends ItemsService {
|
|
6
|
+
constructor(options: AbstractServiceOptions);
|
|
7
|
+
private translationKeyExists;
|
|
8
|
+
createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
|
|
9
|
+
updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import getDatabase from '../database/index.js';
|
|
2
|
+
import { ItemsService } from './items.js';
|
|
3
|
+
import { InvalidPayloadException } from '../index.js';
|
|
4
|
+
export class TranslationsService extends ItemsService {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
super('directus_translations', options);
|
|
7
|
+
this.knex = options.knex || getDatabase();
|
|
8
|
+
this.accountability = options.accountability || null;
|
|
9
|
+
this.schema = options.schema;
|
|
10
|
+
}
|
|
11
|
+
async translationKeyExists(key, language) {
|
|
12
|
+
const result = await this.knex.select('id').from(this.collection).where({ key, language });
|
|
13
|
+
return result.length > 0;
|
|
14
|
+
}
|
|
15
|
+
async createOne(data, opts) {
|
|
16
|
+
if (await this.translationKeyExists(data['key'], data['language'])) {
|
|
17
|
+
throw new InvalidPayloadException('Duplicate key and language combination.');
|
|
18
|
+
}
|
|
19
|
+
return await super.createOne(data, opts);
|
|
20
|
+
}
|
|
21
|
+
async updateMany(keys, data, opts) {
|
|
22
|
+
if (keys.length > 0 && 'key' in data && 'language' in data) {
|
|
23
|
+
throw new InvalidPayloadException('Duplicate key and language combination.');
|
|
24
|
+
}
|
|
25
|
+
else if ('key' in data || 'language' in data) {
|
|
26
|
+
const items = await this.readMany(keys);
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const updatedData = { ...item, ...data };
|
|
29
|
+
if (await this.translationKeyExists(updatedData['key'], updatedData['language'])) {
|
|
30
|
+
throw new InvalidPayloadException('Duplicate key and language combination.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return await super.updateMany(keys, data, opts);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Redis } from 'ioredis';
|
|
2
|
+
import env from './env.js';
|
|
3
|
+
import { getConfigFromEnv } from './utils/get-config-from-env.js';
|
|
4
|
+
let synchronizationManager;
|
|
5
|
+
function getSynchronizationManager() {
|
|
6
|
+
if (synchronizationManager)
|
|
7
|
+
return synchronizationManager;
|
|
8
|
+
if (env['SYNCHRONIZATION_STORE'] === 'redis') {
|
|
9
|
+
synchronizationManager = new SynchronizationManagerRedis();
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
synchronizationManager = new SynchronizationManagerMemory();
|
|
13
|
+
}
|
|
14
|
+
return synchronizationManager;
|
|
15
|
+
}
|
|
16
|
+
class SynchronizationManagerMemory {
|
|
17
|
+
store;
|
|
18
|
+
constructor() {
|
|
19
|
+
this.store = {};
|
|
20
|
+
}
|
|
21
|
+
async set(key, value) {
|
|
22
|
+
this.setSync(key, value);
|
|
23
|
+
}
|
|
24
|
+
async get(key) {
|
|
25
|
+
return this.getSync(key);
|
|
26
|
+
}
|
|
27
|
+
async delete(key) {
|
|
28
|
+
this.deleteSync(key);
|
|
29
|
+
}
|
|
30
|
+
async exists(key) {
|
|
31
|
+
return this.existsSync(key);
|
|
32
|
+
}
|
|
33
|
+
async setGreaterThan(key, value) {
|
|
34
|
+
if (this.existsSync(key)) {
|
|
35
|
+
const oldValue = Number(this.getSync(key));
|
|
36
|
+
if (value <= oldValue) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
this.setSync(key, value);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
setSync(key, value) {
|
|
44
|
+
this.store[key] = String(value);
|
|
45
|
+
}
|
|
46
|
+
getSync(key) {
|
|
47
|
+
return this.store[key] ?? null;
|
|
48
|
+
}
|
|
49
|
+
deleteSync(key) {
|
|
50
|
+
delete this.store[key];
|
|
51
|
+
}
|
|
52
|
+
existsSync(key) {
|
|
53
|
+
return key in this.store;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const SET_GREATER_THAN_SCRIPT = `
|
|
57
|
+
local key = KEYS[1]
|
|
58
|
+
local value = tonumber(ARGV[1])
|
|
59
|
+
|
|
60
|
+
if redis.call("EXISTS", key) == 1 then
|
|
61
|
+
local oldValue = tonumber(redis.call('GET', key))
|
|
62
|
+
|
|
63
|
+
if value <= oldValue then
|
|
64
|
+
return false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
redis.call('SET', key, value)
|
|
69
|
+
|
|
70
|
+
return true
|
|
71
|
+
`;
|
|
72
|
+
class SynchronizationManagerRedis {
|
|
73
|
+
namespace;
|
|
74
|
+
client;
|
|
75
|
+
constructor() {
|
|
76
|
+
const config = getConfigFromEnv('SYNCHRONIZATION_REDIS');
|
|
77
|
+
this.client = new Redis(env['SYNCHRONIZATION_REDIS'] ?? config);
|
|
78
|
+
this.namespace = env['SYNCHRONIZATION_NAMESPACE'] ?? 'directus';
|
|
79
|
+
this.client.defineCommand('setGreaterThan', {
|
|
80
|
+
numberOfKeys: 1,
|
|
81
|
+
lua: SET_GREATER_THAN_SCRIPT,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async set(key, value) {
|
|
85
|
+
await this.client.set(this.getNamespacedKey(key), value);
|
|
86
|
+
}
|
|
87
|
+
get(key) {
|
|
88
|
+
return this.client.get(this.getNamespacedKey(key));
|
|
89
|
+
}
|
|
90
|
+
async delete(key) {
|
|
91
|
+
await this.client.del(this.getNamespacedKey(key));
|
|
92
|
+
}
|
|
93
|
+
async exists(key) {
|
|
94
|
+
const doesExist = await this.client.exists(this.getNamespacedKey(key));
|
|
95
|
+
return doesExist === 1;
|
|
96
|
+
}
|
|
97
|
+
async setGreaterThan(key, value) {
|
|
98
|
+
const client = this.client;
|
|
99
|
+
const wasSet = await client.setGreaterThan(this.getNamespacedKey(key), value);
|
|
100
|
+
return wasSet === 1;
|
|
101
|
+
}
|
|
102
|
+
getNamespacedKey(key) {
|
|
103
|
+
return `${this.namespace}:${key}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export class SynchronizedClock {
|
|
107
|
+
key;
|
|
108
|
+
synchronizationManager;
|
|
109
|
+
constructor(id) {
|
|
110
|
+
this.key = `clock:${id}`;
|
|
111
|
+
this.synchronizationManager = getSynchronizationManager();
|
|
112
|
+
}
|
|
113
|
+
async set(timestamp) {
|
|
114
|
+
const wasSet = await this.synchronizationManager.setGreaterThan(this.key, timestamp);
|
|
115
|
+
return wasSet;
|
|
116
|
+
}
|
|
117
|
+
async reset() {
|
|
118
|
+
await this.synchronizationManager.delete(this.key);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/types/events.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ActionHandler, FilterHandler, InitHandler } from '@directus/types';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ScheduledJob } from '../utils/schedule.js';
|
|
3
3
|
export type EventHandler = {
|
|
4
4
|
type: 'filter';
|
|
5
5
|
name: string;
|
|
@@ -14,5 +14,5 @@ export type EventHandler = {
|
|
|
14
14
|
handler: InitHandler;
|
|
15
15
|
} | {
|
|
16
16
|
type: 'schedule';
|
|
17
|
-
|
|
17
|
+
job: ScheduledJob;
|
|
18
18
|
};
|
|
@@ -11,6 +11,7 @@ export default function applyQuery(knex: Knex, collection: string, dbQuery: Knex
|
|
|
11
11
|
hasMultiRelationalSort?: boolean | undefined;
|
|
12
12
|
}): {
|
|
13
13
|
query: Knex.QueryBuilder<any, any>;
|
|
14
|
+
hasJoins: boolean;
|
|
14
15
|
hasMultiRelationalFilter: boolean;
|
|
15
16
|
};
|
|
16
17
|
export type ColumnSortRecord = {
|
|
@@ -22,13 +23,19 @@ export declare function applySort(knex: Knex, schema: SchemaOverview, rootQuery:
|
|
|
22
23
|
order: "asc" | "desc";
|
|
23
24
|
column: any;
|
|
24
25
|
}[];
|
|
26
|
+
hasJoins: boolean;
|
|
25
27
|
hasMultiRelationalSort: boolean;
|
|
26
|
-
} |
|
|
28
|
+
} | {
|
|
29
|
+
hasJoins: boolean;
|
|
30
|
+
hasMultiRelationalSort: boolean;
|
|
31
|
+
sortRecords?: never;
|
|
32
|
+
};
|
|
27
33
|
export declare function applyLimit(knex: Knex, rootQuery: Knex.QueryBuilder, limit: any): void;
|
|
28
34
|
export declare function applyOffset(knex: Knex, rootQuery: Knex.QueryBuilder, offset: any): void;
|
|
29
35
|
export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuery: Knex.QueryBuilder, rootFilter: Filter, collection: string, aliasMap: AliasMap): {
|
|
30
36
|
query: Knex.QueryBuilder<any, any>;
|
|
37
|
+
hasJoins: boolean;
|
|
31
38
|
hasMultiRelationalFilter: boolean;
|
|
32
39
|
};
|
|
33
40
|
export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
|
|
34
|
-
export declare function applyAggregate(dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
|
|
41
|
+
export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string, hasJoins: boolean): void;
|
|
@@ -15,6 +15,7 @@ export const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
|
|
|
15
15
|
*/
|
|
16
16
|
export default function applyQuery(knex, collection, dbQuery, query, schema, options) {
|
|
17
17
|
const aliasMap = options?.aliasMap ?? Object.create(null);
|
|
18
|
+
let hasJoins = false;
|
|
18
19
|
let hasMultiRelationalFilter = false;
|
|
19
20
|
applyLimit(knex, dbQuery, query.limit);
|
|
20
21
|
if (query.offset) {
|
|
@@ -24,7 +25,10 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
|
|
|
24
25
|
applyOffset(knex, dbQuery, query.limit * (query.page - 1));
|
|
25
26
|
}
|
|
26
27
|
if (query.sort && !options?.isInnerQuery && !options?.hasMultiRelationalSort) {
|
|
27
|
-
applySort(knex, schema, dbQuery, query.sort, collection, aliasMap);
|
|
28
|
+
const sortResult = applySort(knex, schema, dbQuery, query.sort, collection, aliasMap);
|
|
29
|
+
if (!hasJoins) {
|
|
30
|
+
hasJoins = sortResult.hasJoins;
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
if (query.search) {
|
|
30
34
|
applySearch(schema, dbQuery, query.search, collection);
|
|
@@ -32,19 +36,24 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
|
|
|
32
36
|
if (query.group) {
|
|
33
37
|
dbQuery.groupBy(query.group.map((column) => getColumn(knex, collection, column, false, schema)));
|
|
34
38
|
}
|
|
35
|
-
if (query.aggregate) {
|
|
36
|
-
applyAggregate(dbQuery, query.aggregate, collection);
|
|
37
|
-
}
|
|
38
39
|
if (query.filter) {
|
|
39
|
-
|
|
40
|
+
const filterResult = applyFilter(knex, schema, dbQuery, query.filter, collection, aliasMap);
|
|
41
|
+
if (!hasJoins) {
|
|
42
|
+
hasJoins = filterResult.hasJoins;
|
|
43
|
+
}
|
|
44
|
+
hasMultiRelationalFilter = filterResult.hasMultiRelationalFilter;
|
|
45
|
+
}
|
|
46
|
+
if (query.aggregate) {
|
|
47
|
+
applyAggregate(schema, dbQuery, query.aggregate, collection, hasJoins);
|
|
40
48
|
}
|
|
41
|
-
return { query: dbQuery, hasMultiRelationalFilter };
|
|
49
|
+
return { query: dbQuery, hasJoins, hasMultiRelationalFilter };
|
|
42
50
|
}
|
|
43
51
|
function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, knex }) {
|
|
44
52
|
let hasMultiRelational = false;
|
|
53
|
+
let isJoinAdded = false;
|
|
45
54
|
path = clone(path);
|
|
46
55
|
followRelation(path);
|
|
47
|
-
return hasMultiRelational;
|
|
56
|
+
return { hasMultiRelational, isJoinAdded };
|
|
48
57
|
function followRelation(pathParts, parentCollection = collection, parentFields) {
|
|
49
58
|
/**
|
|
50
59
|
* For A2M fields, the path can contain an optional collection scope <field>:<scope>
|
|
@@ -65,6 +74,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
65
74
|
if (relationType === 'm2o') {
|
|
66
75
|
rootQuery.leftJoin({ [alias]: relation.related_collection }, `${aliasedParentCollection}.${relation.field}`, `${alias}.${schema.collections[relation.related_collection].primary}`);
|
|
67
76
|
aliasMap[aliasKey].collection = relation.related_collection;
|
|
77
|
+
isJoinAdded = true;
|
|
68
78
|
}
|
|
69
79
|
else if (relationType === 'a2o') {
|
|
70
80
|
const pathScope = pathParts[0].split(':')[1];
|
|
@@ -77,6 +87,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
77
87
|
.andOn(`${aliasedParentCollection}.${relation.field}`, '=', knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), `${alias}.${schema.collections[pathScope].primary}`));
|
|
78
88
|
});
|
|
79
89
|
aliasMap[aliasKey].collection = pathScope;
|
|
90
|
+
isJoinAdded = true;
|
|
80
91
|
}
|
|
81
92
|
else if (relationType === 'o2a') {
|
|
82
93
|
rootQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => {
|
|
@@ -86,11 +97,13 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
86
97
|
});
|
|
87
98
|
aliasMap[aliasKey].collection = relation.collection;
|
|
88
99
|
hasMultiRelational = true;
|
|
100
|
+
isJoinAdded = true;
|
|
89
101
|
}
|
|
90
102
|
else if (relationType === 'o2m') {
|
|
91
103
|
rootQuery.leftJoin({ [alias]: relation.collection }, `${aliasedParentCollection}.${schema.collections[relation.related_collection].primary}`, `${alias}.${relation.field}`);
|
|
92
104
|
aliasMap[aliasKey].collection = relation.collection;
|
|
93
105
|
hasMultiRelational = true;
|
|
106
|
+
isJoinAdded = true;
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
let parent;
|
|
@@ -114,6 +127,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
114
127
|
}
|
|
115
128
|
export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMap, returnRecords = false) {
|
|
116
129
|
const relations = schema.relations;
|
|
130
|
+
let hasJoins = false;
|
|
117
131
|
let hasMultiRelationalSort = false;
|
|
118
132
|
const sortRecords = rootSort.map((sortField) => {
|
|
119
133
|
const column = sortField.split('.');
|
|
@@ -134,7 +148,7 @@ export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMa
|
|
|
134
148
|
};
|
|
135
149
|
}
|
|
136
150
|
}
|
|
137
|
-
const hasMultiRelational = addJoin({
|
|
151
|
+
const { hasMultiRelational, isJoinAdded } = addJoin({
|
|
138
152
|
path: column,
|
|
139
153
|
collection,
|
|
140
154
|
aliasMap,
|
|
@@ -151,6 +165,9 @@ export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMa
|
|
|
151
165
|
schema,
|
|
152
166
|
});
|
|
153
167
|
const [alias, field] = columnPath.split('.');
|
|
168
|
+
if (!hasJoins) {
|
|
169
|
+
hasJoins = isJoinAdded;
|
|
170
|
+
}
|
|
154
171
|
if (!hasMultiRelationalSort) {
|
|
155
172
|
hasMultiRelationalSort = hasMultiRelational;
|
|
156
173
|
}
|
|
@@ -160,11 +177,11 @@ export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMa
|
|
|
160
177
|
};
|
|
161
178
|
});
|
|
162
179
|
if (returnRecords)
|
|
163
|
-
return { sortRecords, hasMultiRelationalSort };
|
|
180
|
+
return { sortRecords, hasJoins, hasMultiRelationalSort };
|
|
164
181
|
// Clears the order if any, eg: from MSSQL offset
|
|
165
182
|
rootQuery.clear('order');
|
|
166
183
|
rootQuery.orderBy(sortRecords);
|
|
167
|
-
return
|
|
184
|
+
return { hasJoins, hasMultiRelationalSort };
|
|
168
185
|
}
|
|
169
186
|
export function applyLimit(knex, rootQuery, limit) {
|
|
170
187
|
if (typeof limit === 'number') {
|
|
@@ -179,10 +196,11 @@ export function applyOffset(knex, rootQuery, offset) {
|
|
|
179
196
|
export function applyFilter(knex, schema, rootQuery, rootFilter, collection, aliasMap) {
|
|
180
197
|
const helpers = getHelpers(knex);
|
|
181
198
|
const relations = schema.relations;
|
|
199
|
+
let hasJoins = false;
|
|
182
200
|
let hasMultiRelationalFilter = false;
|
|
183
201
|
addJoins(rootQuery, rootFilter, collection);
|
|
184
202
|
addWhereClauses(knex, rootQuery, rootFilter, collection);
|
|
185
|
-
return { query: rootQuery, hasMultiRelationalFilter };
|
|
203
|
+
return { query: rootQuery, hasJoins, hasMultiRelationalFilter };
|
|
186
204
|
function addJoins(dbQuery, filter, collection) {
|
|
187
205
|
for (const [key, value] of Object.entries(filter)) {
|
|
188
206
|
if (key === '_or' || key === '_and') {
|
|
@@ -199,7 +217,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
199
217
|
const filterPath = getFilterPath(key, value);
|
|
200
218
|
if (filterPath.length > 1 ||
|
|
201
219
|
(!(key.includes('(') && key.includes(')')) && schema.collections[collection]?.fields[key]?.type === 'alias')) {
|
|
202
|
-
const hasMultiRelational = addJoin({
|
|
220
|
+
const { hasMultiRelational, isJoinAdded } = addJoin({
|
|
203
221
|
path: filterPath,
|
|
204
222
|
collection,
|
|
205
223
|
knex,
|
|
@@ -208,6 +226,9 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
208
226
|
rootQuery,
|
|
209
227
|
aliasMap,
|
|
210
228
|
});
|
|
229
|
+
if (!hasJoins) {
|
|
230
|
+
hasJoins = isJoinAdded;
|
|
231
|
+
}
|
|
211
232
|
if (!hasMultiRelationalFilter) {
|
|
212
233
|
hasMultiRelationalFilter = hasMultiRelational;
|
|
213
234
|
}
|
|
@@ -503,7 +524,7 @@ function validateNumber(value, parsed) {
|
|
|
503
524
|
// (prevent unintended number parsing, e.g. String(7) !== "ob111")
|
|
504
525
|
return String(parsed) === value;
|
|
505
526
|
}
|
|
506
|
-
export function applyAggregate(dbQuery, aggregate, collection) {
|
|
527
|
+
export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins) {
|
|
507
528
|
for (const [operation, fields] of Object.entries(aggregate)) {
|
|
508
529
|
if (!fields)
|
|
509
530
|
continue;
|
|
@@ -526,7 +547,13 @@ export function applyAggregate(dbQuery, aggregate, collection) {
|
|
|
526
547
|
}
|
|
527
548
|
}
|
|
528
549
|
if (operation === 'countDistinct') {
|
|
529
|
-
|
|
550
|
+
if (!hasJoins && schema.collections[collection]?.primary === field) {
|
|
551
|
+
// Optimize to count as primary keys are unique
|
|
552
|
+
dbQuery.count(`${collection}.${field}`, { as: `countDistinct->${field}` });
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
dbQuery.countDistinct(`${collection}.${field}`, { as: `countDistinct->${field}` });
|
|
556
|
+
}
|
|
530
557
|
}
|
|
531
558
|
if (operation === 'sum') {
|
|
532
559
|
dbQuery.sum(`${collection}.${field}`, { as: `sum->${field}` });
|
package/dist/utils/md.js
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UnknownObject } from '@directus/types';
|
|
2
|
+
type Paths = string[][];
|
|
3
|
+
/**
|
|
4
|
+
* Redact values at certain paths in an object.
|
|
5
|
+
* @param input Input object in which values should be redacted.
|
|
6
|
+
* @param paths Nested array of object paths to be redacted (supports `*` for shallow matching, `**` for deep matching).
|
|
7
|
+
* @param replacement Replacement the values are redacted by.
|
|
8
|
+
* @returns Redacted object.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redact(input: UnknownObject, paths: Paths, replacement: string): UnknownObject;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isObject } from '@directus/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Redact values at certain paths in an object.
|
|
4
|
+
* @param input Input object in which values should be redacted.
|
|
5
|
+
* @param paths Nested array of object paths to be redacted (supports `*` for shallow matching, `**` for deep matching).
|
|
6
|
+
* @param replacement Replacement the values are redacted by.
|
|
7
|
+
* @returns Redacted object.
|
|
8
|
+
*/
|
|
9
|
+
export function redact(input, paths, replacement) {
|
|
10
|
+
const wildcardChars = ['*', '**'];
|
|
11
|
+
const clone = structuredClone(input);
|
|
12
|
+
const visited = new WeakSet();
|
|
13
|
+
traverse(clone, paths);
|
|
14
|
+
return clone;
|
|
15
|
+
function traverse(object, checkPaths) {
|
|
16
|
+
if (checkPaths.length === 0) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
visited.add(object);
|
|
20
|
+
const globalCheckPaths = [];
|
|
21
|
+
for (const key of Object.keys(object)) {
|
|
22
|
+
const localCheckPaths = [];
|
|
23
|
+
for (const [index, path] of [...checkPaths].entries()) {
|
|
24
|
+
const [current, ...remaining] = path;
|
|
25
|
+
const escapedKey = wildcardChars.includes(key) ? `\\${key}` : key;
|
|
26
|
+
switch (current) {
|
|
27
|
+
case escapedKey:
|
|
28
|
+
if (remaining.length > 0) {
|
|
29
|
+
localCheckPaths.push(remaining);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
object[key] = replacement;
|
|
33
|
+
checkPaths.splice(index, 1);
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
case '*':
|
|
37
|
+
if (remaining.length > 0) {
|
|
38
|
+
globalCheckPaths.push(remaining);
|
|
39
|
+
checkPaths.splice(index, 1);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
object[key] = replacement;
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case '**':
|
|
46
|
+
if (remaining.length > 0) {
|
|
47
|
+
const [next, ...nextRemaining] = remaining;
|
|
48
|
+
if (next === escapedKey) {
|
|
49
|
+
if (nextRemaining.length === 0) {
|
|
50
|
+
object[key] = replacement;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
localCheckPaths.push(nextRemaining);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (next !== undefined && wildcardChars.includes(next)) {
|
|
57
|
+
localCheckPaths.push(remaining);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
localCheckPaths.push(path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
object[key] = replacement;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const value = object[key];
|
|
70
|
+
if (isObject(value) && !visited.has(value)) {
|
|
71
|
+
traverse(value, [...globalCheckPaths, ...localCheckPaths]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import cron from 'cron-parser';
|
|
2
|
+
import schedule from 'node-schedule';
|
|
3
|
+
import { SynchronizedClock } from '../synchronization.js';
|
|
4
|
+
export function validateCron(rule) {
|
|
5
|
+
try {
|
|
6
|
+
cron.parseExpression(rule);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
export function scheduleSynchronizedJob(id, rule, cb) {
|
|
14
|
+
const clock = new SynchronizedClock(`${id}:${rule}`);
|
|
15
|
+
const job = schedule.scheduleJob(rule, async (fireDate) => {
|
|
16
|
+
const nextTimestamp = job.nextInvocation().getTime();
|
|
17
|
+
const wasSet = await clock.set(nextTimestamp);
|
|
18
|
+
if (wasSet) {
|
|
19
|
+
await cb(fireDate);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
const stop = async () => {
|
|
23
|
+
job.cancel();
|
|
24
|
+
await clock.reset();
|
|
25
|
+
};
|
|
26
|
+
return { stop };
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type Keyv from 'keyv';
|
|
2
|
+
import type { MutationOptions } from '../types/items.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check whether cache should be cleared
|
|
5
|
+
*
|
|
6
|
+
* @param cache Cache instance
|
|
7
|
+
* @param opts Mutation options
|
|
8
|
+
* @param collection Collection name to check if cache purging should be ignored
|
|
9
|
+
*/
|
|
10
|
+
export declare function shouldClearCache(cache: Keyv<any> | null, opts?: MutationOptions, collection?: string): cache is Keyv<any>;
|