feat: conditional filtering & aggregation support & data ordering support (#2107)

* feat: wip

* feat: add filter on findOne

* fix: tests & small bug

* feat: add test and support aggregation

* feat: add order by support

* fix: fix comments

* fix: tests
This commit is contained in:
Jérémy M
2023-10-19 15:24:36 +02:00
committed by GitHub
parent 2b8a81a05c
commit 3e83cb6846
23 changed files with 555 additions and 156 deletions
@@ -12,7 +12,18 @@ import { PGGraphQLQueryRunner } from './pg-graphql/pg-graphql-query-runner.util'
export class EntityResolverService {
constructor(private readonly dataSourceService: DataSourceService) {}
async findMany(context: SchemaBuilderContext, info: GraphQLResolveInfo) {
async findMany(
args: {
first?: number;
last?: number;
before?: string;
after?: string;
filter?: any;
orderBy?: any;
},
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
) {
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
tableName: context.tableName,
workspaceId: context.workspaceId,
@@ -20,11 +31,11 @@ export class EntityResolverService {
fields: context.fields,
});
return runner.findMany();
return runner.findMany(args);
}
async findOne(
args: { id: string },
args: { filter?: any },
context: SchemaBuilderContext,
info: GraphQLResolveInfo,
) {
@@ -65,8 +65,9 @@ describe('PGGraphQLQueryBuilder', () => {
queryBuilder = new PGGraphQLQueryBuilder(mockOptions);
});
test('findMany generates correct query with complex and nested fields', () => {
test('findMany generates correct query with no arguments', () => {
const query = queryBuilder.findMany();
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
@@ -81,13 +82,19 @@ describe('PGGraphQLQueryBuilder', () => {
);
});
test('findOne generates correct query with complex and nested fields', () => {
const args = { id: '1' };
const query = queryBuilder.findOne(args);
test('findMany generates correct query with filter parameters', () => {
const args = {
filter: {
name: { eq: 'Alice' },
age: { gt: 20 },
},
};
const query = queryBuilder.findMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(filter: { id: { eq: "1" } }) {
TestTableCollection(filter: { column_name: { eq: "Alice" }, column_age: { gt: 20 } }) {
name: column_name
age: column_age
___complexField_subField1: column_subField1
@@ -98,6 +105,56 @@ describe('PGGraphQLQueryBuilder', () => {
);
});
test('findMany generates correct query with combined pagination parameters', () => {
const args = {
first: 5,
after: 'someCursor',
before: 'anotherCursor',
last: 3,
};
const query = queryBuilder.findMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(
first: 5,
after: "someCursor",
before: "anotherCursor",
last: 3
) {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
`),
);
});
test('findOne generates correct query with ID filter', () => {
const args = { filter: { id: { eq: testUUID } } };
const query = queryBuilder.findOne(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
query {
TestTableCollection(filter: { id: { eq: "${testUUID}" } }) {
edges {
node {
name: column_name
age: column_age
___complexField_subField1: column_subField1
___complexField_subField2: column_subField2
}
}
}
}
`),
);
});
test('createMany generates correct mutation with complex and nested fields', () => {
const args = {
data: [
@@ -112,6 +169,7 @@ describe('PGGraphQLQueryBuilder', () => {
],
};
const query = queryBuilder.createMany(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
mutation {
@@ -148,6 +206,7 @@ describe('PGGraphQLQueryBuilder', () => {
},
};
const query = queryBuilder.updateOne(args);
expect(normalizeWhitespace(query)).toBe(
normalizeWhitespace(`
mutation {
@@ -6,10 +6,17 @@ import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity
import { stringifyWithoutKeyQuote } from 'src/tenant/entity-resolver/utils/stringify-without-key-quote.util';
import { convertFieldsToGraphQL } from 'src/tenant/entity-resolver/utils/convert-fields-to-graphql.util';
import { convertArguments } from 'src/tenant/entity-resolver/utils/convert-arguments.util';
import { generateArgsInput } from 'src/tenant/entity-resolver/utils/generate-args-input.util';
type CommandArgs = {
findMany: null;
findOne: { id: string };
findMany: {
first?: number;
last?: number;
before?: string;
after?: string;
filter?: any;
};
findOne: { filter?: any };
createMany: { data: any[] };
updateOne: { id: string; data: any };
};
@@ -34,41 +41,49 @@ export class PGGraphQLQueryBuilder {
}
// Define command setters
findMany() {
findMany(args?: CommandArgs['findMany']) {
const { tableName } = this.options;
const fieldsString = this.getFieldsString();
const convertedArgs = convertArguments(args, this.options.fields);
const argsString = generateArgsInput(convertedArgs);
return `
query {
${tableName}Collection {
${tableName}Collection${argsString ? `(${argsString})` : ''} {
${fieldsString}
}
}
`;
}
findOne({ id }: CommandArgs['findOne']) {
findOne(args: CommandArgs['findOne']) {
const { tableName } = this.options;
const fieldsString = this.getFieldsString();
const convertedArgs = convertArguments(args, this.options.fields);
const argsString = generateArgsInput(convertedArgs);
return `
query {
${tableName}Collection(filter: { id: { eq: "${id}" } }) {
${fieldsString}
${tableName}Collection${argsString ? `(${argsString})` : ''} {
edges {
node {
${fieldsString}
}
}
}
}
`;
}
createMany({ data }: CommandArgs['createMany']) {
createMany(initialArgs: CommandArgs['createMany']) {
const { tableName } = this.options;
const fieldsString = this.getFieldsString();
const args = convertArguments(data, this.options.fields);
const args = convertArguments(initialArgs, this.options.fields);
return `
mutation {
insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote(
args.map((datum) => ({
args.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
@@ -82,16 +97,16 @@ export class PGGraphQLQueryBuilder {
`;
}
updateOne({ id, data }: CommandArgs['updateOne']) {
updateOne(initialArgs: CommandArgs['updateOne']) {
const { tableName } = this.options;
const fieldsString = this.getFieldsString();
const args = convertArguments(data, this.options.fields);
const args = convertArguments(initialArgs, this.options.fields);
return `
mutation {
update${tableName}Collection(set: ${stringifyWithoutKeyQuote(
args,
)}, filter: { id: { eq: "${id}" } }) {
args.data,
)}, filter: { id: { eq: "${args.id}" } }) {
affectedCount
records {
${fieldsString}
@@ -58,18 +58,30 @@ export class PGGraphQLQueryRunner {
return parseResult(result);
}
async findMany(): Promise<any[]> {
const query = this.queryBuilder.findMany();
async findMany(args: {
first?: number;
last?: number;
before?: string;
after?: string;
filter?: any;
orderBy?: any;
}): Promise<any[]> {
const query = this.queryBuilder.findMany(args);
const result = await this.execute(query, this.options.workspaceId);
return this.parseResult(result, '');
}
async findOne(args: { id: string }): Promise<any> {
async findOne(args: { filter?: any }): Promise<any> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new BadRequestException('Missing filter argument');
}
const query = this.queryBuilder.findOne(args);
const result = await this.execute(query, this.options.workspaceId);
const parsedResult = this.parseResult(result, '');
return this.parseResult(result, '');
return parsedResult?.edges?.[0]?.node;
}
async createMany(args: { data: any[] }): Promise<any[]> {
@@ -66,4 +66,25 @@ describe('convertArguments', () => {
const expected = { column_1randomFirstNameKey: 'John', lastName: 'Doe' };
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle deeper nested object arguments', () => {
const args = {
user: {
details: {
firstName: 'John',
website: { link: 'https://www.example.com', text: 'example' },
},
},
};
const expected = {
user: {
details: {
column_1randomFirstNameKey: 'John',
column_randomLinkKey: 'https://www.example.com',
column_randomTex7Key: 'example',
},
},
};
expect(convertArguments(args, fields)).toEqual(expected);
});
});
@@ -0,0 +1,61 @@
import { generateArgsInput } from 'src/tenant/entity-resolver/utils/generate-args-input.util';
const normalizeWhitespace = (str) => str.replace(/\s+/g, '');
describe('generateArgsInput', () => {
it('should handle string inputs', () => {
const args = { someKey: 'someValue' };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: "someValue"'),
);
});
it('should handle number inputs', () => {
const args = { someKey: 123 };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: 123'),
);
});
it('should handle boolean inputs', () => {
const args = { someKey: true };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: true'),
);
});
it('should skip undefined values', () => {
const args = { definedKey: 'value', undefinedKey: undefined };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('definedKey: "value"'),
);
});
it('should handle object inputs', () => {
const args = { someKey: { nestedKey: 'nestedValue' } };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: {nestedKey: "nestedValue"}'),
);
});
it('should handle null inputs', () => {
const args = { someKey: null };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('someKey: null'),
);
});
it('should remove trailing commas', () => {
const args = { firstKey: 'firstValue', secondKey: 'secondValue' };
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
normalizeWhitespace('firstKey: "firstValue", secondKey: "secondValue"'),
);
});
});
@@ -1,55 +0,0 @@
import {
FieldMetadata,
FieldMetadataTargetColumnMap,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { getFieldAliases } from 'src/tenant/entity-resolver/utils/get-fields-aliases.util';
describe('getFieldAliases', () => {
let fields: FieldMetadata[];
beforeEach(() => {
// Setup sample field metadata
fields = [
{
name: 'singleValueField',
targetColumnMap: {
value: 'column_singleValue',
} as FieldMetadataTargetColumnMap,
},
{
name: 'multipleValuesField',
targetColumnMap: {
link: 'column_value1',
text: 'column_value2',
} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
});
test('should return correct aliases for fields with a single value in targetColumnMap', () => {
const aliases = getFieldAliases(fields);
expect(aliases).toHaveProperty('singleValueField', 'column_singleValue');
});
test('should return correct aliases for fields with multiple values in targetColumnMap', () => {
const aliases = getFieldAliases(fields);
expect(aliases).toHaveProperty('column_value1', 'column_value1');
});
test('should handle empty fields array', () => {
const aliases = getFieldAliases([]);
expect(aliases).toEqual({});
});
test('should not create aliases for fields without targetColumnMap values', () => {
const fieldsWithEmptyMap = [
...fields,
{
name: 'emptyField',
targetColumnMap: {} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
const aliases = getFieldAliases(fieldsWithEmptyMap);
expect(aliases).not.toHaveProperty('emptyField');
});
});
@@ -1,5 +1,3 @@
import isEmpty from 'lodash.isempty';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
export const convertArguments = (args: any, fields: FieldMetadata[]): any => {
@@ -7,31 +5,46 @@ export const convertArguments = (args: any, fields: FieldMetadata[]): any => {
fields.map((metadata) => [metadata.name, metadata]),
);
if (Array.isArray(args)) {
return args.map((arg) => convertArguments(arg, fields));
}
const processObject = (obj: any): any => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const newArgs = {};
if (Array.isArray(obj)) {
return obj.map((item) => processObject(item));
}
for (const [key, value] of Object.entries(args)) {
if (fieldsMap.has(key)) {
const fieldMetadata = fieldsMap.get(key)!;
const newObj = {};
if (typeof value === 'object' && value !== null && !isEmpty(value)) {
for (const [key, value] of Object.entries(obj)) {
const fieldMetadata = fieldsMap.get(key);
if (
fieldMetadata &&
typeof value === 'object' &&
value !== null &&
Object.values(fieldMetadata.targetColumnMap).length > 1
) {
for (const [subKey, subValue] of Object.entries(value)) {
if (fieldMetadata.targetColumnMap[subKey]) {
newArgs[fieldMetadata.targetColumnMap[subKey]] = subValue;
const mappedKey = fieldMetadata.targetColumnMap[subKey];
if (mappedKey) {
newObj[mappedKey] = subValue;
}
}
} else {
if (fieldMetadata.targetColumnMap.value) {
newArgs[fieldMetadata.targetColumnMap.value] = value;
}
}
} else {
newArgs[key] = value;
}
}
} else if (fieldMetadata) {
const mappedKey = fieldMetadata.targetColumnMap.value;
return newArgs;
if (mappedKey) {
newObj[mappedKey] = value;
}
} else {
newObj[key] = processObject(value);
}
}
return newObj;
};
return processObject(args);
};
@@ -0,0 +1,30 @@
import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util';
export const generateArgsInput = (args: any) => {
let argsString = '';
for (const key in args) {
// Check if the value is not undefined
if (args[key] === undefined) {
continue;
}
if (typeof args[key] === 'string') {
// If it's a string, add quotes
argsString += `${key}: "${args[key]}", `;
} else if (typeof args[key] === 'object' && args[key] !== null) {
// If it's an object (and not null), stringify it
argsString += `${key}: ${stringifyWithoutKeyQuote(args[key])}, `;
} else {
// For other types (number, boolean), add as is
argsString += `${key}: ${args[key]}, `;
}
}
// Remove trailing comma and space, if present
if (argsString.endsWith(', ')) {
argsString = argsString.slice(0, -2);
}
return argsString;
};
@@ -1,21 +0,0 @@
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
export const getFieldAliases = (fields: FieldMetadata[]) => {
const fieldAliases = fields.reduce((acc, column) => {
const values = Object.values(column.targetColumnMap);
if (values.length === 1) {
return {
...acc,
[column.name]: values[0],
};
} else {
return {
...acc,
[values[0]]: values[0],
};
}
}, {});
return fieldAliases;
};