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

feat: add nested entity filters #247

Merged
merged 3 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 87 additions & 60 deletions src/graphql/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ const GraphQLOrderDirection = new GraphQLEnumType({
}
});

type WhereResult = {
where: {
type: GraphQLInputObjectType;
};
orderByValues: Record<string, { value: string }>;
};

const cache = new Map<string, WhereResult>();

/**
* Controller for performing actions based on the graphql schema provided to its
* constructor. It exposes public functions to generate graphql or database
Expand Down Expand Up @@ -297,78 +306,96 @@ export class GqlEntityController {
type: GraphQLObjectType,
resolver: GraphQLFieldResolver<Parent, Context>
): GraphQLFieldConfig<Parent, Context> {
const whereInputConfig: GraphQLInputObjectTypeConfig = {
name: `Where${type.name}`,
fields: {}
};
const getWhereType = (type: GraphQLObjectType<any, any>): WhereResult => {
const name = `${type.name}_filter`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normalized filter name to match TheGraph.


const cachedValue = cache.get(name);
if (cachedValue) return cachedValue;

const orderByValues = {};
const whereInputConfig: GraphQLInputObjectTypeConfig = {
name,
fields: {}
};

this.getTypeFields(type).forEach(field => {
// all field types in a where input variable must be optional
// so we try to extract the non null type here.
let nonNullFieldType = getNonNullType(field.type);

if (nonNullFieldType instanceof GraphQLObjectType) {
const fields = type.getFields();
const idField = fields['id'];

if (
idField &&
idField.type instanceof GraphQLNonNull &&
idField.type.ofType instanceof GraphQLScalarType &&
['String', 'ID'].includes(idField.type.ofType.name)
) {
whereInputConfig.fields[`${field.name}_`] = getWhereType(nonNullFieldType).where;

nonNullFieldType = getNonNullType(idField.type);
}
}

const orderByValues = {};
// avoid setting up where filters for non scalar types
if (!isLeafType(nonNullFieldType)) {
return;
}

this.getTypeFields(type).forEach(field => {
// all field types in a where input variable must be optional
// so we try to extract the non null type here.
let nonNullFieldType = getNonNullType(field.type);
if (nonNullFieldType === GraphQLInt) {
whereInputConfig.fields[`${field.name}_gt`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_gte`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_lt`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_lte`] = { type: GraphQLInt };
}

if (nonNullFieldType instanceof GraphQLObjectType) {
const fields = type.getFields();
const idField = fields['id'];
if (
(nonNullFieldType instanceof GraphQLScalarType && nonNullFieldType.name === 'BigInt') ||
this.decimalTypes[nonNullFieldType.name]
) {
whereInputConfig.fields[`${field.name}_gt`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_gte`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_lt`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_lte`] = { type: nonNullFieldType };
}

if (
idField &&
idField.type instanceof GraphQLNonNull &&
idField.type.ofType instanceof GraphQLScalarType &&
['String', 'ID'].includes(idField.type.ofType.name)
nonNullFieldType === GraphQLString ||
(nonNullFieldType as GraphQLScalarType).name === 'Text'
) {
nonNullFieldType = getNonNullType(idField.type);
whereInputConfig.fields[`${field.name}_contains`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_not_contains`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_contains_nocase`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_not_contains_nocase`] = { type: GraphQLString };
}
}

// avoid setting up where filters for non scalar types
if (!isLeafType(nonNullFieldType)) {
return;
}
if ((nonNullFieldType as GraphQLScalarType).name !== 'Text') {
whereInputConfig.fields[`${field.name}`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_not`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_in`] = {
type: new GraphQLList(nonNullFieldType)
};
whereInputConfig.fields[`${field.name}_not_in`] = {
type: new GraphQLList(nonNullFieldType)
};
}

if (nonNullFieldType === GraphQLInt) {
whereInputConfig.fields[`${field.name}_gt`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_gte`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_lt`] = { type: GraphQLInt };
whereInputConfig.fields[`${field.name}_lte`] = { type: GraphQLInt };
}
orderByValues[field.name] = { value: field.name };
});

if (
(nonNullFieldType instanceof GraphQLScalarType && nonNullFieldType.name === 'BigInt') ||
this.decimalTypes[nonNullFieldType.name]
) {
whereInputConfig.fields[`${field.name}_gt`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_gte`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_lt`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_lte`] = { type: nonNullFieldType };
}
const result = {
where: { type: new GraphQLInputObjectType(whereInputConfig) },
orderByValues
};

if (
nonNullFieldType === GraphQLString ||
(nonNullFieldType as GraphQLScalarType).name === 'Text'
) {
whereInputConfig.fields[`${field.name}_contains`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_not_contains`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_contains_nocase`] = { type: GraphQLString };
whereInputConfig.fields[`${field.name}_not_contains_nocase`] = { type: GraphQLString };
}
cache.set(name, result);

if ((nonNullFieldType as GraphQLScalarType).name !== 'Text') {
whereInputConfig.fields[`${field.name}`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_not`] = { type: nonNullFieldType };
whereInputConfig.fields[`${field.name}_in`] = {
type: new GraphQLList(nonNullFieldType)
};
whereInputConfig.fields[`${field.name}_not_in`] = {
type: new GraphQLList(nonNullFieldType)
};
}
return result;
};

// add fields to orderBy enum
orderByValues[field.name] = { value: field.name };
});
const { where, orderByValues } = getWhereType(type);

const OrderByEnum = new GraphQLEnumType({
name: `OrderBy${type.name}Fields`,
Expand All @@ -390,7 +417,7 @@ export class GqlEntityController {
orderDirection: {
type: GraphQLOrderDirection
},
where: { type: new GraphQLInputObjectType(whereInputConfig) }
where
},
resolve: resolver
};
Expand Down
93 changes: 74 additions & 19 deletions src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
GraphQLObjectType,
GraphQLResolveInfo,
GraphQLScalarType,
isListType
isListType,
isScalarType
} from 'graphql';
import {
parseResolveInfo,
Expand Down Expand Up @@ -38,38 +39,64 @@ export async function queryMulti(parent, args, context: ResolverContext, info) {

const tableName = getTableName(returnType.name.toLowerCase());

let query = knex.select('*').from(tableName);
const nestedEntitiesMappings = {} as Record<string, Record<string, string>>;

if (args.where) {
Object.entries(args.where).map((w: [string, any]) => {
// TODO: we could generate args.where as objects { name, column, operator, value }
let query = knex.select(`${tableName}.*`).from(tableName);
const handleWhere = (query: Knex.QueryBuilder, prefix: string, where: Record<string, any>) => {
Object.entries(where).map((w: [string, any]) => {
// TODO: we could generate where as objects { name, column, operator, value }
// so we don't have to cut it there
if (w[0].endsWith('_not')) {
query = query.where(w[0].slice(0, -4), '!=', w[1]);
query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '!=', w[1]);
} else if (w[0].endsWith('_gt')) {
query = query.where(w[0].slice(0, -3), '>', w[1]);
query = query.where(`${prefix}.${w[0].slice(0, -3)}`, '>', w[1]);
} else if (w[0].endsWith('_gte')) {
query = query.where(w[0].slice(0, -4), '>=', w[1]);
query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '>=', w[1]);
} else if (w[0].endsWith('_lt')) {
query = query.where(w[0].slice(0, -3), '<', w[1]);
query = query.where(`${prefix}.${w[0].slice(0, -3)}`, '<', w[1]);
} else if (w[0].endsWith('_lte')) {
query = query.where(w[0].slice(0, -4), '<=', w[1]);
query = query.where(`${prefix}.${w[0].slice(0, -4)}`, '<=', w[1]);
} else if (w[0].endsWith('_not_contains')) {
query = query.not.whereLike(w[0].slice(0, -13), `%${w[1]}%`);
query = query.not.whereLike(`${prefix}.${w[0].slice(0, -13)}`, `%${w[1]}%`);
} else if (w[0].endsWith('_not_contains_nocase')) {
query = query.not.whereILike(w[0].slice(0, -20), `%${w[1]}%`);
query = query.not.whereILike(`${prefix}.${w[0].slice(0, -20)}`, `%${w[1]}%`);
} else if (w[0].endsWith('_contains')) {
query = query.whereLike(w[0].slice(0, -9), `%${w[1]}%`);
query = query.whereLike(`${prefix}.${w[0].slice(0, -9)}`, `%${w[1]}%`);
} else if (w[0].endsWith('_contains_nocase')) {
query = query.whereILike(w[0].slice(0, -16), `%${w[1]}%`);
query = query.whereILike(`${prefix}.${w[0].slice(0, -16)}`, `%${w[1]}%`);
} else if (w[0].endsWith('_not_in')) {
query = query.not.whereIn(w[0].slice(0, -7), w[1]);
query = query.not.whereIn(`${prefix}.${w[0].slice(0, -7)}`, w[1]);
} else if (w[0].endsWith('_in')) {
query = query.whereIn(w[0].slice(0, -3), w[1] as any);
query = query.whereIn(`${prefix}.${w[0].slice(0, -3)}`, w[1]);
} else if (typeof w[1] === 'object' && w[0].endsWith('_')) {
const fieldName = w[0].slice(0, -1);
const nestedReturnType = returnType.getFields()[fieldName].type as GraphQLObjectType;
const nestedTableName = getTableName(nestedReturnType.name.toLowerCase());

const fields = Object.values(nestedReturnType.getFields())
.filter(field => isScalarType(getNonNullType(field.type)))
.map(field => field.name);

nestedEntitiesMappings[fieldName] = {
[`${fieldName}.id`]: `${nestedTableName}.id`,
...Object.fromEntries(
fields.map(field => [`${fieldName}.${field}`, `${nestedTableName}.${field}`])
)
};

query = query
.columns(nestedEntitiesMappings[fieldName])
.innerJoin(nestedTableName, `${tableName}.${fieldName}`, '=', `${nestedTableName}.id`);

handleWhere(query, nestedTableName, w[1]);
} else {
query = query.where(w[0], w[1]);
query = query.where(`${prefix}.${w[0]}`, w[1]);
}
});
};

if (args.where) {
handleWhere(query, tableName, args.where);
}

if (args.orderBy) {
Expand All @@ -80,7 +107,28 @@ export async function queryMulti(parent, args, context: ResolverContext, info) {
log.debug({ sql: query.toQuery(), args }, 'executing multi query');

const result = await query;
return result.map(item => formatItem(item, jsonFields));
return result.map(item => {
const nested = Object.fromEntries(
Object.entries(nestedEntitiesMappings).map(([fieldName, mapping]) => {
return [
fieldName,
Object.fromEntries(
Object.entries(mapping).map(([to, from]) => {
const exploded = from.split('.');
const key = exploded[exploded.length - 1];

return [key, item[to]];
})
)
];
})
);

return {
...formatItem(item, jsonFields),
...nested
};
});
}

export async function querySingle(
Expand All @@ -92,7 +140,14 @@ export async function querySingle(
const returnType = getNonNullType(info.returnType) as GraphQLObjectType;
const jsonFields = getJsonFields(returnType);

const id = parent?.[info.fieldName] || args.id;
const currentValue = parent?.[info.fieldName];

const alreadyResolvedInParent = typeof currentValue === 'object';
if (alreadyResolvedInParent) {
return formatItem(currentValue, jsonFields);
}

const id = currentValue || args.id;

const parsed = parseResolveInfo(info);
if (parsed) {
Expand Down
4 changes: 2 additions & 2 deletions test/unit/graphql/__snapshots__/controller.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ create index \`votes_big_decimal_index\` on \`votes\` (\`big_decimal\`)"
exports[`GqlEntityController generateQueryFields should work 1`] = `
"type Query {
vote(id: Int!): Vote
votes(first: Int, skip: Int, orderBy: OrderByVoteFields, orderDirection: OrderDirection, where: WhereVote): [Vote]
votes(first: Int, skip: Int, orderBy: OrderByVoteFields, orderDirection: OrderDirection, where: Vote_filter): [Vote]
}

type Vote {
Expand All @@ -38,7 +38,7 @@ enum OrderDirection {
desc
}

input WhereVote {
input Vote_filter {
id_gt: Int
id_gte: Int
id_lt: Int
Expand Down