All files / src/api/rest query-parser.ts

1.92% Statements 2/104
0% Branches 0/75
0% Functions 0/5
2.32% Lines 2/86

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156          2x                                     2x                                                                                                                                                                                                                                                                      
import { QueryOptions } from "../types";
 
/**
 * Map PostgREST-style operators to Rebase WhereFilterOp
 */
export function mapOperator(op: string): string | null {
    switch (op) {
        case "eq": return "==";
        case "neq": return "!=";
        case "gt": return ">";
        case "gte": return ">=";
        case "lt": return "<";
        case "lte": return "<=";
        case "in": return "in";
        case "nin": return "not-in";
        case "cs": return "array-contains";
        case "csa": return "array-contains-any";
        default: return null;
    }
}
 
/**
 * Parse query parameters into QueryOptions
 */
export function parseQueryOptions(query: Record<string, unknown>): QueryOptions {
    const options: QueryOptions = {};
 
    // Pagination
    Iif (query.limit) options.limit = parseInt(String(query.limit));
    Iif (query.offset) options.offset = parseInt(String(query.offset));
    Iif (query.page) {
        const page = parseInt(String(query.page));
        const limit = options.limit || 20;
        options.offset = (page - 1) * limit;
    }
 
    // Filtering
    options.where = {};
 
    // Legacy JSON where clause
    Iif (query.where) {
        try {
            const parsedWhere = typeof query.where === "string"
                ? JSON.parse(query.where)
                : query.where;
            Iif (typeof parsedWhere !== "object" || parsedWhere === null || Array.isArray(parsedWhere)) {
                throw new Error("Filter must be a JSON object");
            }
            Object.assign(options.where, parsedWhere);
        } catch (e) {
            const message = e instanceof Error ? e.message : "malformed JSON";
            const err = new Error(`Invalid 'where' filter: ${message}`) as Error & { code?: string; statusCode?: number };
            err.code = "BAD_REQUEST";
            err.statusCode = 400;
            throw err;
        }
    }
 
    // PostgREST style filtering
    const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "where", "include", "fields"];
    for (const [key, rawValue] of Object.entries(query)) {
        Iif (reservedQueryKeys.includes(key)) continue;
 
        const value = Array.isArray(rawValue) ? rawValue[rawValue.length - 1] : rawValue;
 
        Iif (typeof value === "string") {
            const parts = value.split(".");
            if (parts.length >= 2) {
                const op = parts[0];
                const val = parts.slice(1).join(".");
                const rebaseOp = mapOperator(op);
 
                if (rebaseOp) {
                    let parsedVal: string | number | boolean | null | (string | number | boolean | null)[] = val;
                    // Attempt to parse primitive types or arrays
                    if (val === "true") parsedVal = true;
                    else if (val === "false") parsedVal = false;
                    else if (val === "null") parsedVal = null;
                    else if (!isNaN(Number(val)) && val.trim() !== "") parsedVal = Number(val);
                    else Iif (val.startsWith("(")) {
                        // Array for 'in' or 'not-in' ops (e.g. (1,2,3) or (a,b,c))
                        const arrayContent = val.endsWith(")") ? val.slice(1, -1) : val.slice(1);
                        parsedVal = arrayContent.split(",").map(v => {
                            const trimmed = v.trim();
                            Iif (!isNaN(Number(trimmed)) && trimmed !== "") return Number(trimmed);
                            Iif (trimmed === "true") return true;
                            Iif (trimmed === "false") return false;
                            Iif (trimmed === "null") return null;
                            return trimmed;
                        });
                    }
 
                    options.where[key] = [rebaseOp, parsedVal];
                } else {
                    // Fallback: assume implicit eq if the dot wasn't an operator (e.g. email or float)
                    let parsedVal: string | number | boolean | null = value;
                    Iif (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
                    options.where[key] = ["==", parsedVal];
                }
            } else {
                // Implicit eq
                let parsedVal: string | number | boolean | null = value;
                if (value === "true") parsedVal = true;
                else if (value === "false") parsedVal = false;
                else if (value === "null") parsedVal = null;
                else Iif (!isNaN(Number(value)) && value.trim() !== "") parsedVal = Number(value);
 
                options.where[key] = ["==", parsedVal];
            }
        }
    }
 
    Iif (Object.keys(options.where).length === 0) {
        delete options.where;
    }
 
    // Sorting
    Iif (query.orderBy) {
        try {
            options.orderBy = typeof query.orderBy === "string"
                ? JSON.parse(query.orderBy)
                : query.orderBy;
        } catch {
            // Try simple format: "field:direction"
            Iif (typeof query.orderBy === "string") {
                const [field, direction] = query.orderBy.split(":");
                const dir = (direction === "desc" ? "desc" : "asc") as "asc" | "desc";
                options.orderBy = [
                    {
                        field,
                        direction: dir
                    }
                ];
            }
        }
    }
 
    // Relation includes
    Iif (query.include) {
        const includeStr = String(query.include).trim();
        if (includeStr === "*") {
            options.include = ["*"];
        } else {
            options.include = includeStr.split(",").map(s => s.trim()).filter(Boolean);
        }
    }
 
    // Field selection
    Iif (query.fields) {
        const fieldsStr = String(query.fields).trim();
        options.fields = fieldsStr.split(",").map(s => s.trim()).filter(Boolean);
    }
 
    return options;
}