Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested alias returning null #21826

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shy-terms-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

Check warning on line 1 in .changeset/shy-terms-glow.md

View workflow job for this annotation

GitHub Actions / Lint

File ignored by default.
'@directus/api': patch
---

Bug fix for nested alias fields in queries
2 changes: 1 addition & 1 deletion api/src/services/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
)
: query;

let ast = await getASTFromQuery(this.collection, updatedQuery, this.schema, {
let ast = getASTFromQuery(this.collection, updatedQuery, this.schema, {
accountability: this.accountability,
// By setting the permissions action, you can read items using the permissions for another
// operation's permissions. This is used to dynamically check if you have update/delete
Expand Down
246 changes: 246 additions & 0 deletions api/src/utils/get-ast-from-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { describe, expect, test } from 'vitest';
import type { PermissionsAction, Query, SchemaOverview, FieldOverview } from '@directus/types';

import getASTFromQuery from './get-ast-from-query.js';

const collection = 'root_collection';

const query = {
alias: {
firstLevelA: 'first_level_a',
firstLevelB: 'first_level_b',
},
deep: {
firstLevelA: {
_alias: {
secondLevelA: 'second_level_a',
},
},
},
fields: [
'id',
'firstLevelA.id',
'firstLevelA.secondLevelA.id',
'firstLevelA.secondLevelA.field_to_alias',
'firstLevelB.id',
],
} as Query;

const fieldDefaults: FieldOverview = {
field: 'id',
defaultValue: 'AUTO_INCREMENT',
nullable: true,
generated: false,
type: 'integer',
dbType: 'integer',
precision: null,
scale: null,
special: [],
note: null,
alias: false,
validation: null,
};

const metaDefaults = {
one_field: null,
one_collection_field: null,
one_allowed_collections: null,
junction_field: null,
sort_field: null,
one_deselect_action: 'nullify' as 'delete' | 'nullify',
};

const schema: SchemaOverview = {
collections: {
first_level_a: {
collection: 'first_level_a',
primary: 'id',
singleton: false,
note: null,
sortField: null,
accountability: 'all',
fields: {
id: {
...fieldDefaults,
},
second_level_a: {
...fieldDefaults,
field: 'second_level_a',
special: ['m2o'],
},
},
},
first_level_b: {
collection: 'first_level_b',
primary: 'id',
singleton: false,
note: null,
sortField: null,
accountability: 'all',
fields: {
id: {
...fieldDefaults,
},
},
},
root_collection: {
collection: 'root_collection',
primary: 'id',
singleton: false,
note: null,
sortField: null,
accountability: 'all',
fields: {
id: {
...fieldDefaults,
},
first_level_b: {
...fieldDefaults,
field: 'first_level_b',
defaultValue: null,
special: ['m2o'],
},
first_level_a: {
...fieldDefaults,
field: 'first_level_a',
defaultValue: null,
special: ['m2o'],
},
},
},
second_level_a: {
collection: 'second_level_a',
primary: 'id',
singleton: false,
note: null,
sortField: null,
accountability: 'all',
fields: {
id: {
...fieldDefaults,
},
field_to_alias: {
...fieldDefaults,
field: 'field_to_alias',
defaultValue: null,
type: 'string',
dbType: 'character varying',
},
},
},
},
relations: [
{
collection: 'root_collection',
field: 'first_level_a',
related_collection: 'first_level_a',
schema: {
constraint_name: 'root_collection_first_level_a_foreign',
table: 'root_collection',
column: 'first_level_a',
foreign_key_schema: 'public',
foreign_key_table: 'first_level_a',
foreign_key_column: 'id',
on_update: 'NO ACTION',
on_delete: 'SET NULL',
},
meta: {
...metaDefaults,
id: 7,
many_collection: 'root_collection',
many_field: 'first_level_a',
one_collection: 'first_level_a',
},
},
{
collection: 'root_collection',
field: 'first_level_b',
related_collection: 'first_level_b',
schema: {
constraint_name: 'root_collection_first_level_b_foreign',
table: 'root_collection',
column: 'first_level_b',
foreign_key_schema: 'public',
foreign_key_table: 'first_level_b',
foreign_key_column: 'id',
on_update: 'NO ACTION',
on_delete: 'SET NULL',
},
meta: {
...metaDefaults,
id: 6,
many_collection: 'root_collection',
many_field: 'first_level_b',
one_collection: 'first_level_b',
},
},
{
collection: 'first_level_a',
field: 'second_level_a',
related_collection: 'second_level_a',
schema: {
constraint_name: 'first_level_a_second_level_a_foreign',
table: 'first_level_a',
column: 'second_level_a',
foreign_key_schema: 'public',
foreign_key_table: 'second_level_a',
foreign_key_column: 'id',
on_update: 'NO ACTION',
on_delete: 'SET NULL',
},
meta: {
...metaDefaults,
id: 8,
many_collection: 'first_level_a',
many_field: 'second_level_a',
one_collection: 'second_level_a',
},
},
],
};

const options: { action: PermissionsAction } = {
action: 'read',
};

describe('getASTFromQuery', () => {
test('should return the complete AST for nested alias fields', async () => {
const ast = getASTFromQuery(collection, query, schema, options);

expect(ast).toEqual(
expect.objectContaining({
type: 'root',
name: 'root_collection',
query: {
alias: {
firstLevelA: 'first_level_a',
firstLevelB: 'first_level_b',
},
sort: ['id'],
},
children: expect.arrayContaining([
expect.objectContaining({
type: 'field',
name: 'id',
fieldKey: 'id',
}),
expect.objectContaining({
type: 'm2o',
name: 'first_level_a',
fieldKey: 'firstLevelA',
query: {
alias: {
secondLevelA: 'second_level_a',
},
},
}),
expect.objectContaining({
type: 'm2o',
name: 'first_level_b',
fieldKey: 'firstLevelB',
}),
]),
}),
);
});
});
42 changes: 23 additions & 19 deletions api/src/utils/get-ast-from-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { REGEX_BETWEEN_PARENS } from '@directus/constants';
import type { Accountability, PermissionsAction, Query, SchemaOverview } from '@directus/types';
import type { Knex } from 'knex';
import { cloneDeep, isEmpty, mapKeys, omitBy, uniq } from 'lodash-es';
import { cloneDeep, mapKeys, omitBy, uniq } from 'lodash-es';
import type { AST, FieldNode, FunctionFieldNode, NestedCollectionNode } from '../types/index.js';
import { getRelationType } from './get-relation-type.js';

Expand All @@ -19,12 +19,12 @@ type anyNested = {
[collectionScope: string]: string[];
};

export default async function getASTFromQuery(
export default function getASTFromQuery(
collection: string,
query: Query,
schema: SchemaOverview,
options?: GetASTOptions,
): Promise<AST> {
): AST {
query = cloneDeep(query);

const accountability = options?.accountability;
Expand Down Expand Up @@ -97,14 +97,19 @@ export default async function getASTFromQuery(
delete query.sort;
}

ast.children = await parseFields(collection, fields, deep);
ast.children = parseFields(collection, fields, deep, query.alias);

return ast;

async function parseFields(parentCollection: string, fields: string[] | null, deep?: Record<string, any>) {
function parseFields(
parentCollection: string,
fields: string[] | null,
deep?: Record<string, any>,
alias?: Record<string, string> | null,
) {
if (!fields) return [];

fields = await convertWildcards(parentCollection, fields);
fields = convertWildcards(parentCollection, fields, alias);

if (!fields || !Array.isArray(fields)) return [];

Expand All @@ -115,10 +120,10 @@ export default async function getASTFromQuery(
for (const fieldKey of fields) {
let name = fieldKey;

if (query.alias) {
if (alias) {
// check for field alias (is is one of the key)
if (name in query.alias) {
name = query.alias[fieldKey]!;
if (name in alias) {
name = alias[fieldKey]!;
}
}

Expand Down Expand Up @@ -196,8 +201,8 @@ export default async function getASTFromQuery(
for (const [fieldKey, nestedFields] of Object.entries(relationalStructure)) {
let fieldName = fieldKey;

if (query.alias && fieldKey in query.alias) {
fieldName = query.alias[fieldKey]!;
if (alias && fieldKey in alias) {
fieldName = alias[fieldKey]!;
}

const relatedCollection = getRelatedCollection(parentCollection, fieldName);
Expand Down Expand Up @@ -233,7 +238,7 @@ export default async function getASTFromQuery(
};

for (const relatedCollection of allowedCollections) {
child.children[relatedCollection] = await parseFields(
child.children[relatedCollection] = parseFields(
relatedCollection,
Array.isArray(nestedFields) ? nestedFields : (nestedFields as anyNested)[relatedCollection] || [],
deep?.[`${fieldKey}:${relatedCollection}`],
Expand All @@ -250,7 +255,6 @@ export default async function getASTFromQuery(

// update query alias for children parseFields
const deepAlias = getDeepQuery(deep?.[fieldKey] || {})?.['alias'];
if (!isEmpty(deepAlias)) query.alias = deepAlias;

child = {
type: relationType,
Expand All @@ -260,7 +264,7 @@ export default async function getASTFromQuery(
relatedKey: schema.collections[relatedCollection]!.primary,
relation: relation,
query: getDeepQuery(deep?.[fieldKey] || {}),
children: await parseFields(relatedCollection, nestedFields as string[], deep?.[fieldKey] || {}),
children: parseFields(relatedCollection, nestedFields as string[], deep?.[fieldKey] || {}, deepAlias),
};

if (relationType === 'o2m' && !child!.query.sort) {
Expand All @@ -287,7 +291,7 @@ export default async function getASTFromQuery(
});
}

async function convertWildcards(parentCollection: string, fields: string[]) {
function convertWildcards(parentCollection: string, fields: string[], alias?: Record<string, string> | null) {
fields = cloneDeep(fields);

const fieldsInCollection = Object.entries(schema.collections[parentCollection]!.fields).map(([name]) => name);
Expand All @@ -310,15 +314,15 @@ export default async function getASTFromQuery(
if (fieldKey.includes('*') === false) continue;

if (fieldKey === '*') {
const aliases = Object.keys(query.alias ?? {});
const aliases = Object.keys(alias ?? {});

// Set to all fields in collection
if (allowedFields.includes('*')) {
fields.splice(index, 1, ...fieldsInCollection, ...aliases);
} else {
// Set to all allowed fields
const allowedAliases = aliases.filter((fieldKey) => {
const name = query.alias![fieldKey]!;
const name = alias![fieldKey]!;
return allowedFields!.includes(name);
});

Expand All @@ -344,8 +348,8 @@ export default async function getASTFromQuery(

const nonRelationalFields = allowedFields.filter((fieldKey) => relationalFields.includes(fieldKey) === false);

const aliasFields = Object.keys(query.alias ?? {}).map((fieldKey) => {
const name = query.alias![fieldKey];
const aliasFields = Object.keys(alias ?? {}).map((fieldKey) => {
const name = alias![fieldKey];

if (relationalFields.includes(name)) {
return `${fieldKey}.${parts.slice(1).join('.')}`;
Expand Down