@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.
Files changed (98) hide show
  1. package/dist/ai/chat/lib/create-ui-stream.js +2 -1
  2. package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
  3. package/dist/ai/chat/lib/transform-file-parts.js +36 -0
  4. package/dist/ai/files/adapters/anthropic.d.ts +3 -0
  5. package/dist/ai/files/adapters/anthropic.js +25 -0
  6. package/dist/ai/files/adapters/google.d.ts +3 -0
  7. package/dist/ai/files/adapters/google.js +58 -0
  8. package/dist/ai/files/adapters/index.d.ts +3 -0
  9. package/dist/ai/files/adapters/index.js +3 -0
  10. package/dist/ai/files/adapters/openai.d.ts +3 -0
  11. package/dist/ai/files/adapters/openai.js +22 -0
  12. package/dist/ai/files/controllers/upload.d.ts +2 -0
  13. package/dist/ai/files/controllers/upload.js +101 -0
  14. package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
  15. package/dist/ai/files/lib/fetch-provider.js +23 -0
  16. package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
  17. package/dist/ai/files/lib/upload-to-provider.js +26 -0
  18. package/dist/ai/files/router.d.ts +1 -0
  19. package/dist/ai/files/router.js +5 -0
  20. package/dist/ai/files/types.d.ts +5 -0
  21. package/dist/ai/files/types.js +1 -0
  22. package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
  23. package/dist/ai/providers/anthropic-file-support.js +94 -0
  24. package/dist/ai/providers/registry.js +3 -6
  25. package/dist/ai/tools/flows/index.d.ts +16 -16
  26. package/dist/ai/tools/schema.d.ts +8 -8
  27. package/dist/ai/tools/schema.js +2 -2
  28. package/dist/app.js +10 -1
  29. package/dist/controllers/deployment-webhooks.d.ts +2 -0
  30. package/dist/controllers/deployment-webhooks.js +95 -0
  31. package/dist/controllers/deployment.js +61 -165
  32. package/dist/controllers/files.js +2 -1
  33. package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
  34. package/dist/database/helpers/date/dialects/oracle.js +2 -0
  35. package/dist/database/helpers/date/dialects/sqlite.js +2 -0
  36. package/dist/database/helpers/date/types.d.ts +1 -1
  37. package/dist/database/helpers/date/types.js +3 -1
  38. package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
  39. package/dist/database/helpers/fn/dialects/mssql.js +21 -0
  40. package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
  41. package/dist/database/helpers/fn/dialects/mysql.js +30 -0
  42. package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
  43. package/dist/database/helpers/fn/dialects/oracle.js +21 -0
  44. package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
  45. package/dist/database/helpers/fn/dialects/postgres.js +40 -0
  46. package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
  47. package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
  48. package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
  49. package/dist/database/helpers/fn/json/parse-function.js +66 -0
  50. package/dist/database/helpers/fn/types.d.ts +8 -0
  51. package/dist/database/helpers/fn/types.js +19 -0
  52. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
  53. package/dist/database/helpers/schema/dialects/mysql.js +11 -0
  54. package/dist/database/helpers/schema/types.d.ts +1 -0
  55. package/dist/database/helpers/schema/types.js +3 -0
  56. package/dist/database/index.js +2 -1
  57. package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
  58. package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
  59. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  60. package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
  61. package/dist/database/run-ast/lib/parse-current-level.js +8 -1
  62. package/dist/database/run-ast/run-ast.js +11 -1
  63. package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
  64. package/dist/database/run-ast/utils/get-column.js +13 -2
  65. package/dist/deployment/deployment.d.ts +25 -2
  66. package/dist/deployment/drivers/netlify.d.ts +6 -2
  67. package/dist/deployment/drivers/netlify.js +114 -12
  68. package/dist/deployment/drivers/vercel.d.ts +5 -2
  69. package/dist/deployment/drivers/vercel.js +84 -5
  70. package/dist/deployment.d.ts +5 -0
  71. package/dist/deployment.js +34 -0
  72. package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
  73. package/dist/request/is-denied-ip.js +24 -23
  74. package/dist/services/authentication.js +27 -22
  75. package/dist/services/collections.js +1 -0
  76. package/dist/services/deployment-projects.d.ts +31 -2
  77. package/dist/services/deployment-projects.js +109 -5
  78. package/dist/services/deployment-runs.d.ts +19 -1
  79. package/dist/services/deployment-runs.js +86 -0
  80. package/dist/services/deployment.d.ts +44 -3
  81. package/dist/services/deployment.js +263 -15
  82. package/dist/services/files/utils/get-metadata.js +6 -6
  83. package/dist/services/files.d.ts +3 -1
  84. package/dist/services/files.js +26 -3
  85. package/dist/services/graphql/resolvers/query.js +23 -6
  86. package/dist/services/payload.d.ts +6 -0
  87. package/dist/services/payload.js +27 -2
  88. package/dist/services/server.js +1 -1
  89. package/dist/services/users.js +6 -1
  90. package/dist/utils/get-field-relational-depth.d.ts +13 -0
  91. package/dist/utils/get-field-relational-depth.js +22 -0
  92. package/dist/utils/parse-value.d.ts +4 -0
  93. package/dist/utils/parse-value.js +11 -0
  94. package/dist/utils/sanitize-query.js +3 -2
  95. package/dist/utils/split-fields.d.ts +4 -0
  96. package/dist/utils/split-fields.js +32 -0
  97. package/dist/utils/validate-query.js +2 -1
  98. 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
- const isRelational = name.includes('.') ||
39
- // We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
40
- // anything
41
- !!context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === name);
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
- // field is relational
44
- const parts = fieldKey.split('.');
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) {
@@ -16,6 +16,8 @@ export class DateHelperOracle extends DateHelper {
16
16
  }
17
17
  fieldFlagForField(fieldType) {
18
18
  switch (fieldType) {
19
+ case 'json':
20
+ return 'cast-json';
19
21
  case 'dateTime':
20
22
  return 'cast-datetime';
21
23
  default:
@@ -17,6 +17,8 @@ export class DateHelperSQLite extends DateHelper {
17
17
  }
18
18
  fieldFlagForField(fieldType) {
19
19
  switch (fieldType) {
20
+ case 'json':
21
+ return 'cast-json';
20
22
  case 'timestamp':
21
23
  return 'cast-timestamp';
22
24
  default:
@@ -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(_fieldType: string): string;
6
+ fieldFlagForField(fieldType: string): string;
7
7
  }
@@ -14,7 +14,9 @@ export class DateHelper extends DatabaseHelper {
14
14
  writeTimestamp(date) {
15
15
  return parseISO(date);
16
16
  }
17
- fieldFlagForField(_fieldType) {
17
+ fieldFlagForField(fieldType) {
18
+ if (fieldType === 'json')
19
+ return 'cast-json';
18
20
  return '';
19
21
  }
20
22
  }
@@ -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
  }
@@ -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,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -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" | "time" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
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" | "time" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
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
  };