@directus/api 33.3.1 → 34.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/ai/chat/lib/create-ui-stream.js +2 -1
- package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
- package/dist/ai/chat/lib/transform-file-parts.js +36 -0
- package/dist/ai/files/adapters/anthropic.d.ts +3 -0
- package/dist/ai/files/adapters/anthropic.js +25 -0
- package/dist/ai/files/adapters/google.d.ts +3 -0
- package/dist/ai/files/adapters/google.js +58 -0
- package/dist/ai/files/adapters/index.d.ts +3 -0
- package/dist/ai/files/adapters/index.js +3 -0
- package/dist/ai/files/adapters/openai.d.ts +3 -0
- package/dist/ai/files/adapters/openai.js +22 -0
- package/dist/ai/files/controllers/upload.d.ts +2 -0
- package/dist/ai/files/controllers/upload.js +101 -0
- package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
- package/dist/ai/files/lib/fetch-provider.js +23 -0
- package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
- package/dist/ai/files/lib/upload-to-provider.js +26 -0
- package/dist/ai/files/router.d.ts +1 -0
- package/dist/ai/files/router.js +5 -0
- package/dist/ai/files/types.d.ts +5 -0
- package/dist/ai/files/types.js +1 -0
- package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
- package/dist/ai/providers/anthropic-file-support.js +94 -0
- package/dist/ai/providers/registry.js +3 -6
- package/dist/ai/tools/flows/index.d.ts +16 -16
- package/dist/ai/tools/schema.d.ts +8 -8
- package/dist/ai/tools/schema.js +2 -2
- package/dist/app.js +10 -1
- package/dist/controllers/deployment-webhooks.d.ts +2 -0
- package/dist/controllers/deployment-webhooks.js +95 -0
- package/dist/controllers/deployment.js +61 -165
- package/dist/controllers/files.js +2 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
- package/dist/database/helpers/date/dialects/oracle.js +2 -0
- package/dist/database/helpers/date/dialects/sqlite.js +2 -0
- package/dist/database/helpers/date/types.d.ts +1 -1
- package/dist/database/helpers/date/types.js +3 -1
- package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/mssql.js +21 -0
- package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
- package/dist/database/helpers/fn/dialects/mysql.js +30 -0
- package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/oracle.js +21 -0
- package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
- package/dist/database/helpers/fn/dialects/postgres.js +40 -0
- package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
- package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
- package/dist/database/helpers/fn/json/parse-function.js +66 -0
- package/dist/database/helpers/fn/types.d.ts +8 -0
- package/dist/database/helpers/fn/types.js +19 -0
- package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
- package/dist/database/helpers/schema/dialects/mysql.js +11 -0
- package/dist/database/helpers/schema/types.d.ts +1 -0
- package/dist/database/helpers/schema/types.js +3 -0
- package/dist/database/index.js +2 -1
- package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
- package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
- package/dist/database/run-ast/lib/parse-current-level.js +8 -1
- package/dist/database/run-ast/run-ast.js +11 -1
- package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
- package/dist/database/run-ast/utils/get-column.js +13 -2
- package/dist/deployment/deployment.d.ts +25 -2
- package/dist/deployment/drivers/netlify.d.ts +6 -2
- package/dist/deployment/drivers/netlify.js +114 -12
- package/dist/deployment/drivers/vercel.d.ts +5 -2
- package/dist/deployment/drivers/vercel.js +84 -5
- package/dist/deployment.d.ts +5 -0
- package/dist/deployment.js +34 -0
- package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
- package/dist/request/is-denied-ip.js +24 -23
- package/dist/services/authentication.js +27 -22
- package/dist/services/collections.js +1 -0
- package/dist/services/deployment-projects.d.ts +31 -2
- package/dist/services/deployment-projects.js +109 -5
- package/dist/services/deployment-runs.d.ts +19 -1
- package/dist/services/deployment-runs.js +86 -0
- package/dist/services/deployment.d.ts +44 -3
- package/dist/services/deployment.js +263 -15
- package/dist/services/files/utils/get-metadata.js +6 -6
- package/dist/services/files.d.ts +3 -1
- package/dist/services/files.js +26 -3
- package/dist/services/graphql/resolvers/query.js +23 -6
- package/dist/services/payload.d.ts +6 -0
- package/dist/services/payload.js +27 -2
- package/dist/services/server.js +1 -1
- package/dist/services/users.js +6 -1
- package/dist/utils/get-field-relational-depth.d.ts +13 -0
- package/dist/utils/get-field-relational-depth.js +22 -0
- package/dist/utils/parse-value.d.ts +4 -0
- package/dist/utils/parse-value.js +11 -0
- package/dist/utils/sanitize-query.js +3 -2
- package/dist/utils/split-fields.d.ts +4 -0
- package/dist/utils/split-fields.js +32 -0
- package/dist/utils/validate-query.js +2 -1
- package/package.json +29 -29
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { REGEX_BETWEEN_PARENS } from '@directus/constants';
|
|
2
|
-
import { getRelation, getRelationType } from '@directus/utils';
|
|
2
|
+
import { getRelation, getRelationType, parseFilterFunctionPath } from '@directus/utils';
|
|
3
3
|
import { isEmpty } from 'lodash-es';
|
|
4
4
|
import { fetchPermissions } from '../../../permissions/lib/fetch-permissions.js';
|
|
5
5
|
import { fetchPolicies } from '../../../permissions/lib/fetch-policies.js';
|
|
@@ -35,13 +35,58 @@ export async function parseFields(options, context) {
|
|
|
35
35
|
name = options.query.alias[fieldKey];
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// Normalize function calls to move relational prefixes outside the function.
|
|
39
|
+
// e.g., json(m2m.data, color) → m2m.json(data, color)
|
|
40
|
+
// This allows the standard relational handling below to process the traversal
|
|
41
|
+
// uniformly for all functions (json, count, year, etc.).
|
|
42
|
+
name = parseFilterFunctionPath(name);
|
|
43
|
+
const isFunctionCall = name.includes('(') && name.includes(')');
|
|
44
|
+
if (isFunctionCall) {
|
|
45
|
+
const functionName = name.split('(')[0];
|
|
46
|
+
const columnName = name.match(REGEX_BETWEEN_PARENS)[1];
|
|
47
|
+
const foundField = context.schema.collections[options.parentCollection].fields[columnName];
|
|
48
|
+
// Create a FunctionFieldNode for relational count functions (count(related_items))
|
|
49
|
+
if (functionName === 'count' && foundField && foundField.type === 'alias') {
|
|
50
|
+
const foundRelation = context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === columnName);
|
|
51
|
+
if (foundRelation) {
|
|
52
|
+
children.push({
|
|
53
|
+
type: 'functionField',
|
|
54
|
+
name,
|
|
55
|
+
fieldKey,
|
|
56
|
+
query: {},
|
|
57
|
+
relatedCollection: foundRelation.collection,
|
|
58
|
+
whenCase: [],
|
|
59
|
+
cases: [],
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Create a FunctionFieldNode for direct (non-relational) json function calls
|
|
65
|
+
if (functionName === 'json') {
|
|
66
|
+
children.push({
|
|
67
|
+
type: 'functionField',
|
|
68
|
+
name,
|
|
69
|
+
fieldKey,
|
|
70
|
+
query: {},
|
|
71
|
+
relatedCollection: options.parentCollection,
|
|
72
|
+
whenCase: [],
|
|
73
|
+
cases: [],
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const isRelationalFunctionCall = isFunctionCall && name.includes('.') && name.indexOf('.') < name.indexOf('(');
|
|
79
|
+
const isRelational = (!isFunctionCall || isRelationalFunctionCall) &&
|
|
80
|
+
(name.includes('.') ||
|
|
81
|
+
// We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
|
|
82
|
+
// anything
|
|
83
|
+
!!context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === name));
|
|
42
84
|
if (isRelational) {
|
|
43
|
-
//
|
|
44
|
-
|
|
85
|
+
// For normalized function calls, split on the resolved name since
|
|
86
|
+
// parseFilterFunctionPath may have moved relational segments outside
|
|
87
|
+
// the function args (e.g., json(m2m.data, color) → m2m.json(data, color)).
|
|
88
|
+
// For plain fields, split on fieldKey to preserve existing alias behavior.
|
|
89
|
+
const parts = (isFunctionCall ? name : fieldKey).split('.');
|
|
45
90
|
let rootField = parts[0];
|
|
46
91
|
let collectionScope = null;
|
|
47
92
|
// a2o related collection scoped field selector `fields=sections.section_id:headings.title`
|
|
@@ -72,25 +117,6 @@ export async function parseFields(options, context) {
|
|
|
72
117
|
}
|
|
73
118
|
}
|
|
74
119
|
else {
|
|
75
|
-
if (name.includes('(') && name.includes(')')) {
|
|
76
|
-
const columnName = name.match(REGEX_BETWEEN_PARENS)[1];
|
|
77
|
-
const foundField = context.schema.collections[options.parentCollection].fields[columnName];
|
|
78
|
-
if (foundField && foundField.type === 'alias') {
|
|
79
|
-
const foundRelation = context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === columnName);
|
|
80
|
-
if (foundRelation) {
|
|
81
|
-
children.push({
|
|
82
|
-
type: 'functionField',
|
|
83
|
-
name,
|
|
84
|
-
fieldKey,
|
|
85
|
-
query: {},
|
|
86
|
-
relatedCollection: foundRelation.collection,
|
|
87
|
-
whenCase: [],
|
|
88
|
-
cases: [],
|
|
89
|
-
});
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
120
|
if (name.includes(':')) {
|
|
95
121
|
const [key, scope] = name.split(':');
|
|
96
122
|
if (key in relationalStructure === false) {
|
|
@@ -3,5 +3,5 @@ export declare abstract class DateHelper extends DatabaseHelper {
|
|
|
3
3
|
parse(date: string | Date): string;
|
|
4
4
|
readTimestampString(date: string): string;
|
|
5
5
|
writeTimestamp(date: string): Date;
|
|
6
|
-
fieldFlagForField(
|
|
6
|
+
fieldFlagForField(fieldType: string): string;
|
|
7
7
|
}
|
|
@@ -11,4 +11,5 @@ export declare class FnHelperMSSQL extends FnHelper {
|
|
|
11
11
|
minute(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
12
12
|
second(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
13
13
|
count(table: string, column: string, options?: FnHelperOptions): Knex.Raw<any>;
|
|
14
|
+
json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
15
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
1
2
|
import { FnHelper } from '../types.js';
|
|
2
3
|
const parseLocaltime = (columnType) => {
|
|
3
4
|
if (columnType === 'timestamp') {
|
|
@@ -41,4 +42,24 @@ export class FnHelperMSSQL extends FnHelper {
|
|
|
41
42
|
}
|
|
42
43
|
throw new Error(`Couldn't extract type from ${table}.${column}`);
|
|
43
44
|
}
|
|
45
|
+
json(table, column, options) {
|
|
46
|
+
const collectionName = options?.originalCollectionName || table;
|
|
47
|
+
const fieldSchema = this.schema.collections?.[collectionName]?.fields?.[column];
|
|
48
|
+
if (!fieldSchema || fieldSchema.type !== 'json' || !options?.jsonPath) {
|
|
49
|
+
throw new InvalidQueryError({ reason: `${collectionName}.${column} is not a JSON field` });
|
|
50
|
+
}
|
|
51
|
+
// ".items[0].name" → "$.items[0].name"
|
|
52
|
+
const jsonPath = '$' + options?.jsonPath;
|
|
53
|
+
// JSON_VALUE only returns scalar values (returns NULL for objects/arrays)
|
|
54
|
+
// JSON_QUERY only returns objects/arrays (returns NULL for scalars)
|
|
55
|
+
// COALESCE handles both cases
|
|
56
|
+
return this.knex.raw(`COALESCE(JSON_QUERY(??.??, ?), JSON_VALUE(??.??, ?))`, [
|
|
57
|
+
table,
|
|
58
|
+
column,
|
|
59
|
+
jsonPath,
|
|
60
|
+
table,
|
|
61
|
+
column,
|
|
62
|
+
jsonPath,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
44
65
|
}
|
|
@@ -11,4 +11,6 @@ export declare class FnHelperMySQL extends FnHelper {
|
|
|
11
11
|
minute(table: string, column: string): Knex.Raw;
|
|
12
12
|
second(table: string, column: string): Knex.Raw;
|
|
13
13
|
count(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
|
+
json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
15
|
}
|
|
16
|
+
export declare function convertToMySQLPath(path: string): string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
2
|
+
import { toPath } from 'lodash-es';
|
|
1
3
|
import { FnHelper } from '../types.js';
|
|
2
4
|
export class FnHelperMySQL extends FnHelper {
|
|
3
5
|
year(table, column) {
|
|
@@ -35,4 +37,32 @@ export class FnHelperMySQL extends FnHelper {
|
|
|
35
37
|
}
|
|
36
38
|
throw new Error(`Couldn't extract type from ${table}.${column}`);
|
|
37
39
|
}
|
|
40
|
+
json(table, column, options) {
|
|
41
|
+
const collectionName = options?.originalCollectionName || table;
|
|
42
|
+
const fieldSchema = this.schema.collections?.[collectionName]?.fields?.[column];
|
|
43
|
+
if (!fieldSchema || fieldSchema.type !== 'json' || !options?.jsonPath) {
|
|
44
|
+
throw new InvalidQueryError({ reason: `${collectionName}.${column} is not a JSON field` });
|
|
45
|
+
}
|
|
46
|
+
// Convert dot notation to MySQL JSON path
|
|
47
|
+
// ".items[0].name" → "$['items'][0]['name']"
|
|
48
|
+
const jsonPath = convertToMySQLPath(options.jsonPath);
|
|
49
|
+
return this.knex.raw(`JSON_UNQUOTE(JSON_EXTRACT(??.??, ?))`, [table, column, jsonPath]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function convertToMySQLPath(path) {
|
|
53
|
+
// Use dot notation for object keys (compatible with both MySQL and MariaDB)
|
|
54
|
+
// ".color" → "$.color"
|
|
55
|
+
// ".items[0].name" → "$.items[0].name"
|
|
56
|
+
const parts = toPath(path.startsWith('.') ? path.slice(1) : path);
|
|
57
|
+
let result = '$';
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
const num = Number(part);
|
|
60
|
+
if (Number.isInteger(num) && num >= 0 && String(num) === part) {
|
|
61
|
+
result += `[${part}]`;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
result += `.${part}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
38
68
|
}
|
|
@@ -11,4 +11,5 @@ export declare class FnHelperOracle extends FnHelper {
|
|
|
11
11
|
minute(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
12
12
|
second(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
13
13
|
count(table: string, column: string, options?: FnHelperOptions): Knex.Raw<any>;
|
|
14
|
+
json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
15
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
1
2
|
import { FnHelper } from '../types.js';
|
|
2
3
|
const parseLocaltime = (columnType) => {
|
|
3
4
|
if (columnType === 'timestamp') {
|
|
@@ -41,4 +42,24 @@ export class FnHelperOracle extends FnHelper {
|
|
|
41
42
|
}
|
|
42
43
|
throw new Error(`Couldn't extract type from ${table}.${column}`);
|
|
43
44
|
}
|
|
45
|
+
json(table, column, options) {
|
|
46
|
+
const collectionName = options?.originalCollectionName || table;
|
|
47
|
+
const fieldSchema = this.schema.collections?.[collectionName]?.fields?.[column];
|
|
48
|
+
if (!fieldSchema || fieldSchema.type !== 'json' || !options?.jsonPath) {
|
|
49
|
+
throw new InvalidQueryError({ reason: `${collectionName}.${column} is not a JSON field` });
|
|
50
|
+
}
|
|
51
|
+
// ".items[0].name" → "$.items[0].name"
|
|
52
|
+
const jsonPath = '$' + options.jsonPath;
|
|
53
|
+
// JSON_VALUE only returns scalar values (returns NULL for objects/arrays)
|
|
54
|
+
// JSON_QUERY only returns objects/arrays (returns NULL for scalars)
|
|
55
|
+
// COALESCE handles both cases
|
|
56
|
+
return this.knex.raw(`COALESCE(JSON_QUERY(??.??, ?), JSON_VALUE(??.??, ?))`, [
|
|
57
|
+
table,
|
|
58
|
+
column,
|
|
59
|
+
jsonPath,
|
|
60
|
+
table,
|
|
61
|
+
column,
|
|
62
|
+
jsonPath,
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
44
65
|
}
|
|
@@ -11,4 +11,18 @@ export declare class FnHelperPostgres extends FnHelper {
|
|
|
11
11
|
minute(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
12
12
|
second(table: string, column: string, options: FnHelperOptions): Knex.Raw;
|
|
13
13
|
count(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
|
+
parseJsonResult(value: unknown): unknown;
|
|
15
|
+
json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a parameterized PostgreSQL JSON path using -> operators.
|
|
19
|
+
* Returns a template string containing only operators and ? placeholders,
|
|
20
|
+
* plus a bindings array with the actual values.
|
|
21
|
+
*
|
|
22
|
+
* @example ".color" → { template: "->?", bindings: ["color"] }
|
|
23
|
+
* @example ".items[0].name" → { template: "->?->?->?", bindings: ["items", 0, "name"] }
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildPostgresJsonPath(path: string): {
|
|
26
|
+
template: string;
|
|
27
|
+
bindings: (string | number)[];
|
|
28
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
2
|
+
import { toPath } from 'lodash-es';
|
|
1
3
|
import { FnHelper } from '../types.js';
|
|
2
4
|
const parseLocaltime = (columnType) => {
|
|
3
5
|
if (columnType === 'timestamp') {
|
|
@@ -45,4 +47,42 @@ export class FnHelperPostgres extends FnHelper {
|
|
|
45
47
|
}
|
|
46
48
|
throw new Error(`Couldn't extract type from ${table}.${column}`);
|
|
47
49
|
}
|
|
50
|
+
// The pg driver automatically deserializes json/jsonb columns, so no string parsing needed.
|
|
51
|
+
parseJsonResult(value) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
json(table, column, options) {
|
|
55
|
+
const collectionName = options?.originalCollectionName || table;
|
|
56
|
+
const fieldSchema = this.schema.collections?.[collectionName]?.fields?.[column];
|
|
57
|
+
if (!fieldSchema || fieldSchema.type !== 'json' || !options?.jsonPath) {
|
|
58
|
+
throw new InvalidQueryError({ reason: `${collectionName}.${column} is not a JSON field` });
|
|
59
|
+
}
|
|
60
|
+
const { template, bindings } = buildPostgresJsonPath(options.jsonPath);
|
|
61
|
+
const cast = fieldSchema.dbType === 'jsonb' ? 'jsonb' : 'json';
|
|
62
|
+
return this.knex.raw(`??::${cast}${template}`, [table + '.' + column, ...bindings]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a parameterized PostgreSQL JSON path using -> operators.
|
|
67
|
+
* Returns a template string containing only operators and ? placeholders,
|
|
68
|
+
* plus a bindings array with the actual values.
|
|
69
|
+
*
|
|
70
|
+
* @example ".color" → { template: "->?", bindings: ["color"] }
|
|
71
|
+
* @example ".items[0].name" → { template: "->?->?->?", bindings: ["items", 0, "name"] }
|
|
72
|
+
*/
|
|
73
|
+
export function buildPostgresJsonPath(path) {
|
|
74
|
+
const parts = toPath(path.startsWith('.') ? path.slice(1) : path);
|
|
75
|
+
let template = '';
|
|
76
|
+
const bindings = [];
|
|
77
|
+
for (let i = 0; i < parts.length; i++) {
|
|
78
|
+
const num = Number(parts[i]);
|
|
79
|
+
template += '->?';
|
|
80
|
+
if (!isNaN(num) && num >= 0 && Number.isInteger(num)) {
|
|
81
|
+
bindings.push(num);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
bindings.push(parts[i]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { template, bindings };
|
|
48
88
|
}
|
|
@@ -11,4 +11,5 @@ export declare class FnHelperSQLite extends FnHelper {
|
|
|
11
11
|
minute(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
12
12
|
second(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
13
13
|
count(table: string, column: string, options?: FnHelperOptions): Knex.Raw<any>;
|
|
14
|
+
json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
14
15
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
1
2
|
import { FnHelper } from '../types.js';
|
|
2
3
|
const parseLocaltime = (columnType) => {
|
|
3
4
|
if (columnType === 'timestamp') {
|
|
@@ -65,4 +66,15 @@ export class FnHelperSQLite extends FnHelper {
|
|
|
65
66
|
}
|
|
66
67
|
throw new Error(`Couldn't extract type from ${table}.${column}`);
|
|
67
68
|
}
|
|
69
|
+
json(table, column, options) {
|
|
70
|
+
const collectionName = options?.originalCollectionName || table;
|
|
71
|
+
const fieldSchema = this.schema.collections?.[collectionName]?.fields?.[column];
|
|
72
|
+
if (!fieldSchema || fieldSchema.type !== 'json' || !options?.jsonPath) {
|
|
73
|
+
throw new InvalidQueryError({ reason: `${collectionName}.${column} is not a JSON field` });
|
|
74
|
+
}
|
|
75
|
+
// SQLite uses json_extract with $ path notation
|
|
76
|
+
// ".data.items[0].name" → "$.items[0].name"
|
|
77
|
+
const jsonPath = '$' + options.jsonPath;
|
|
78
|
+
return this.knex.raw(`json_extract(??.??, ?)`, [table, column, jsonPath]);
|
|
79
|
+
}
|
|
68
80
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates the depth of a JSON path by counting the number of property accesses and array accesses.
|
|
3
|
+
* @example .color → 1
|
|
4
|
+
* @example .settings.theme → 2
|
|
5
|
+
* @example .items[0].name → 3
|
|
6
|
+
* @example [0] → 1
|
|
7
|
+
*/
|
|
8
|
+
export declare function calculateJsonPathDepth(path: string): number;
|
|
9
|
+
/**
|
|
10
|
+
* Parses a json function selection into its field and path components.
|
|
11
|
+
* Expects relational prefixes to have already been extracted by parseFilterFunctionPath,
|
|
12
|
+
* so the field should always be a simple column name.
|
|
13
|
+
* @example json(metadata, color) → { field: 'metadata', path: '.color' }
|
|
14
|
+
* @example json(data, items[0].name) → { field: 'data', path: '.items[0].name' }
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseJsonFunction(functionString: string): {
|
|
17
|
+
field: string;
|
|
18
|
+
path: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
3
|
+
const env = useEnv();
|
|
4
|
+
const MAX_JSON_QUERY_DEPTH = Number(env['MAX_JSON_QUERY_DEPTH']);
|
|
5
|
+
/**
|
|
6
|
+
* Calculates the depth of a JSON path by counting the number of property accesses and array accesses.
|
|
7
|
+
* @example .color → 1
|
|
8
|
+
* @example .settings.theme → 2
|
|
9
|
+
* @example .items[0].name → 3
|
|
10
|
+
* @example [0] → 1
|
|
11
|
+
*/
|
|
12
|
+
export function calculateJsonPathDepth(path) {
|
|
13
|
+
let depth = 0;
|
|
14
|
+
for (let i = 0; i < path.length; i++) {
|
|
15
|
+
if (path[i] === '.' || path[i] === '[') {
|
|
16
|
+
depth++;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return depth;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parses a json function selection into its field and path components.
|
|
23
|
+
* Expects relational prefixes to have already been extracted by parseFilterFunctionPath,
|
|
24
|
+
* so the field should always be a simple column name.
|
|
25
|
+
* @example json(metadata, color) → { field: 'metadata', path: '.color' }
|
|
26
|
+
* @example json(data, items[0].name) → { field: 'data', path: '.items[0].name' }
|
|
27
|
+
*/
|
|
28
|
+
export function parseJsonFunction(functionString) {
|
|
29
|
+
if (!functionString.startsWith('json(') || !functionString.endsWith(')')) {
|
|
30
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax' });
|
|
31
|
+
}
|
|
32
|
+
// Extract content between parentheses
|
|
33
|
+
const content = functionString.substring('json('.length, functionString.length - 1).trim();
|
|
34
|
+
if (!content) {
|
|
35
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax' });
|
|
36
|
+
}
|
|
37
|
+
// Split on comma to separate field from path
|
|
38
|
+
const commaIndex = content.indexOf(',');
|
|
39
|
+
if (commaIndex === -1) {
|
|
40
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax: requires json(field, path) format' });
|
|
41
|
+
}
|
|
42
|
+
if (commaIndex === 0) {
|
|
43
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax: missing field name' });
|
|
44
|
+
}
|
|
45
|
+
const field = content.substring(0, commaIndex).trim();
|
|
46
|
+
const pathContent = content.substring(commaIndex + 1).trim();
|
|
47
|
+
if (!pathContent) {
|
|
48
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax: missing path' });
|
|
49
|
+
}
|
|
50
|
+
if (pathContent.includes('[]') || /[*?@$]/.test(pathContent)) {
|
|
51
|
+
throw new InvalidQueryError({ reason: 'Invalid json() syntax: unsupported path expression' });
|
|
52
|
+
}
|
|
53
|
+
// Normalize path to always start with dot or bracket
|
|
54
|
+
const path = pathContent.startsWith('[') ? pathContent : '.' + pathContent;
|
|
55
|
+
// Validate JSON path depth
|
|
56
|
+
const depth = calculateJsonPathDepth(path);
|
|
57
|
+
if (depth > MAX_JSON_QUERY_DEPTH) {
|
|
58
|
+
throw new InvalidQueryError({
|
|
59
|
+
reason: `JSON path depth (${depth}) exceeds allowed maximum of ${MAX_JSON_QUERY_DEPTH}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
field,
|
|
64
|
+
path,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -9,6 +9,7 @@ export type FnHelperOptions = {
|
|
|
9
9
|
cases: Filter[];
|
|
10
10
|
permissions: Permission[];
|
|
11
11
|
} | undefined;
|
|
12
|
+
jsonPath: string | undefined;
|
|
12
13
|
};
|
|
13
14
|
export declare abstract class FnHelper extends DatabaseHelper {
|
|
14
15
|
protected schema: SchemaOverview;
|
|
@@ -22,5 +23,12 @@ export declare abstract class FnHelper extends DatabaseHelper {
|
|
|
22
23
|
abstract minute(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
23
24
|
abstract second(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
24
25
|
abstract count(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
26
|
+
abstract json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
27
|
+
/**
|
|
28
|
+
* Parse a value returned from a json() function query.
|
|
29
|
+
* Most databases return objects/arrays as stringified JSON — override this to skip parsing
|
|
30
|
+
* for drivers that already deserialize the result (e.g. the pg driver for PostgreSQL).
|
|
31
|
+
*/
|
|
32
|
+
parseJsonResult(value: unknown): unknown;
|
|
25
33
|
protected _relationalCount(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
|
|
26
34
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseJSON } from '@directus/utils';
|
|
1
2
|
import { applyFilter } from '../../run-ast/lib/apply-query/filter/index.js';
|
|
2
3
|
import { generateRelationalQueryAlias } from '../../run-ast/utils/generate-alias.js';
|
|
3
4
|
import { DatabaseHelper } from '../types.js';
|
|
@@ -8,6 +9,24 @@ export class FnHelper extends DatabaseHelper {
|
|
|
8
9
|
this.schema = schema;
|
|
9
10
|
this.schema = schema;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse a value returned from a json() function query.
|
|
14
|
+
* Most databases return objects/arrays as stringified JSON — override this to skip parsing
|
|
15
|
+
* for drivers that already deserialize the result (e.g. the pg driver for PostgreSQL).
|
|
16
|
+
*/
|
|
17
|
+
parseJsonResult(value) {
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
return value;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = parseJSON(value);
|
|
22
|
+
if (typeof parsed === 'object' && parsed !== null)
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// keep original string value (e.g. actual string data in the JSON path)
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
11
30
|
_relationalCount(table, column, options) {
|
|
12
31
|
const collectionName = options?.originalCollectionName || table;
|
|
13
32
|
const relation = this.schema.relations.find((relation) => relation.related_collection === collectionName && relation?.meta?.one_field === column);
|
|
@@ -6,4 +6,5 @@ export declare class SchemaHelperMySQL extends SchemaHelper {
|
|
|
6
6
|
getDatabaseSize(): Promise<number | null>;
|
|
7
7
|
addInnerSortFieldsToGroupBy(groupByFields: (string | Knex.Raw)[], sortRecords: SortRecord[], hasRelationalSort: boolean): void;
|
|
8
8
|
createIndex(collection: string, field: string, options?: CreateIndexOptions): Promise<Knex.SchemaBuilder>;
|
|
9
|
+
parseCollectionName(collection: string): Promise<string>;
|
|
9
10
|
}
|
|
@@ -4,6 +4,7 @@ import { toArray } from '@directus/utils';
|
|
|
4
4
|
import { getDefaultIndexName } from '../../../../utils/get-default-index-name.js';
|
|
5
5
|
import { SchemaHelper } from '../types.js';
|
|
6
6
|
const env = useEnv();
|
|
7
|
+
let lowerCaseTableNames;
|
|
7
8
|
export class SchemaHelperMySQL extends SchemaHelper {
|
|
8
9
|
generateIndexName(type, collection, fields) {
|
|
9
10
|
return getDefaultIndexName(type, collection, fields, { maxLength: 64 });
|
|
@@ -92,4 +93,14 @@ export class SchemaHelperMySQL extends SchemaHelper {
|
|
|
92
93
|
}
|
|
93
94
|
return blockingQuery;
|
|
94
95
|
}
|
|
96
|
+
async parseCollectionName(collection) {
|
|
97
|
+
if (lowerCaseTableNames === undefined) {
|
|
98
|
+
const result = await this.knex.raw('SELECT @@lower_case_table_names AS lctn');
|
|
99
|
+
lowerCaseTableNames = Number(result[0]?.[0]?.lctn ?? 0);
|
|
100
|
+
}
|
|
101
|
+
if (lowerCaseTableNames === 1) {
|
|
102
|
+
return collection.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
return collection;
|
|
105
|
+
}
|
|
95
106
|
}
|
|
@@ -59,4 +59,5 @@ export declare abstract class SchemaHelper extends DatabaseHelper {
|
|
|
59
59
|
getColumnNameMaxLength(): number;
|
|
60
60
|
getTableNameMaxLength(): number;
|
|
61
61
|
createIndex(collection: string, field: string, options?: CreateIndexOptions): Promise<Knex.SchemaBuilder>;
|
|
62
|
+
parseCollectionName(collection: string): Promise<string>;
|
|
62
63
|
}
|
|
@@ -148,4 +148,7 @@ export class SchemaHelper extends DatabaseHelper {
|
|
|
148
148
|
const constraintName = this.generateIndexName(isUnique ? 'unique' : 'index', collection, field);
|
|
149
149
|
return this.knex.raw(`CREATE ${isUnique ? 'UNIQUE ' : ''}INDEX ?? ON ?? (??)`, [constraintName, collection, field]);
|
|
150
150
|
}
|
|
151
|
+
async parseCollectionName(collection) {
|
|
152
|
+
return collection;
|
|
153
|
+
}
|
|
151
154
|
}
|
package/dist/database/index.js
CHANGED
|
@@ -246,11 +246,11 @@ export async function validateMigrations() {
|
|
|
246
246
|
* These database extensions should be optional, so we don't throw or return any problem states when they don't
|
|
247
247
|
*/
|
|
248
248
|
export async function validateDatabaseExtensions() {
|
|
249
|
+
const logger = useLogger();
|
|
249
250
|
const database = getDatabase();
|
|
250
251
|
const client = getDatabaseClient(database);
|
|
251
252
|
const helpers = getHelpers(database);
|
|
252
253
|
const geometrySupport = await helpers.st.supported();
|
|
253
|
-
const logger = useLogger();
|
|
254
254
|
if (!geometrySupport) {
|
|
255
255
|
switch (client) {
|
|
256
256
|
case 'postgres':
|
|
@@ -261,6 +261,7 @@ export async function validateDatabaseExtensions() {
|
|
|
261
261
|
break;
|
|
262
262
|
default:
|
|
263
263
|
logger.warn(`Geometry type not supported on ${client}`);
|
|
264
|
+
break;
|
|
264
265
|
}
|
|
265
266
|
}
|
|
266
267
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_deployments', (table) => {
|
|
3
|
+
table.json('webhook_ids').nullable();
|
|
4
|
+
table.string('webhook_secret').nullable();
|
|
5
|
+
table.timestamp('last_synced_at').nullable();
|
|
6
|
+
});
|
|
7
|
+
await knex.schema.alterTable('directus_deployment_projects', (table) => {
|
|
8
|
+
table.string('url').nullable();
|
|
9
|
+
table.string('framework').nullable();
|
|
10
|
+
table.boolean('deployable').notNullable().defaultTo(true);
|
|
11
|
+
});
|
|
12
|
+
await knex.schema.alterTable('directus_deployment_runs', (table) => {
|
|
13
|
+
table.string('status').nullable();
|
|
14
|
+
table.string('url').nullable();
|
|
15
|
+
table.timestamp('started_at').nullable();
|
|
16
|
+
table.timestamp('completed_at').nullable();
|
|
17
|
+
});
|
|
18
|
+
await knex('directus_deployment_runs').delete();
|
|
19
|
+
}
|
|
20
|
+
export async function down(knex) {
|
|
21
|
+
await knex.schema.alterTable('directus_deployment_runs', (table) => {
|
|
22
|
+
table.dropColumn('status');
|
|
23
|
+
table.dropColumn('url');
|
|
24
|
+
table.dropColumn('started_at');
|
|
25
|
+
table.dropColumn('completed_at');
|
|
26
|
+
});
|
|
27
|
+
await knex.schema.alterTable('directus_deployment_projects', (table) => {
|
|
28
|
+
table.dropColumn('url');
|
|
29
|
+
table.dropColumn('framework');
|
|
30
|
+
table.dropColumn('deployable');
|
|
31
|
+
});
|
|
32
|
+
await knex.schema.alterTable('directus_deployments', (table) => {
|
|
33
|
+
table.dropColumn('webhook_ids');
|
|
34
|
+
table.dropColumn('webhook_secret');
|
|
35
|
+
table.dropColumn('last_synced_at');
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { FieldOverview } from '@directus/types';
|
|
2
2
|
export declare function getFilterType(fields: Record<string, FieldOverview>, key: string, collection?: string): {
|
|
3
|
-
type: "string" | "boolean" | "binary" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "
|
|
3
|
+
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
|
|
4
4
|
special?: never;
|
|
5
5
|
} | {
|
|
6
|
-
type: "string" | "boolean" | "binary" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "
|
|
6
|
+
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
|
|
7
7
|
special: string[];
|
|
8
8
|
};
|