/** * SCIM 2.0 Query Builder * * Type-safe query builder for SCIM 2.0 filtering. * * @example * ```typescript * // Array-based (recommended for UI) * const filters: QueryConditions = [ * { field: 'first_name', op: 'eq', value: 'John' }, * { field: 'age', op: 'gt', value: 18, connector: 'and' }, * ] * buildQuery(filters) // → 'first_name eq "John" and age gt 18' * * // Range query (same field twice) * const rangeFilters: QueryConditions = [ * { field: 'amount', op: 'ge', value: 100 }, * { field: 'amount', op: 'le', value: 500, connector: 'and' }, * ] * buildQuery(rangeFilters) // → 'amount ge 100 and amount le 500' * ``` */ type Primitive = string | number | boolean | null type DeepKeyOf = T extends Primitive ? never : { [K in keyof T & string]: K | `${K}.${DeepKeyOf}`; }[keyof T & string] /** * SCIM 2.0 comparison operators */ export type ComparisonOperator = | 'eq' // equal | 'ne' // not equal | 'gt' // greater than | 'ge' // greater than or equal | 'lt' // less than | 'le' // less than or equal | 'co' // contains | 'sw' // starts with | 'ew' // ends with | 'pr' // present (has value) | 'in' // in list — expands to (field eq "a" or field eq "b" …) | 'nin' // not in list — expands to (field ne "a" and field ne "b" …) /** * SCIM 2.0 logical operators */ export type LogicalOperator = 'and' | 'or' /** * A single filter condition */ export interface FilterCondition = Record> { field: DeepKeyOf | string op: ComparisonOperator value?: Primitive | Primitive[] // array only used with 'in'/'nin' connector?: LogicalOperator // before this condition, ignored for first } /** * Array of filter conditions - the primary way to define queries */ export type QueryConditions = Record> = FilterCondition[] /** * Format a value for SCIM 2.0 query */ function formatValue(value: Primitive): string { if (value === null) return 'null' if (typeof value === 'string') { const escaped = value.replace(/"/g, '\\"') return `"${escaped}"` } if (typeof value === 'boolean') return value.toString() return String(value) } /** * Build a SCIM 2.0 query string from conditions array * * @example * ```typescript * buildQuery([ * { field: 'name', op: 'eq', value: 'John' }, * { field: 'age', op: 'gt', value: 18, connector: 'and' }, * ]) * // → 'name eq "John" and age gt 18' * ``` */ export function buildQuery>( conditions?: QueryConditions ): string { if (!conditions || conditions.length === 0) return '' const parts: string[] = [] for (let i = 0; i < conditions.length; i++) { const { field, op, value, connector } = conditions[i] // Add connector (except for first condition) if (i > 0 && connector) { parts.push(connector) } // Build the condition if (op === 'pr') { parts.push(`${field} pr`) } else if (op === 'in' || op === 'nin') { const values = Array.isArray(value) ? value : (value != null ? [value] : []) if (values.length === 0) continue const innerOp = op === 'in' ? 'eq' : 'ne' const innerConn = op === 'in' ? 'or' : 'and' const inner = values.map(v => `${field} ${innerOp} ${formatValue(v)}`).join(` ${innerConn} `) parts.push(values.length > 1 ? `(${inner})` : inner) } else { parts.push(`${field} ${op} ${formatValue(value as Primitive ?? null)}`) } } return parts.join(' ') } /** * Query Builder for SCIM 2.0 filtering */ export class QueryFilter> { private parts: string[] = [] /** * Equal comparison */ eq>(field: K, value: Primitive): this { this.parts.push(`${field as string} eq ${QueryFilter.formatValue(value)}`) return this } /** * Not equal comparison */ ne>(field: K, value: Primitive): this { this.parts.push(`${field as string} ne ${QueryFilter.formatValue(value)}`) return this } /** * Greater than comparison */ gt>(field: K, value: number | string): this { this.parts.push(`${field as string} gt ${QueryFilter.formatValue(value)}`) return this } /** * Greater than or equal comparison */ ge>(field: K, value: number | string): this { this.parts.push(`${field as string} ge ${QueryFilter.formatValue(value)}`) return this } /** * Less than comparison */ lt>(field: K, value: number | string): this { this.parts.push(`${field as string} lt ${QueryFilter.formatValue(value)}`) return this } /** * Less than or equal comparison */ le>(field: K, value: number | string): this { this.parts.push(`${field as string} le ${QueryFilter.formatValue(value)}`) return this } /** * Contains comparison (for strings) */ co>(field: K, value: string): this { this.parts.push(`${field as string} co ${QueryFilter.formatValue(value)}`) return this } /** * Starts with comparison (for strings) */ sw>(field: K, value: string): this { this.parts.push(`${field as string} sw ${QueryFilter.formatValue(value)}`) return this } /** * Ends with comparison (for strings) */ ew>(field: K, value: string): this { this.parts.push(`${field as string} ew ${QueryFilter.formatValue(value)}`) return this } /** * Present check (field has a value) */ pr>(field: K): this { this.parts.push(`${field as string} pr`) return this } /** * AND logical operator */ and(): this { this.parts.push('and') return this } /** * OR logical operator */ or(): this { this.parts.push('or') return this } /** * NOT logical operator */ not(): this { this.parts.push('not') return this } /** * Group expressions with parentheses */ group(builderFn: (qb: QueryFilter) => QueryFilter): this { const groupBuilder = new QueryFilter() builderFn(groupBuilder) const groupQuery = groupBuilder.build() if (groupQuery) { this.parts.push(`(${groupQuery})`) } return this } /** * Add raw query string (use with caution) */ raw(query: string): this { this.parts.push(query) return this } /** * Clear the query */ clear(): this { this.parts = [] return this } /** * Build the final query string */ build(): string { return this.parts.join(' ') } /** * Convert to string automatically (allows using builder without .build()) */ toString(): string { return this.build() } /** * Allow implicit string conversion */ [Symbol.toPrimitive](hint: string): string | number { if (hint === 'string') { return this.build() } return this.parts.length } /** * Format value for SCIM 2.0 query */ private static formatValue(value: Primitive): string { if (value === null) { return 'null' } if (typeof value === 'string') { // Escape quotes in strings const escaped = value.replace(/"/g, '\\"') return `"${escaped}"` } if (typeof value === 'boolean') { return value.toString() } return String(value) } } /** * Build a query string from conditions or create a chainable query builder * * @example * ```typescript * // From conditions array * queryFilter([ * { field: 'first_name', op: 'eq', value: 'John' }, * { field: 'age', op: 'gt', value: 18, connector: 'and' }, * ]).build() * // → 'first_name eq "John" and age gt 18' * * // Chainable builder * queryFilter().eq('first_name', 'John').and().gt('age', 18).build() * // → 'first_name eq "John" and age gt 18' * * // Empty returns empty builder * queryFilter() // → QueryFilter instance * ``` */ export function queryFilter>( conditions?: QueryConditions ): QueryFilter { const builder = new QueryFilter() if (conditions && conditions.length > 0) { builder.raw(buildQuery(conditions)) } return builder } /** * Create a query that checks if any of the fields match the value */ export function anyOf>( fields: Array | string>, value: Primitive ): string { const conditions: QueryConditions = fields.map((field, i) => ({ field, op: 'eq' as const, value, connector: i > 0 ? 'or' as const : undefined, })) return buildQuery(conditions) } /** * Create a range query (field >= min and field <= max) */ export function range>( field: DeepKeyOf | string, min: number | string, max: number | string ): string { return buildQuery([ { field, op: 'ge', value: min }, { field, op: 'le', value: max, connector: 'and' }, ]) } /** * Create a search query across multiple string fields (OR) */ export function search>( fields: Array | string>, searchTerm: string ): string { const conditions: QueryConditions = fields.map((field, i) => ({ field, op: 'co' as const, value: searchTerm, connector: i > 0 ? 'or' as const : undefined, })) return buildQuery(conditions) } /** * Evaluate a SCIM 2.0 query string against a data object. * Returns true if the data matches, or if the query is empty/invalid. * * @example * ```typescript * evaluateQuery('status eq "active" and age gt 18', { status: 'active', age: 25 }) // → true * evaluateQuery('status eq "active" and age gt 18', { status: 'inactive', age: 25 }) // → false * ``` */ export function evaluateQuery(query: string, data: Record): boolean { if (!query) return true try { const conditions = parseQuery(query) if (conditions.length === 0) return true let result = true let prev: boolean | null = null for (let i = 0; i < conditions.length; i++) { const { field, op, value, connector } = conditions[i] const fieldValue = getNestedValue(data, field as string) let match = false switch (op) { case 'eq': match = fieldValue === value; break case 'ne': match = fieldValue !== value; break case 'gt': match = value != null && fieldValue > value; break case 'ge': match = value != null && fieldValue >= value; break case 'lt': match = value != null && fieldValue < value; break case 'le': match = value != null && fieldValue <= value; break case 'co': match = value != null && String(fieldValue).includes(String(value)); break case 'sw': match = value != null && String(fieldValue).startsWith(String(value)); break case 'ew': match = value != null && String(fieldValue).endsWith(String(value)); break case 'pr': match = fieldValue != null; break default: break } if (i === 0) { result = match prev = match } else { result = connector === 'or' ? (prev ?? false) || match : (prev ?? false) && match prev = result } } return result } catch { return true } } function getNestedValue(obj: Record, path: string): any { return path.split('.').reduce((acc, key) => acc?.[key], obj) } const OPERATORS: ComparisonOperator[] = ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'co', 'sw', 'ew', 'pr', 'in', 'nin'] const CONNECTORS: LogicalOperator[] = ['and', 'or'] /** * Parse a value string into a Primitive */ function parseValue(valueStr: string): Primitive { const trimmed = valueStr.trim() if (trimmed === 'null') return null if (trimmed === 'true') return true if (trimmed === 'false') return false if (trimmed.startsWith('"') && trimmed.endsWith('"')) { // Unescape quotes in strings return trimmed.slice(1, -1).replace(/\\"/g, '"') } const num = Number(trimmed) if (!Number.isNaN(num) && trimmed !== '') return num return trimmed } /** * Parse a SCIM 2.0 query string into conditions array * Supports grouping with parentheses: (a eq 1 or b eq 2) and c eq 3 * * @example * ```typescript * parseQuery('first_name eq "John" and age gt 18') * // → [ * // { field: 'first_name', op: 'eq', value: 'John' }, * // { field: 'age', op: 'gt', value: 18, connector: 'and' }, * // ] * * parseQuery('(item eq "A" or item eq "B") and status eq "active"') * // → [ * // { field: 'item', op: 'eq', value: 'A' }, * // { field: 'item', op: 'eq', value: 'B', connector: 'or' }, * // { field: 'status', op: 'eq', value: 'active', connector: 'and' }, * // ] * ``` */ export function parseQuery = Record>( queryString?: string ): QueryConditions { if (queryString == null || queryString.trim() === '') return [] const tokens = tokenize(queryString) return parseTokens(tokens, 0, tokens.length).conditions } interface ParseResult> { conditions: QueryConditions endIndex: number } function parseTokens>( tokens: string[], start: number, end: number ): ParseResult { const conditions: QueryConditions = [] let i = start while (i < end) { let connector: LogicalOperator | undefined // Check for connector (and/or) if (conditions.length > 0 && CONNECTORS.includes(tokens[i]?.toLowerCase() as LogicalOperator)) { connector = tokens[i].toLowerCase() as LogicalOperator i++ } if (i >= end) break // Handle opening parenthesis - parse grouped expression if (tokens[i] === '(') { // Find matching closing parenthesis let depth = 1 let closeIndex = i + 1 while (closeIndex < end && depth > 0) { if (tokens[closeIndex] === '(') depth++ else if (tokens[closeIndex] === ')') depth-- closeIndex++ } // Parse the group recursively const groupResult = parseTokens(tokens, i + 1, closeIndex - 1) // Apply connector to first condition in group if (connector && groupResult.conditions.length > 0) { groupResult.conditions[0].connector = connector } conditions.push(...groupResult.conditions) i = closeIndex continue } // Handle closing parenthesis - should not happen at top level if (tokens[i] === ')') { i++ continue } const field = tokens[i++] if (i >= end) break const op = tokens[i++]?.toLowerCase() as ComparisonOperator if (!OPERATORS.includes(op)) { throw new Error(`Invalid operator: ${op}`) } let value: Primitive | undefined if (op !== 'pr') { if (i >= end) { throw new Error(`Missing value for operator ${op}`) } value = parseValue(tokens[i++]) } const condition: FilterCondition = { field, op, value } if (connector) condition.connector = connector conditions.push(condition) } return { conditions, endIndex: i } } /** * Tokenize a SCIM query string, handling quoted strings and parentheses */ function tokenize(query: string): string[] { const tokens: string[] = [] let current = '' let inQuote = false let i = 0 while (i < query.length) { const char = query[i] if (inQuote) { if (char === '\\' && query[i + 1] === '"') { current += '\\"' i += 2 continue } if (char === '"') { current += '"' tokens.push(current) current = '' inQuote = false i++ continue } current += char i++ } else { if (char === '"') { if (current) tokens.push(current) current = '"' inQuote = true i++ continue } // Handle parentheses as separate tokens if (char === '(' || char === ')') { if (current) { tokens.push(current) current = '' } tokens.push(char) i++ continue } if (char === ' ' || char === '\t') { if (current) { tokens.push(current) current = '' } i++ continue } current += char i++ } } if (current) tokens.push(current) return tokens }