Typeorm dynamic query builder from structured object

Jake Lowen picture Jake Lowen · Jan 15, 2019 · Viewed 7.8k times · Source

For use in a graphql server I have defined a structured input type where you can specify a number of filter conditions very similar to how prisma works:

enter image description here

Which allows me to submit structured filters in a query like:

{
  users(
    where: {
      OR: [{ email: { starts_with: "ja" } }, { email: { ends_with: ".com" } }],
      AND: [{ email: { starts_with: "ja" } }, { email: { ends_with: ".com" } }],
      email: {contains: "lowe"}
    }
  ) {
    id
    email
  }
}

Inside my resolver I feed the args.where through a function to parse the structure and utilize TypeOrm's query builder to convert it to proper sql. The entirety of the function is:

import { Brackets } from "typeorm";

export const filterQuery = (query: any, where: any) => {
  if (!where) {
    return query;
  }

  Object.keys(where).forEach(key => {
    if (key === "OR") {
      where[key].map((queryArray: any) => {
        query.orWhere(new Brackets(qb => filterQuery(qb, queryArray)));
      });
    } else if (key === "AND") {
      where[key].map((queryArray: any) => {
        query.andWhere(new Brackets(qb => filterQuery(qb, queryArray)));
      });
    } else {
      const whereArgs = Object.entries(where);

      whereArgs.map(whereArg => {
        const [fieldName, filters] = whereArg;
        const ops = Object.entries(filters);

        ops.map(parameters => {
          const [operation, value] = parameters;

          switch (operation) {
            case "is": {
              query.andWhere(`${fieldName} = :isvalue`, { isvalue: value });
              break;
            }
            case "not": {
              query.andWhere(`${fieldName} != :notvalue`, { notvalue: value });
              break;
            }
            case "in": {
              query.andWhere(`${fieldName} IN :invalue`, { invalue: value });
              break;
            }
            case "not_in": {
              query.andWhere(`${fieldName} NOT IN :notinvalue`, {
                notinvalue: value
              });
              break;
            }
            case "lt": {
              query.andWhere(`${fieldName} < :ltvalue`, { ltvalue: value });
              break;
            }
            case "lte": {
              query.andWhere(`${fieldName} <= :ltevalue`, { ltevalue: value });
              break;
            }
            case "gt": {
              query.andWhere(`${fieldName} > :gtvalue`, { gtvalue: value });
              break;
            }
            case "gte": {
              query.andWhere(`${fieldName} >= :gtevalue`, { gtevalue: value });
              break;
            }
            case "contains": {
              query.andWhere(`${fieldName} ILIKE :convalue`, {
                convalue: `%${value}%`
              });
              break;
            }
            case "not_contains": {
              query.andWhere(`${fieldName} NOT ILIKE :notconvalue`, {
                notconvalue: `%${value}%`
              });
              break;
            }
            case "starts_with": {
              query
                .andWhere(`${fieldName} ILIKE :swvalue`)
                .setParameter("swvalue", `${value}%`);
              break;
            }
            case "not_starts_with": {
              query
                .andWhere(`${fieldName} NOT ILIKE :nswvalue`)
                .setParameter("nswvalue", `${value}%`);
              break;
            }
            case "ends_with": {
              query.andWhere(`${fieldName} ILIKE :ewvalue`, {
                ewvalue: `%${value}`
              });
              break;
            }
            case "not_ends_with": {
              query.andWhere(`${fieldName} ILIKE :newvalue`, {
                newvalue: `%${value}`
              });
              break;
            }
            default: {
              break;
            }
          }
        });
      });
    }
  });

  return query;
};

Which works (kinda) but does not nest the AND/OR queries like I would expect (and had previously got working in KNEX). The above function generates the SQL:

SELECT
  "user"."id" AS "user_id",
  "user"."name" AS "user_name",
  "user"."email" AS "user_email",
  "user"."loginToken" AS "user_loginToken",
  "user"."loginTokenExpiry" AS "user_loginTokenExpiry",
  "user"."active" AS "user_active",
  "user"."visible" AS "user_visible",
  "user"."isStaff" AS "user_isStaff",
  "user"."isBilling" AS "user_isBilling",
  "user"."createdAt" AS "user_createdAt",
  "user"."updatedAt" AS "user_updatedAt",
  "user"."version" AS "user_version"
FROM "user" "user"
WHERE (email ILIKE $1)
  AND (email ILIKE $2)
  OR (email ILIKE $3)
  OR (email ILIKE $4)
  AND email ILIKE $5
-- PARAMETERS: ["ja%","%.com","ja%","%.com","%lowe%"]

But I would expect to see something more like:

..... 
WHERE email ILIKE '%low%' 
AND (
    email ILIKE 'ja%' AND email ILIKE '%.com'
) AND (
    email ILIKE 'ja%' OR email ILIKE '%.com'
)

Forgive the nonsense, repetitive query. I'm just trying to illustrated the expected NESTED statements.

How can I force the AND/OR branches of my query builder function to properly nest like expected?

** Bonus points if someone can help me figure out the actual typescript typings here **

Answer

benawad picture benawad · Jan 15, 2019
  1. Split it up into 2 functions to make adding the types easier
  2. In your case statements you need to do orWhere or andWhere
  3. Instead of mapping over the brackets, lift it up one level
import { Brackets, WhereExpression, SelectQueryBuilder } from "typeorm";

interface FieldOptions {
  starts_with?: string;
  ends_with?: string;
  contains?: string;
}

interface Fields {
  email?: FieldOptions;
}

interface Where extends Fields {
  OR?: Fields[];
  AND?: Fields[];
}

const handleArgs = (
  query: WhereExpression,
  where: Where,
  andOr: "andWhere" | "orWhere"
) => {
  const whereArgs = Object.entries(where);

  whereArgs.map(whereArg => {
    const [fieldName, filters] = whereArg;
    const ops = Object.entries(filters);

    ops.map(parameters => {
      const [operation, value] = parameters;

      switch (operation) {
        case "is": {
          query[andOr](`${fieldName} = :isvalue`, { isvalue: value });
          break;
        }
        case "not": {
          query[andOr](`${fieldName} != :notvalue`, { notvalue: value });
          break;
        }
        case "in": {
          query[andOr](`${fieldName} IN :invalue`, { invalue: value });
          break;
        }
        case "not_in": {
          query[andOr](`${fieldName} NOT IN :notinvalue`, {
            notinvalue: value
          });
          break;
        }
        case "lt": {
          query[andOr](`${fieldName} < :ltvalue`, { ltvalue: value });
          break;
        }
        case "lte": {
          query[andOr](`${fieldName} <= :ltevalue`, { ltevalue: value });
          break;
        }
        case "gt": {
          query[andOr](`${fieldName} > :gtvalue`, { gtvalue: value });
          break;
        }
        case "gte": {
          query[andOr](`${fieldName} >= :gtevalue`, { gtevalue: value });
          break;
        }
        case "contains": {
          query[andOr](`${fieldName} ILIKE :convalue`, {
            convalue: `%${value}%`
          });
          break;
        }
        case "not_contains": {
          query[andOr](`${fieldName} NOT ILIKE :notconvalue`, {
            notconvalue: `%${value}%`
          });
          break;
        }
        case "starts_with": {
          query[andOr](`${fieldName} ILIKE :swvalue`, {
            swvalue: `${value}%`
          });
          break;
        }
        case "not_starts_with": {
          query[andOr](`${fieldName} NOT ILIKE :nswvalue`, {
            nswvalue: `${value}%`
          });
          break;
        }
        case "ends_with": {
          query[andOr](`${fieldName} ILIKE :ewvalue`, {
            ewvalue: `%${value}`
          });
          break;
        }
        case "not_ends_with": {
          query[andOr](`${fieldName} ILIKE :newvalue`, {
            newvalue: `%${value}`
          });
          break;
        }
        default: {
          break;
        }
      }
    });
  });

  return query;
};

export const filterQuery = <T>(query: SelectQueryBuilder<T>, where: Where) => {
  if (!where) {
    return query;
  }

  Object.keys(where).forEach(key => {
    if (key === "OR") {
      query.andWhere(
        new Brackets(qb =>
          where[key]!.map(queryArray => {
            handleArgs(qb, queryArray, "orWhere");
          })
        )
      );
    } else if (key === "AND") {
      query.andWhere(
        new Brackets(qb =>
          where[key]!.map(queryArray => {
            handleArgs(qb, queryArray, "andWhere");
          })
        )
      );
    }
  });

  return query;
};