From 546af9263877197bcfaad8a7d711d6f92bed12b4 Mon Sep 17 00:00:00 2001 From: prathamesh0 <42446521+prathamesh0@users.noreply.github.com> Date: Mon, 6 Nov 2023 11:17:54 +0530 Subject: [PATCH] Support logical filter operations in plural GQL queries (#449) * Handle logical operators in where input * Handle logical filter operators while building the database query * Refactor code to build where clause for database query --- packages/graph-node/src/watcher.ts | 43 ++-- packages/util/src/database.ts | 302 ++++++++++++++++++++--------- 2 files changed, 232 insertions(+), 113 deletions(-) diff --git a/packages/graph-node/src/watcher.ts b/packages/graph-node/src/watcher.ts index 6aa81830..bfc86945 100644 --- a/packages/graph-node/src/watcher.ts +++ b/packages/graph-node/src/watcher.ts @@ -472,23 +472,28 @@ export class GraphWatcher { return acc; } + if (['and', 'or'].includes(fieldWithSuffix)) { + assert(Array.isArray(value)); + + // Parse all the comibations given in the array + acc[fieldWithSuffix] = value.map(w => { + return this._buildFilter(w); + }); + + return acc; + } + const [field, ...suffix] = fieldWithSuffix.split('_'); if (!acc[field]) { acc[field] = []; } - const filter: Filter = { - value, - not: false, - operator: 'equals' - }; + let op = suffix.shift(); - let operator = suffix.shift(); - - // If the operator is "" (different from undefined), it means it's a nested filter on a relation field - if (operator === '') { - acc[field].push({ + // If op is "" (different from undefined), it means it's a nested filter on a relation field + if (op === '') { + (acc[field] as Filter[]).push({ // Parse nested filter value value: this._buildFilter(value), not: false, @@ -498,21 +503,27 @@ export class GraphWatcher { return acc; } - if (operator === 'not') { + const filter: Filter = { + value, + not: false, + operator: 'equals' + }; + + if (op === 'not') { filter.not = true; - operator = suffix.shift(); + op = suffix.shift(); } - if (operator) { - filter.operator = operator as keyof typeof OPERATOR_MAP; + if (op) { + filter.operator = op as keyof typeof OPERATOR_MAP; } // If filter field ends with "nocase", use case insensitive version of the operator if (suffix[suffix.length - 1] === 'nocase') { - filter.operator = `${operator}_nocase` as keyof typeof OPERATOR_MAP; + filter.operator = `${op}_nocase` as keyof typeof OPERATOR_MAP; } - acc[field].push(filter); + (acc[field] as Filter[]).push(filter); return acc; }, {}); diff --git a/packages/util/src/database.ts b/packages/util/src/database.ts index 497ffb35..3ea84f65 100644 --- a/packages/util/src/database.ts +++ b/packages/util/src/database.ts @@ -17,7 +17,8 @@ import { ObjectLiteral, QueryRunner, Repository, - SelectQueryBuilder + SelectQueryBuilder, + WhereExpressionBuilder } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; import _ from 'lodash'; @@ -75,7 +76,9 @@ export interface Filter { } export interface Where { - [key: string]: Filter[]; + // Where[] in case of and / or operators + // Filter[] in others + [key: string]: Filter[] | Where[]; } export type Relation = string | { property: string, alias: string } @@ -833,125 +836,230 @@ export class Database { where: Readonly = {}, relations: Readonly<{ [key: string]: any }> = {}, block: Readonly = {}, - alias?: string + alias?: string, + variableSuffix = '' ): SelectQueryBuilder { if (!alias) { alias = selectQueryBuilder.alias; } - Object.entries(where).forEach(([field, filters]) => { - const columnMetadata = repo.metadata.findColumnWithPropertyName(field); + return selectQueryBuilder.andWhere(this.buildWhereClause( + repo, + where, + relations, + block, + alias, + variableSuffix + )); + } - filters.forEach((filter, index) => { - let { not, operator, value } = filter; - const relation = relations[field]; - - // Handle nested relation filter (only one level deep supported) - if (operator === 'nested' && relation) { - const relationRepo = this.conn.getRepository(relation.entity); - const relationTableName = relationRepo.metadata.tableName; - let relationSubQuery: SelectQueryBuilder = relationRepo.createQueryBuilder(relationTableName, repo.queryRunner) - .select('1'); - - if (relation.isDerived) { - const derivationField = relation.field; - relationSubQuery = relationSubQuery.where(`${relationTableName}.${derivationField} = ${alias}.id`); - } else { - // Column has to exist for non-derived fields - assert(columnMetadata); - - if (relation.isArray) { - relationSubQuery = relationSubQuery.where(`${relationTableName}.id = ANY(${alias}.${columnMetadata.databaseName})`); - } else { - relationSubQuery = relationSubQuery.where(`${relationTableName}.id = ${alias}.${columnMetadata.databaseName}`); - } - } - - // canonicalBlockHashes take precedence over block number if provided - if (block.canonicalBlockHashes) { - relationSubQuery = relationSubQuery - .andWhere(new Brackets(qb => { - qb.where(`${relationTableName}.block_hash IN (:...relationBlockHashes)`, { relationBlockHashes: block.canonicalBlockHashes }) - .orWhere(`${relationTableName}.block_number <= :relationCanonicalBlockNumber`, { relationCanonicalBlockNumber: block.number }); - })); - } else if (block.number) { - relationSubQuery = relationSubQuery.andWhere(`${relationTableName}.block_number <= :blockNumber`, { blockNumber: block.number }); - } - - relationSubQuery = this.buildQuery(relationRepo, relationSubQuery, value); - selectQueryBuilder = selectQueryBuilder - .andWhere(`EXISTS (${relationSubQuery.getQuery()})`) - .setParameters(relationSubQuery.getParameters()); + buildWhereClause ( + repo: Repository, + where: Readonly = {}, + relations: Readonly<{ [key: string]: any }> = {}, + block: Readonly = {}, + alias: string, + variableSuffix?: string + ): Brackets { + // Chain resulting where clauses in brackets + return new Brackets(whereBuilder => { + Object.entries(where).forEach(([field, filters], whereIndex) => { + // Handle and / or operators + if (field === 'and' || field === 'or') { + this.buildWhereClauseWithLogicalFilter( + repo, + whereBuilder, + filters as Where[], + field, + relations, + block, + alias, + `${variableSuffix}_${whereIndex}` + ); return; } - // Column has to exist if it's not a nested filter - assert(columnMetadata); - const columnIsArray = columnMetadata.isArray; + filters.forEach((filter, fieldIndex) => { + let { not, operator, value } = filter as Filter; + const relation = relations[field]; - // Form the where clause. - assert(operator); - let whereClause = ''; + // Handle nested relation filter (only one level deep supported) + if (operator === 'nested' && relation) { + this.buildWhereClauseWithNestedFilter( + repo, + whereBuilder, + value, + field, + relation, + block, + alias, + `${variableSuffix}_${whereIndex}` + ); - // In case of array field having contains, NOT comes before the field name - // Disregards nocase - if (columnIsArray && operator.includes('contains')) { - if (not) { - whereClause += 'NOT '; - whereClause += `"${alias}"."${columnMetadata.databaseName}" `; - whereClause += '&& '; + return; + } + + // Column has to exist if it's not a logical operator or a nested filter + const columnMetadata = repo.metadata.findColumnWithPropertyName(field); + assert(columnMetadata); + const columnIsArray = columnMetadata.isArray; + + // Form the where clause. + assert(operator); + let whereClause = ''; + + // In case of array field having contains, NOT comes before the field name + // Disregards nocase + if (columnIsArray && operator.includes('contains')) { + if (not) { + whereClause += 'NOT '; + whereClause += `"${alias}"."${columnMetadata.databaseName}" `; + whereClause += '&& '; + } else { + whereClause += `"${alias}"."${columnMetadata.databaseName}" `; + whereClause += '@> '; + } } else { whereClause += `"${alias}"."${columnMetadata.databaseName}" `; - whereClause += '@> '; - } - } else { - whereClause += `"${alias}"."${columnMetadata.databaseName}" `; - if (not) { - if (operator === 'equals') { - whereClause += '!'; - } else { - whereClause += 'NOT '; + if (not) { + if (operator === 'equals') { + whereClause += '!'; + } else { + whereClause += 'NOT '; + } + } + + whereClause += `${OPERATOR_MAP[operator]} `; + } + + value = this._transformBigValues(value); + if (operator === 'in') { + whereClause += '(:...'; + } else { + whereClause += ':'; + } + + const variableName = `${field}${variableSuffix}_${whereIndex}_${fieldIndex}`; + whereClause += variableName; + + if (operator === 'in') { + whereClause += ')'; + + if (!value.length) { + whereClause = 'FALSE'; } } - whereClause += `${OPERATOR_MAP[operator]} `; - } + if (!columnIsArray) { + if (['contains', 'contains_nocase', 'ends', 'ends_nocase'].some(el => el === operator)) { + value = `%${value}`; + } - value = this._transformBigValues(value); - if (operator === 'in') { - whereClause += '(:...'; - } else { - whereClause += ':'; - } - - const variableName = `${field}${index}`; - whereClause += variableName; - - if (operator === 'in') { - whereClause += ')'; - - if (!value.length) { - whereClause = 'FALSE'; - } - } - - if (!columnIsArray) { - if (['contains', 'contains_nocase', 'ends', 'ends_nocase'].some(el => el === operator)) { - value = `%${value}`; + if (['contains', 'contains_nocase', 'starts', 'starts_nocase'].some(el => el === operator)) { + value += '%'; + } } - if (['contains', 'contains_nocase', 'starts', 'starts_nocase'].some(el => el === operator)) { - value += '%'; - } - } - - selectQueryBuilder = selectQueryBuilder.andWhere(whereClause, { [variableName]: value }); + whereBuilder.andWhere(whereClause, { [variableName]: value }); + }); }); }); + } - return selectQueryBuilder; + buildWhereClauseWithLogicalFilter ( + repo: Repository, + whereBuilder: WhereExpressionBuilder, + wheres: ReadonlyArray = [], + operator: 'and' | 'or', + relations: Readonly<{ [key: string]: any }> = {}, + block: Readonly = {}, + alias: string, + variableSuffix?: string + ): void { + switch (operator) { + case 'and': { + whereBuilder.andWhere(new Brackets(andWhereBuilder => { + // Chain all where clauses using AND + wheres.forEach(w => { + andWhereBuilder.andWhere(this.buildWhereClause( + repo, + w, + relations, + block, + alias, + variableSuffix + )); + }); + })); + + break; + } + + case 'or': { + whereBuilder.andWhere(new Brackets(orWhereBuilder => { + // Chain all where clauses using OR + wheres.forEach(w => { + orWhereBuilder.orWhere(this.buildWhereClause( + repo, + w, + relations, + block, + alias, + variableSuffix + )); + }); + })); + + break; + } + } + } + + buildWhereClauseWithNestedFilter ( + repo: Repository, + whereBuilder: WhereExpressionBuilder, + where: Readonly = {}, + field: string, + relation: Readonly = {}, + block: Readonly = {}, + alias: string, + variableSuffix?: string + ): void { + const relationRepo = this.conn.getRepository(relation.entity); + const relationTableName = relationRepo.metadata.tableName; + let relationSubQuery: SelectQueryBuilder = relationRepo.createQueryBuilder(relationTableName, repo.queryRunner) + .select('1'); + + if (relation.isDerived) { + const derivationField = relation.field; + relationSubQuery = relationSubQuery.where(`${relationTableName}.${derivationField} = ${alias}.id`); + } else { + // Column has to exist for non-derived fields + const columnMetadata = repo.metadata.findColumnWithPropertyName(field); + assert(columnMetadata); + + if (relation.isArray) { + relationSubQuery = relationSubQuery.where(`${relationTableName}.id = ANY(${alias}.${columnMetadata.databaseName})`); + } else { + relationSubQuery = relationSubQuery.where(`${relationTableName}.id = ${alias}.${columnMetadata.databaseName}`); + } + } + + // canonicalBlockHashes take precedence over block number if provided + if (block.canonicalBlockHashes) { + relationSubQuery = relationSubQuery + .andWhere(new Brackets(qb => { + qb.where(`${relationTableName}.block_hash IN (:...relationBlockHashes)`, { relationBlockHashes: block.canonicalBlockHashes }) + .orWhere(`${relationTableName}.block_number <= :relationCanonicalBlockNumber`, { relationCanonicalBlockNumber: block.number }); + })); + } else if (block.number) { + relationSubQuery = relationSubQuery.andWhere(`${relationTableName}.block_number <= :blockNumber`, { blockNumber: block.number }); + } + + relationSubQuery = this.buildQuery(relationRepo, relationSubQuery, where, {}, block, undefined, variableSuffix); + whereBuilder.andWhere(`EXISTS (${relationSubQuery.getQuery()})`, relationSubQuery.getParameters()); } orderQuery (