feat: conditional schema based on column map instead of column field (#1978)

* feat: wip conditional schema based on column map instead of column field

* feat: conditionalSchema columnMap and singular plural

* fix: remove uuid fix

* feat: add name and label (singular/plural) drop old tableColumnName
This commit is contained in:
Jérémy M
2023-10-12 18:28:27 +02:00
committed by GitHub
parent 8fbad7d3ba
commit 4e993316a6
44 changed files with 1577 additions and 311 deletions
@@ -0,0 +1,69 @@
import {
FieldMetadata,
FieldMetadataTargetColumnMap,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { convertArguments } from 'src/tenant/entity-resolver/utils/convert-arguments.util';
describe('convertArguments', () => {
let fields;
beforeEach(() => {
fields = [
{
nameSingular: 'firstName',
targetColumnMap: {
value: 'column_1randomFirstNameKey',
} as FieldMetadataTargetColumnMap,
type: 'text',
},
{
nameSingular: 'age',
targetColumnMap: {
value: 'column_randomAgeKey',
} as FieldMetadataTargetColumnMap,
type: 'text',
},
{
nameSingular: 'website',
targetColumnMap: {
link: 'column_randomLinkKey',
text: 'column_randomTex7Key',
} as FieldMetadataTargetColumnMap,
type: 'url',
},
] as FieldMetadata[];
});
test('should handle non-array arguments', () => {
const args = { firstName: 'John', age: 30 };
const expected = {
column_1randomFirstNameKey: 'John',
column_randomAgeKey: 30,
};
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle array arguments', () => {
const args = [{ firstName: 'John' }, { firstName: 'Jane' }];
const expected = [
{ column_1randomFirstNameKey: 'John' },
{ column_1randomFirstNameKey: 'Jane' },
];
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should handle nested object arguments', () => {
const args = { website: { link: 'https://www.google.fr', text: 'google' } };
const expected = {
column_randomLinkKey: 'https://www.google.fr',
column_randomTex7Key: 'google',
};
expect(convertArguments(args, fields)).toEqual(expected);
});
test('should ignore fields not in the field metadata', () => {
const args = { firstName: 'John', lastName: 'Doe' };
const expected = { column_1randomFirstNameKey: 'John', lastName: 'Doe' };
expect(convertArguments(args, fields)).toEqual(expected);
});
});
@@ -0,0 +1,99 @@
import {
FieldMetadata,
FieldMetadataTargetColumnMap,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { convertFieldsToGraphQL } from 'src/tenant/entity-resolver/utils/convert-fields-to-graphql.util';
const normalizeWhitespace = (str) => str.replace(/\s+/g, ' ').trim();
describe('convertFieldsToGraphQL', () => {
let fields;
beforeEach(() => {
fields = [
{
nameSingular: 'simpleField',
targetColumnMap: {
value: 'column_RANDOMSTRING1',
} as FieldMetadataTargetColumnMap,
},
{
nameSingular: 'complexField',
targetColumnMap: {
link: 'column_RANDOMSTRING2',
text: 'column_RANDOMSTRING3',
} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
});
test('should handle simple fields correctly', () => {
const select = { simpleField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'simpleField: column_RANDOMSTRING1\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle complex fields with multiple values correctly', () => {
const select = { complexField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = `
___complexField_link: column_RANDOMSTRING2
___complexField_text: column_RANDOMSTRING3
`;
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle fields not in the field metadata correctly', () => {
const select = { unknownField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'unknownField\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle nested object fields correctly', () => {
const select = { parentField: { childField: true } };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'parentField {\nchildField\n}\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle nested selections with multiple levels correctly', () => {
const select = {
level1: {
level2: {
simpleField: true,
},
},
};
const result = convertFieldsToGraphQL(select, fields);
const expected =
'level1 {\nlevel2 {\nsimpleField: column_RANDOMSTRING1\n}\n}\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should handle empty targetColumnMap gracefully', () => {
const emptyField = {
nameSingular: 'emptyField',
targetColumnMap: {},
} as FieldMetadata;
fields.push(emptyField);
const select = { emptyField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = 'emptyField\n';
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
test('should use formatted targetColumnMap values with unique random parts', () => {
const select = { simpleField: true, complexField: true };
const result = convertFieldsToGraphQL(select, fields);
const expected = `
simpleField: column_RANDOMSTRING1
___complexField_link: column_RANDOMSTRING2
___complexField_text: column_RANDOMSTRING3
`;
expect(normalizeWhitespace(result)).toBe(normalizeWhitespace(expected));
});
});
@@ -0,0 +1,58 @@
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 = [
{
nameSingular: 'singleValueField',
namePlural: 'singleValueFields',
targetColumnMap: {
value: 'column_singleValue',
} as FieldMetadataTargetColumnMap,
},
{
nameSingular: 'multipleValuesField',
namePlural: 'multipleValuesFields',
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,
{
nameSingular: 'emptyField',
namePlural: 'emptyFields',
targetColumnMap: {} as FieldMetadataTargetColumnMap,
},
] as FieldMetadata[];
const aliases = getFieldAliases(fieldsWithEmptyMap);
expect(aliases).not.toHaveProperty('emptyField');
});
});
@@ -0,0 +1,105 @@
import {
isSpecialKey,
handleSpecialKey,
parseResult,
} from 'src/tenant/entity-resolver/utils/parse-result.util';
describe('isSpecialKey', () => {
test('should return true if the key starts with "___"', () => {
expect(isSpecialKey('___specialKey')).toBe(true);
});
test('should return false if the key does not start with "___"', () => {
expect(isSpecialKey('normalKey')).toBe(false);
});
});
describe('handleSpecialKey', () => {
let result;
beforeEach(() => {
result = {};
});
test('should correctly process a special key and add it to the result object', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
expect(result).toEqual({
complexField: {
link: 'value1',
},
});
});
test('should add values under the same newKey if called multiple times', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
handleSpecialKey(result, '___complexField_text', 'value2');
expect(result).toEqual({
complexField: {
link: 'value1',
text: 'value2',
},
});
});
test('should not create a new field if the special key is not correctly formed', () => {
handleSpecialKey(result, '___complexField', 'value1');
expect(result).toEqual({});
});
});
describe('parseResult', () => {
test('should recursively parse an object and handle special keys', () => {
const obj = {
normalField: 'value1',
___specialField_part1: 'value2',
nested: {
___specialFieldNested_part2: 'value3',
},
};
const expectedResult = {
normalField: 'value1',
specialField: {
part1: 'value2',
},
nested: {
specialFieldNested: {
part2: 'value3',
},
},
};
expect(parseResult(obj)).toEqual(expectedResult);
});
test('should handle arrays and parse each element', () => {
const objArray = [
{
___specialField_part1: 'value1',
},
{
___specialField_part2: 'value2',
},
];
const expectedResult = [
{
specialField: {
part1: 'value1',
},
},
{
specialField: {
part2: 'value2',
},
},
];
expect(parseResult(objArray)).toEqual(expectedResult);
});
test('should return the original value if it is not an object or array', () => {
expect(parseResult('stringValue')).toBe('stringValue');
expect(parseResult(12345)).toBe(12345);
});
});
@@ -0,0 +1,53 @@
import { stringifyWithoutKeyQuote } from 'src/tenant/entity-resolver/utils/stringify-without-key-quote.util';
describe('stringifyWithoutKeyQuote', () => {
test('should stringify object correctly without quotes around keys', () => {
const obj = { name: 'John', age: 30, isAdmin: false };
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{name:"John",age:30,isAdmin:false}');
});
test('should handle nested objects', () => {
const obj = {
name: 'John',
age: 30,
address: { city: 'New York', zipCode: 10001 },
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,address:{city:"New York",zipCode:10001}}',
);
});
test('should handle arrays', () => {
const obj = {
name: 'John',
age: 30,
hobbies: ['reading', 'movies', 'hiking'],
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,hobbies:["reading","movies","hiking"]}',
);
});
test('should handle empty objects', () => {
const obj = {};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{}');
});
test('should handle numbers, strings, and booleans', () => {
const num = 10;
const str = 'Hello';
const bool = false;
expect(stringifyWithoutKeyQuote(num)).toBe('10');
expect(stringifyWithoutKeyQuote(str)).toBe('"Hello"');
expect(stringifyWithoutKeyQuote(bool)).toBe('false');
});
test('should handle null and undefined', () => {
expect(stringifyWithoutKeyQuote(null)).toBe('null');
expect(stringifyWithoutKeyQuote(undefined)).toBe(undefined);
});
});
@@ -0,0 +1,38 @@
import isEmpty from 'lodash.isempty';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
export const convertArguments = (args: any, fields: FieldMetadata[]): any => {
const fieldsMap = new Map(
// TODO: Handle plural for fields when we add relations
fields.map((metadata) => [metadata.nameSingular, metadata]),
);
if (Array.isArray(args)) {
return args.map((arg) => convertArguments(arg, fields));
}
const newArgs = {};
for (const [key, value] of Object.entries(args)) {
if (fieldsMap.has(key)) {
const fieldMetadata = fieldsMap.get(key)!;
if (typeof value === 'object' && value !== null && !isEmpty(value)) {
for (const [subKey, subValue] of Object.entries(value)) {
if (fieldMetadata.targetColumnMap[subKey]) {
newArgs[fieldMetadata.targetColumnMap[subKey]] = subValue;
}
}
} else {
if (fieldMetadata.targetColumnMap.value) {
newArgs[fieldMetadata.targetColumnMap.value] = value;
}
}
} else {
newArgs[key] = value;
}
}
return newArgs;
};
@@ -1,21 +1,55 @@
import isEmpty from 'lodash.isempty';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
export const convertFieldsToGraphQL = (
fields: any,
fieldAliases: Record<string, string>,
select: any,
fields: FieldMetadata[],
acc = '',
) => {
for (const [key, value] of Object.entries(fields)) {
if (value && !isEmpty(value)) {
const fieldsMap = new Map(
// TODO: Handle plural for fields when we add relations
fields.map((metadata) => [metadata.nameSingular, metadata]),
);
for (const [key, value] of Object.entries(select)) {
let fieldAlias = key;
if (fieldsMap.has(key)) {
const metadata = fieldsMap.get(key)!;
const entries = Object.entries(metadata.targetColumnMap);
if (entries.length > 0) {
// If there is only one value, use it as the alias
if (entries.length === 1) {
const alias = entries[0][1];
fieldAlias = `${key}: ${alias}`;
} else {
// Otherwise it means it's a special type with multiple values, so we need fetch all fields
fieldAlias = `
${entries
.map(
([key, value]) => `___${metadata.nameSingular}_${key}: ${value}`,
)
.join('\n')}
`;
}
}
}
// Recurse if value is a nested object, otherwise append field or alias
if (
!fieldsMap.has(key) &&
value &&
typeof value === 'object' &&
!isEmpty(value)
) {
acc += `${key} {\n`;
acc = convertFieldsToGraphQL(value, fieldAliases, acc);
acc = convertFieldsToGraphQL(value, fields, acc); // recursive call with updated accumulator
acc += `}\n`;
} else {
if (fieldAliases[key]) {
acc += `${key}: ${fieldAliases[key]}\n`;
} else {
acc += `${key}\n`;
}
acc += `${fieldAlias}\n`;
}
}
@@ -0,0 +1,22 @@
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,
// TODO: Handle plural for fields when we add relations
[column.nameSingular]: values[0],
};
} else {
return {
...acc,
[values[0]]: values[0],
};
}
}, {});
return fieldAliases;
};
@@ -0,0 +1,51 @@
export const isSpecialKey = (key: string): boolean => {
return key.startsWith('___');
};
export const handleSpecialKey = (
result: any,
key: string,
value: any,
): void => {
const parts = key.split('_').filter((part) => part);
// If parts don't contain enough information, return without altering result
if (parts.length < 2) {
return;
}
const newKey = parts.slice(0, -1).join('');
const subKey = parts[parts.length - 1];
if (!result[newKey]) {
result[newKey] = {};
}
result[newKey][subKey] = value;
};
export const parseResult = (obj: any): any => {
if (obj === null || typeof obj !== 'object' || typeof obj === 'function') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => parseResult(item));
}
const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
result[key] = parseResult(obj[key]);
} else if (isSpecialKey(key)) {
handleSpecialKey(result, key, obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
};
@@ -1,128 +0,0 @@
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import { v4 as uuidv4 } from 'uuid';
import { pascalCase } from 'src/utils/pascal-case';
import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util';
import { convertFieldsToGraphQL } from './convert-fields-to-graphql.util';
type Command = 'findMany' | 'findOne' | 'createMany' | 'updateOne';
type CommandArgs = {
findMany: null;
findOne: { id: string };
createMany: { data: any[] };
updateOne: { id: string; data: any };
};
export interface PGGraphQLQueryBuilderOptions {
entityName: string;
tableName: string;
info: GraphQLResolveInfo;
fieldAliases: Record<string, string>;
}
export class PGGraphQLQueryBuilder {
private options: PGGraphQLQueryBuilderOptions;
private command: Command;
private commandArgs: any;
constructor(options: PGGraphQLQueryBuilderOptions) {
this.options = options;
}
private getFields(): string {
const fields = graphqlFields(this.options.info);
return convertFieldsToGraphQL(fields, this.options.fieldAliases);
}
// Define command setters
findMany() {
this.command = 'findMany';
this.commandArgs = null;
return this;
}
findOne(args: CommandArgs['findOne']) {
this.command = 'findOne';
this.commandArgs = args;
return this;
}
createMany(args: CommandArgs['createMany']) {
this.command = 'createMany';
this.commandArgs = args;
return this;
}
updateOne(args: CommandArgs['updateOne']) {
this.command = 'updateOne';
this.commandArgs = args;
return this;
}
build() {
const { entityName, tableName } = this.options;
const fields = this.getFields();
switch (this.command) {
case 'findMany':
return `
query FindMany${pascalCase(entityName)} {
findMany${pascalCase(entityName)}: ${tableName}Collection {
${fields}
}
}
`;
case 'findOne':
return `
query FindOne${pascalCase(entityName)} {
findOne${pascalCase(
entityName,
)}: ${tableName}Collection(filter: { id: { eq: "${
this.commandArgs.id
}" } }) {
${fields}
}
}
`;
case 'createMany':
return `
mutation CreateMany${pascalCase(entityName)} {
createMany${pascalCase(
entityName,
)}: insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote(
this.commandArgs.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
)}) {
affectedCount
records {
${fields}
}
}
}
`;
case 'updateOne':
return `
mutation UpdateOne${pascalCase(entityName)} {
updateOne${pascalCase(
entityName,
)}: update${tableName}Collection(set: ${stringifyWithoutKeyQuote(
this.commandArgs.data,
)}, filter: { id: { eq: "${this.commandArgs.id}" } }) {
affectedCount
records {
${fields}
}
}
}
`;
default:
throw new Error('Invalid command');
}
}
}
@@ -1,94 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { pascalCase } from 'src/utils/pascal-case';
import { PGGraphQLQueryBuilder } from './pg-graphql-query-builder.util';
interface QueryRunnerOptions {
entityName: string;
tableName: string;
workspaceId: string;
info: GraphQLResolveInfo;
fieldAliases: Record<string, string>;
}
export class PGGraphQLQueryRunner {
private queryBuilder: PGGraphQLQueryBuilder;
private options: QueryRunnerOptions;
constructor(
private dataSourceService: DataSourceService,
options: QueryRunnerOptions,
) {
this.queryBuilder = new PGGraphQLQueryBuilder({
entityName: options.entityName,
tableName: options.tableName,
info: options.info,
fieldAliases: options.fieldAliases,
});
this.options = options;
}
private async execute(query: string, workspaceId: string): Promise<any> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
await workspaceDataSource?.query(`
SET search_path TO ${this.dataSourceService.getSchemaName(workspaceId)};
`);
return workspaceDataSource?.query(`
SELECT graphql.resolve($$
${query}
$$);
`);
}
private parseResults(graphqlResult: any, command: string): any {
const entityKey = `${command}${pascalCase(this.options.entityName)}`;
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
if (!result) {
throw new BadRequestException('Malformed result from GraphQL query');
}
return result;
}
async findMany(): Promise<any[]> {
const query = this.queryBuilder.findMany().build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'findMany');
}
async findOne(args: { id: string }): Promise<any> {
const query = this.queryBuilder.findOne(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'findOne');
}
async createMany(args: { data: any[] }): Promise<any[]> {
const query = this.queryBuilder.createMany(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'createMany')?.records;
}
async createOne(args: { data: any }): Promise<any> {
const records = await this.createMany({ data: [args.data] });
return records?.[0];
}
async updateOne(args: { id: string; data: any }): Promise<any> {
const query = this.queryBuilder.updateOne(args).build();
const result = await this.execute(query, this.options.workspaceId);
return this.parseResults(result, 'updateOne')?.records?.[0];
}
}
@@ -1,5 +1,6 @@
export const stringifyWithoutKeyQuote = (obj: any) => {
const jsonString = JSON.stringify(obj);
const jsonWithoutQuotes = jsonString.replace(/"(\w+)"\s*:/g, '$1:');
const jsonWithoutQuotes = jsonString?.replace(/"(\w+)"\s*:/g, '$1:');
return jsonWithoutQuotes;
};