import { SqlQuerySpec } from "@azure/cosmos"; import { assertNotEmpty } from "../../util/assert"; import { parse } from "./Expression"; // A type for json export type Json = null | boolean | number | string | JsonArray | JsonObject; export type JsonArray = Array; export interface JsonObject { [key: string]: Json; } /** * Condition for find. (e.g. filter / sort / offset / limit / fields) */ export interface Condition { filter?: JsonObject; sort?: [string, string]; offset?: number; limit?: number; fields?: string[]; } /** * Default find limit to protect db. override this by setting condition.limit explicitly. */ export const DEFAULT_LIMIT = 100; /** * User defined type guard for JsonObject * @param json */ export const isJsonObject = (json: Json | undefined): json is JsonObject => { return ( json !== undefined && json !== null && typeof json !== "boolean" && typeof json !== "number" && typeof json !== "string" && !Array.isArray(json) ); }; /** * convert condition to a querySpec (SQL and params) * @param condition */ export const toQuerySpec = (condition: Condition, countOnly?: boolean): SqlQuerySpec => { const { filter: _filter, sort = [], offset } = condition; let { limit } = condition; //TODO fields const fields = countOnly ? "COUNT(1)" : "*"; // filters const { queries, params } = _generateFilter(_filter); let queryText = [`SELECT ${fields} FROM root r`, queries.join(" AND ")] .filter((s) => s) .join(" WHERE "); // sort if (!countOnly && sort) { // r.name const order = sort.length ? " ORDER BY " + _formatKey(sort[0]) : ""; // ASC const order2 = sort.length > 1 ? ` ${sort[1]}` : ""; queryText += order + order2; } // offset and limit if (!countOnly) { //default limit is 100 to protect db limit = limit || DEFAULT_LIMIT; // https://docs.microsoft.com/en-us/azure/cosmos-db/how-to-sql-query#OffsetLimitClause const OFFSET = offset !== undefined ? ` OFFSET ${offset}` : " OFFSET 0"; const LIMIT = limit !== undefined ? ` LIMIT ${limit}` : ""; queryText = queryText + OFFSET + LIMIT; } const querySpec: SqlQuerySpec = { query: queryText, parameters: params, }; console.info("querySpec:", querySpec); return querySpec; }; export type Param = { name: string; value: Json; }; export type FilterResult = { queries: string[]; params: Param[]; }; /** * generate query text and params for filter part. * * e.g. {"count >": 10} -> {queries: ["count > @count_xxx"], params: [{name: "@count_xxx", value: 10}]} * * @param _filter */ export const _generateFilter = (_filter: JsonObject | undefined): FilterResult => { // undefined filter if (!_filter) { return { queries: [], params: [] }; } // normalize the filter const filter = _flatten(_filter); // process binary expressions {"count >": 10, "lastName !=": "Banks", "firstName CONTAINS"} let queries: string[] = []; let params: { name: string; value: Json }[] = []; Object.keys(filter).forEach((k) => { const exp = parse(k, filter[k]); const { queries: expQueries, params: expParams } = exp.toFilterResult(); queries = queries.concat(expQueries); params = params.concat(expParams); }); return { queries, params }; }; /** * flatten an object to a flat "obj1.key1.key2" format * * e.g. {obj1 : { key1 : { key2 : "test"}}} -> {obj1: {"key1.key2": "test"}} * * @param obj * @param result * @param keys */ export const _flatten = ( obj?: JsonObject, result: JsonObject = {}, keys: string[] = [], ): JsonObject => { if (!obj) { return {}; } Object.keys(obj).forEach((k) => { keys.push(k); const childObj = obj[k]; if (isJsonObject(childObj)) { _flatten(childObj, result, keys); } else if (childObj !== undefined) { result[keys.join(".")] = obj[k]; } keys.pop(); }); return result; }; /** * Instead of c.key, return c["key"] or c["key1"]["key2"] for query. In order for cosmosdb reserved words * * @param key filter's key * @param collectionAlias default to "c", can be "x" when using subquery for EXISTS or JOIN * @return formatted filter's key c["key1"]["key2"] */ export const _formatKey = (key: string, collectionAlias = "r"): string => { assertNotEmpty(collectionAlias, "collectionAlias"); if (!key) { // return collectionAlias when key is empty return collectionAlias; } return key .split(".") .reduce( (r, f) => { r.push(`["${f}"]`); return r; }, [collectionAlias], ) .join(""); };