UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

558 lines (504 loc) 19.9 kB
import { needsFieldQuoting } from "../client/builders/select-utils"; import { type Column, ColumnFunction, isColumn, isColumnFunction } from "./column"; /** * FilterExpression represents a filter condition that can be used in where() clauses. * Internal representation of operator expressions that get converted to OData filter syntax. */ export class FilterExpression { readonly operator: string; // biome-ignore lint/suspicious/noExplicitAny: Operands can be Column, FilterExpression, or any value type readonly operands: (Column | any | FilterExpression)[]; // biome-ignore lint/suspicious/noExplicitAny: Operands can be Column, FilterExpression, or any value type constructor(operator: string, operands: (Column | any | FilterExpression)[]) { this.operator = operator; this.operands = operands; } /** * Convert this expression to OData filter syntax. * @internal Used by QueryBuilder */ toODataFilter(useEntityIds?: boolean): string { switch (this.operator) { // Comparison operators case "eq": return this._binaryOp("eq", useEntityIds); case "ne": return this._binaryOp("ne", useEntityIds); case "gt": return this._binaryOp("gt", useEntityIds); case "gte": return this._binaryOp("ge", useEntityIds); case "lt": return this._binaryOp("lt", useEntityIds); case "lte": return this._binaryOp("le", useEntityIds); case "in": return this._inOp(useEntityIds); case "notIn": return this._notInOp(useEntityIds); // String operators case "contains": return this._functionOp("contains", useEntityIds); case "startsWith": return this._functionOp("startswith", useEntityIds); case "endsWith": return this._functionOp("endswith", useEntityIds); case "matchesPattern": return this._functionOp("matchesPattern", useEntityIds); // Null checks case "isNull": return this._isNullOp(useEntityIds); case "isNotNull": return this._isNotNullOp(useEntityIds); // Logical operators case "and": return this._logicalOp("and", useEntityIds); case "or": return this._logicalOp("or", useEntityIds); case "not": return this._notOp(useEntityIds); default: throw new Error(`Unknown operator: ${this.operator}`); } } private _binaryOp(op: string, useEntityIds?: boolean): string { const [left, right] = this.operands; // For binary ops, the column is typically the first operand and value is the second // But we also support column-to-column comparisons, so check both let columnForValue: typeof left | typeof right | undefined; if (isColumn(left) && !isColumn(right)) { columnForValue = left; } else if (isColumn(right) && !isColumn(left)) { columnForValue = right; } else { columnForValue = undefined; } const leftStr = this._operandToString(left, useEntityIds, columnForValue); const rightStr = this._operandToString(right, useEntityIds, columnForValue); return `${leftStr} ${op} ${rightStr}`; } private _functionOp(fnName: string, useEntityIds?: boolean): string { const [column, value] = this.operands; const columnInstance = isColumn(column) ? column : undefined; const columnStr = this._operandToString(column, useEntityIds); const valueStr = this._operandToString(value, useEntityIds, columnInstance); return `${fnName}(${columnStr}, ${valueStr})`; } private _inOp(useEntityIds?: boolean): string { const [column, values] = this.operands; const columnInstance = isColumn(column) ? column : undefined; const columnStr = this._operandToString(column, useEntityIds); // biome-ignore lint/suspicious/noExplicitAny: Dynamic array of values from user input const valuesStr = (values as any[]).map((v) => this._operandToString(v, useEntityIds, columnInstance)).join(", "); return `${columnStr} in (${valuesStr})`; } private _notInOp(useEntityIds?: boolean): string { const [column, values] = this.operands; const columnInstance = isColumn(column) ? column : undefined; const columnStr = this._operandToString(column, useEntityIds); // biome-ignore lint/suspicious/noExplicitAny: Dynamic array of values from user input const valuesStr = (values as any[]).map((v) => this._operandToString(v, useEntityIds, columnInstance)).join(", "); return `not (${columnStr} in (${valuesStr}))`; } private _isNullOp(useEntityIds?: boolean): string { const [column] = this.operands; const columnStr = this._operandToString(column, useEntityIds); return `${columnStr} eq null`; } private _isNotNullOp(useEntityIds?: boolean): string { const [column] = this.operands; const columnStr = this._operandToString(column, useEntityIds); return `${columnStr} ne null`; } private _logicalOp(op: string, useEntityIds?: boolean): string { const expressions = this.operands.map((expr) => { if (expr instanceof FilterExpression) { const innerExpr = expr.toODataFilter(useEntityIds); // Wrap in parens if it's a logical expression to ensure precedence if (expr.operator === "and" || expr.operator === "or") { return `(${innerExpr})`; } return innerExpr; } throw new Error("Logical operators require FilterExpression operands"); }); return expressions.join(` ${op} `); } private _notOp(useEntityIds?: boolean): string { const [expr] = this.operands; if (expr instanceof FilterExpression) { return `not (${expr.toODataFilter(useEntityIds)})`; } throw new Error("NOT operator requires a FilterExpression operand"); } private _formatTemporalValue(value: unknown, fieldType: "date" | "time" | "timestamp"): string { if (!(value instanceof Date)) { return String(value); } if (fieldType === "date") { return value.toISOString().slice(0, 10); } if (fieldType === "time") { return value.toISOString().slice(11, 19); } return value.toISOString(); } private _operandToString( // biome-ignore lint/suspicious/noExplicitAny: Operand can be Column, FilterExpression, or any value type operand: any, useEntityIds?: boolean, // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration column?: Column<any, any, any, any>, ): string { if (isColumnFunction(operand)) { return operand.toFilterString(useEntityIds); } if (isColumn(operand)) { const fieldIdentifier = operand.getFieldIdentifier(useEntityIds); // Quote field names in OData filters per FileMaker OData API requirements return needsFieldQuoting(fieldIdentifier) ? `"${fieldIdentifier}"` : fieldIdentifier; } // If we have a column with an input validator, apply it to transform the value let value = operand; if (column?.inputValidator) { try { const result = column.inputValidator["~standard"].validate(value); // Handle async validators (though they shouldn't be async for filters) if (result instanceof Promise) { // For filters, we can't use async validators, so skip transformation // This is a limitation - async validators won't work in filters value = operand; } else if ("issues" in result && result.issues) { // Validation failed, use original value value = operand; } else if ("value" in result) { // Validation succeeded, use transformed value value = result.value; } } catch (_error) { // If validation throws, use the original value (will likely cause a query error) // This maintains backward compatibility and allows the server to handle validation value = operand; } } // Date/time/timestamp values must be unquoted in OData filters. // Date objects are normalized to OData-friendly ISO fragments by field type. const ft = column?.fieldType; if (ft === "date" || ft === "time" || ft === "timestamp") { return this._formatTemporalValue(value, ft); } if (typeof value === "string") { return `'${value.replace(/'/g, "''")}'`; // Escape single quotes } if (value === null || value === undefined) { return "null"; } if (value instanceof Date) { return value.toISOString(); } if (typeof value === "object") { const valueType = value?.constructor?.name ?? "Object"; throw new Error( `Unsupported filter operand: received ${valueType}. Pass a table column or a primitive filter value.`, ); } return String(value); } } // ============================================================================ // Comparison Operators // ============================================================================ /** * Equal operator - checks if column equals a value or another column. * * @example * eq(users.name, "John") // name equals "John" * eq(users.id, contacts.id_user) // cross-table comparison */ export function eq<TOutput, TInput>( column1: Column<TOutput, TInput>, column2: Column<TOutput, TInput> | NoInfer<TInput>, ): FilterExpression; // biome-ignore lint/suspicious/noExplicitAny: Implementation signature for overloads export function eq(column: Column, value: any): FilterExpression { return new FilterExpression("eq", [column, value]); } /** * Not equal operator - checks if column does not equal a value or another column. * * @example * ne(users.status, "inactive") // status not equal to "inactive" * ne(users.id, contacts.id_user) // cross-table comparison */ export function ne<TOutput, TInput>( column1: Column<TOutput, TInput>, column2: Column<TOutput, TInput> | NoInfer<TInput>, ): FilterExpression; // biome-ignore lint/suspicious/noExplicitAny: Implementation signature for overloads export function ne(column: Column, value: any): FilterExpression { return new FilterExpression("ne", [column, value]); } /** * Greater than operator - checks if column is greater than a value. * * @example * gt(users.age, 18) // age greater than 18 */ export function gt<TOutput extends number | string | Date | null, TInput>( column: Column<TOutput, TInput>, value: NoInfer<TInput>, ): FilterExpression { return new FilterExpression("gt", [column, value]); } /** * Greater than or equal operator - checks if column is >= a value. * * @example * gte(users.age, 18) // age >= 18 */ export function gte<TOutput extends number | string | Date | null, TInput>( column: Column<TOutput, TInput>, value: NoInfer<TInput>, ): FilterExpression { return new FilterExpression("gte", [column, value]); } /** * Less than operator - checks if column is less than a value. * * @example * lt(users.age, 65) // age less than 65 */ export function lt<TOutput extends number | string | Date | null, TInput>( column: Column<TOutput, TInput>, value: NoInfer<TInput>, ): FilterExpression { return new FilterExpression("lt", [column, value]); } /** * Less than or equal operator - checks if column is <= a value. * * @example * lte(users.age, 65) // age <= 65 */ export function lte<TOutput extends number | string | Date | null, TInput>( column: Column<TOutput, TInput>, value: NoInfer<TInput>, ): FilterExpression { return new FilterExpression("lte", [column, value]); } // ============================================================================ // String Operators // ============================================================================ /** * Contains operator - checks if a string column contains a substring. * * @example * contains(users.name, "John") // name contains "John" */ export function contains<TOutput, TInput>(column: Column<TOutput, TInput>, value: NoInfer<TInput>): FilterExpression { return new FilterExpression("contains", [column, value]); } /** * Starts with operator - checks if a string column starts with a prefix. * * @example * startsWith(users.email, "admin") // email starts with "admin" */ export function startsWith<TOutput, TInput>(column: Column<TOutput, TInput>, value: NoInfer<TInput>): FilterExpression { return new FilterExpression("startsWith", [column, value]); } /** * Ends with operator - checks if a string column ends with a suffix. * * @example * endsWith(users.email, "@example.com") // email ends with "@example.com" */ export function endsWith<TOutput, TInput>(column: Column<TOutput, TInput>, value: NoInfer<TInput>): FilterExpression { return new FilterExpression("endsWith", [column, value]); } /** * Matches pattern operator - checks if a string column matches a regex pattern. * * @example * matchesPattern(users.name, "^A.*e$") // name matches regex pattern */ export function matchesPattern<TOutput extends string | null, TInput>( column: Column<TOutput, TInput>, pattern: string, ): FilterExpression { return new FilterExpression("matchesPattern", [column, pattern]); } // ============================================================================ // String Transform Functions // ============================================================================ /** * Wraps a column with OData `tolower()` for case-insensitive comparisons. * * @example * eq(tolower(users.name), "john") // tolower(name) eq 'john' */ export function tolower<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>( column: Column<TOutput, TInput, TableName, IsContainer>, ): ColumnFunction<TOutput, TInput, TableName, IsContainer> { return new ColumnFunction("tolower", column); } /** * Wraps a column with OData `toupper()` for case-insensitive comparisons. * * @example * eq(toupper(users.name), "JOHN") // toupper(name) eq 'JOHN' */ export function toupper<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>( column: Column<TOutput, TInput, TableName, IsContainer>, ): ColumnFunction<TOutput, TInput, TableName, IsContainer> { return new ColumnFunction("toupper", column); } /** * Wraps a column with OData `trim()` to remove leading/trailing whitespace. * * @example * eq(trim(users.name), "John") // trim(name) eq 'John' */ export function trim<TOutput extends string | null, TInput, TableName extends string, IsContainer extends boolean>( column: Column<TOutput, TInput, TableName, IsContainer>, ): ColumnFunction<TOutput, TInput, TableName, IsContainer> { return new ColumnFunction("trim", column); } // ============================================================================ // Array Operators // ============================================================================ /** * In array operator - checks if column value is in an array of values. * * @example * inArray(users.status, ["active", "pending"]) // status is "active" or "pending" */ export function inArray<TOutput, TInput>(column: Column<TOutput, TInput>, values: NoInfer<TInput>[]): FilterExpression { return new FilterExpression("in", [column, values]); } /** * Not in array operator - checks if column value is not in an array of values. * * @example * notInArray(users.status, ["deleted", "banned"]) // status is neither "deleted" nor "banned" */ export function notInArray<TOutput, TInput>( column: Column<TOutput, TInput>, values: NoInfer<TInput>[], ): FilterExpression { return new FilterExpression("notIn", [column, values]); } // ============================================================================ // Null Check Operators // ============================================================================ /** * Is null operator - checks if column value is null. * * @example * isNull(users.deletedAt) // deletedAt is null */ export function isNull<TOutput, TInput>(column: Column<TOutput, TInput>): FilterExpression { return new FilterExpression("isNull", [column]); } /** * Is not null operator - checks if column value is not null. * * @example * isNotNull(users.email) // email is not null */ export function isNotNull<TOutput, TInput>(column: Column<TOutput, TInput>): FilterExpression { return new FilterExpression("isNotNull", [column]); } // ============================================================================ // Logical Operators // ============================================================================ /** * AND operator - combines multiple filter expressions with logical AND. * All expressions must be true for the record to match. * * @example * and( * eq(users.active, true), * gt(users.age, 18) * ) // active is true AND age > 18 */ export function and(...expressions: FilterExpression[]): FilterExpression { if (expressions.length === 0) { throw new Error("AND operator requires at least one expression"); } if (expressions.length === 1 && expressions[0] !== undefined) { return expressions[0]; } return new FilterExpression("and", expressions); } /** * OR operator - combines multiple filter expressions with logical OR. * At least one expression must be true for the record to match. * * @example * or( * eq(users.role, "admin"), * eq(users.role, "moderator") * ) // role is "admin" OR "moderator" */ export function or(...expressions: FilterExpression[]): FilterExpression { if (expressions.length === 0) { throw new Error("OR operator requires at least one expression"); } if (expressions.length === 1 && expressions[0] !== undefined) { return expressions[0]; } return new FilterExpression("or", expressions); } /** * NOT operator - negates a filter expression. * * @example * not(eq(users.status, "deleted")) // status is NOT "deleted" */ export function not(expression: FilterExpression): FilterExpression { return new FilterExpression("not", [expression]); } // ============================================================================ // OrderBy Operators // ============================================================================ /** * OrderByExpression represents a sort order specification for a column. * Used in orderBy() clauses to provide type-safe sorting with direction. */ export class OrderByExpression<TableName extends string = string> { // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration readonly column: Column<any, any, TableName>; readonly direction: "asc" | "desc"; // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration constructor(column: Column<any, any, TableName>, direction: "asc" | "desc") { this.column = column; this.direction = direction; } } /** * Type guard to check if a value is an OrderByExpression instance. */ // biome-ignore lint/suspicious/noExplicitAny: Type guard accepting any value type export function isOrderByExpression(value: any): value is OrderByExpression { return value instanceof OrderByExpression; } /** * Ascending order operator - sorts a column in ascending order. * * @example * asc(users.name) // Sort by name ascending */ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration export function asc<TableName extends string>(column: Column<any, any, TableName>): OrderByExpression<TableName> { return new OrderByExpression(column, "asc"); } /** * Descending order operator - sorts a column in descending order. * * @example * desc(users.age) // Sort by age descending */ // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any Column configuration export function desc<TableName extends string>(column: Column<any, any, TableName>): OrderByExpression<TableName> { return new OrderByExpression(column, "desc"); }