odata-builder
Version:
Type-safe OData v4.01 query builder for TypeScript with compile-time validation. Fluent FilterBuilder API, lambda expressions (any/all), in/not/has operators.
1,043 lines (1,042 loc) • 38 kB
TypeScript
type QueryFilter<T> = StringQueryFilter<T> | StringPredicateQueryFilter<T> | NumberQueryFilter<T> | DateQueryFilter<T> | GuidQueryFilter<T> | BooleanQueryFilter<T> | LambdaFilter<T> | InFilter | NegatedFilter<T> | HasFilter<T>;
interface StringQueryFilter<T> extends BaseFilter<T, string> {
operator: StringFilterOperators;
ignoreCase?: boolean;
removeQuotes?: boolean;
function?: StringFunctionDefinition<T>;
transform?: StringTransform[];
}
/**
* String filter with boolean predicate function (contains, startswith, endswith)
* These functions return boolean, so value is boolean not string
* Example: contains(name, 'test') eq true
*/
interface StringPredicateQueryFilter<T> {
field: FilterFields<T, string>;
operator: GeneralFilterOperators;
value: boolean | null;
ignoreCase?: boolean;
function: {
type: "contains" | "startswith" | "endswith";
value: string | FieldReference<T, string>;
};
}
interface NumberQueryFilter<T> extends BaseFilter<T, number> {
operator: NumberFilterOperators | GeneralFilterOperators;
function?: ArithmeticFunctionDefinition<T>;
transform?: NumberTransform[];
}
interface DateQueryFilter<T> extends BaseFilter<T, Date> {
operator: DateFilterOperators | GeneralFilterOperators;
function?: DateFunctionDefinition<T>;
transform?: DateTransform[];
}
interface GuidQueryFilter<T> extends BaseFilter<T, Guid> {
operator: GeneralFilterOperators;
removeQuotes?: boolean;
transform?: GuidTransform[];
}
interface BooleanQueryFilter<T> extends BaseFilter<T, boolean> {
operator: GeneralFilterOperators;
}
/**
* Filter for membership testing using 'in' operator (OData 4.01)
* Example: Name in ('A', 'B', 'C')
*/
interface InFilter {
field: string;
operator: "in";
values: InFilterValue[];
}
type InFilterValue = string | number | boolean | Date | Guid | null;
/**
* Negated filter using 'not' operator
* Example: not (contains(Name, 'test'))
* Example: not (Age gt 18)
*/
interface NegatedFilter<T> {
type: "not";
filter: QueryFilter<T> | CombinedFilter<T>;
}
/**
* Filter for enum flag checking using 'has' operator
* Example: Style has Sales.Color'Yellow'
*
* Note: The value must be a valid OData enum literal,
* typically in format Namespace.EnumType'Value'
* The library does not validate or modify the enum literal.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HasFilter<_T> {
field: string;
operator: "has";
value: string;
}
interface BaseFilter<T, V> {
field: FilterFields<T, V>;
operator: FilterOperators<V>;
value: V | null;
}
type PrimitiveArrayElementModel<V> = {
s: V;
};
type LambdaFilter<T> = {
[K in ArrayFields<T>]: {
field: K;
lambdaOperator: "any" | "all";
expression: ArrayElement<T, K> extends object ? QueryExpression<ArrayElement<T, K>> | CombinedQueryExpression<ArrayElement<T, K>> : QueryExpression<PrimitiveArrayElementModel<ArrayElement<T, K>>> | CombinedQueryExpression<PrimitiveArrayElementModel<ArrayElement<T, K>>>;
};
}[ArrayFields<T>];
type QueryExpression<T, F extends SupportedFunction<T> | undefined = undefined> = F extends {
type: "now";
} ? {
field: FilterFields<T, Date>;
function: F;
operator?: never;
value?: never;
} : F extends {
type: "length";
} ? {
field: FilterFields<T, string>;
function: F;
operator: FilterOperators<number>;
value: number | null;
} : F extends {
type: "indexof";
} ? {
field: FilterFields<T, string>;
function: F;
operator: FilterOperators<number>;
value: number | null;
} : F extends {
type: "substring";
} ? {
field: FilterFields<T, string>;
function: F;
operator: FilterOperators<string>;
value: string | null;
} : F extends {
type: "contains";
} ? {
field: FilterFields<T, string>;
function: F;
operator?: FilterOperators<boolean> | undefined;
value?: boolean | null | undefined;
} : QueryFilter<T>;
type CombinedQueryExpression<T> = {
logic: "and" | "or";
filters: Array<QueryExpression<T> | CombinedQueryExpression<T>>;
};
type StringFunctionDefinition<T> = {
type: "concat";
values: (string | FieldReference<T, string>)[];
} | {
type: "contains";
value: string | FieldReference<T, string>;
} | {
type: "endswith";
value: string | FieldReference<T, string>;
} | {
type: "indexof";
value: string | FieldReference<T, string>;
} | {
type: "length";
} | {
type: "startswith";
value: string | FieldReference<T, string>;
} | {
type: "substring";
start: number | FieldReference<T, number>;
length?: number | FieldReference<T, number>;
};
type ArithmeticOperator = "add" | "sub" | "mul" | "div" | "mod";
type ArithmeticFunctionDefinition<T> = {
type: ArithmeticOperator;
operand: number | FieldReference<T, number>;
};
type DateFunctionDefinition<T> = {
type: "now";
} | {
type: "date";
field: FieldReference<T, Date>;
} | {
type: "time";
field: FieldReference<T, Date>;
};
type FieldReference<T, V extends string | number | Date | boolean> = {
fieldReference: FilterFields<T, V>;
};
type SupportedFunction<T> = StringFunctionDefinition<T> | ArithmeticFunctionDefinition<T> | DateFunctionDefinition<T>;
type ArrayFields<T> = {
[K in keyof T]: T[K] extends Array<unknown> ? K : never;
}[keyof T];
type ArrayElement<T, K extends ArrayFields<T>> = T[K] extends (infer U)[] ? U : T[K] extends readonly (infer U)[] ? U : never;
type FilterFields<T, VALUETYPE> = {
[K in Extract<keyof T, string>]: T[K] extends Record<string, unknown> ? T[K] extends VALUETYPE ? K : `${K}/${NestedFilterFields<T[K], VALUETYPE>}` : T[K] extends VALUETYPE | null | undefined ? K : T[K] extends readonly VALUETYPE[] ? K : T[K] extends readonly Record<string, infer INNERVALUE>[] ? INNERVALUE extends VALUETYPE ? K : never : never;
}[Extract<keyof T, string>];
type NestedFilterFieldsHelper<T, VALUETYPE> = T extends Record<string, unknown> ? {
[K in keyof T & string]: T[K] extends VALUETYPE | null | undefined ? K : T[K] extends Record<string, unknown> ? `${K}/${NestedFilterFieldsHelper<Exclude<T[K], undefined>, VALUETYPE>}` extends `${infer P}` ? P : never : never;
}[keyof T & string] : never;
type NestedFilterFields<T, VALUETYPE> = NestedFilterFieldsHelper<T, VALUETYPE>;
type LambdaFilterFields<T, VALUETYPE> = {
[K in Extract<keyof T, string>]: T[K] extends readonly (infer TYPE)[] ? TYPE extends object // Nur Arrays von Objekten
? {
[Key in keyof TYPE]: TYPE[Key] extends VALUETYPE ? Key : never;
}[keyof TYPE] // Extrahiere nur Felder mit VALUETYPE
: never : never;
}[Extract<keyof T, string>];
type GeneralFilterOperators = "eq" | "ne";
type StringFilterOperators = GeneralFilterOperators;
type StringTransform = "tolower" | "toupper" | "trim" | "length";
type DateTransform = "year" | "month" | "day" | "hour" | "minute" | "second";
type NumberTransform = "round" | "floor" | "ceiling";
type GuidTransform = "tolower";
type NumberFilterOperators = "ge" | "gt" | "le" | "lt";
type DateFilterOperators = NumberFilterOperators;
type DependentFilterOperators<VALUETYPE> = VALUETYPE extends string ? StringFilterOperators : VALUETYPE extends number ? NumberFilterOperators : VALUETYPE extends Date ? DateFilterOperators : never;
type FilterOperators<VALUETYPE> = GeneralFilterOperators | DependentFilterOperators<VALUETYPE>;
interface CombinedFilter<T> {
logic: "and" | "or";
filters: Array<QueryFilter<T> | CombinedFilter<T>>;
}
type ExpandFields<T, Depth extends number = 5> = Depth extends 0 ? never : {
[K in Extract<keyof T, string>]: IsObjectType<NonNullable<T[K]>> extends true ?
// Check for empty object
HasKeys<NonNullable<T[K]>> extends true ?
// If there's at least one key, include `K` and deeper expansions
K | (Depth extends 1 ? never : `${K}/${ExpandFields<NonNullable<T[K]>, PrevDepth<Depth>>}`) : never : never;
}[Extract<keyof T, string>];
/**
* Generates type-safe field paths for $select queries.
*
* Supports nested property paths using '/' separator (e.g., 'address/city').
* Recursively generates all valid paths up to the specified depth.
* Works with both interfaces and inline types.
*
* @typeParam T - The entity type
* @typeParam Depth - Maximum recursion depth (default: 5)
*
* @example
* interface User {
* name: string;
* address: { city: string; zip: number };
* }
*
* type Fields = SelectFields<User>;
* // 'name' | 'address' | 'address/city' | 'address/zip'
*/
type SelectFields<T, Depth extends number = 5> = [
Depth
] extends [
never
] ? never : {
[K in Extract<keyof T, string>]-?: IsObjectType<NonNullable<T[K]>> extends true ? K | (string extends SelectFields<NonNullable<T[K]>, PrevDepth<Depth>> ? never : `${K}/${SelectFields<NonNullable<T[K]>, PrevDepth<Depth>> & string}`) : K;
}[Extract<keyof T, string>];
type Guid = string & {
_type: Guid;
};
type HasKeys<T> = [
keyof T
] extends [
never
] ? false : true;
/**
* Helper type to check if a type is an object (not array, not primitive, not function, not Date)
* Works correctly with both interfaces and inline types
*/
type IsObjectType<T> = T extends object ? T extends readonly unknown[] ? false : T extends (...args: unknown[]) => unknown ? false : T extends Date ? false : true : false;
type PrevDepth<T extends number> = [
never,
// 0
0,
// 1
1,
// 2
2,
// 3
3,
// 4
4
][T];
interface OrderByDescriptor<T> {
field: OrderByFields<T>;
orderDirection: "asc" | "desc";
}
type OrderByFields<T, Depth extends number = 5> = [
Depth
] extends [
never
] ? never : {
[K in Extract<keyof T, string>]-?: IsObjectType<NonNullable<T[K]>> extends true ? K | (string extends OrderByFields<NonNullable<T[K]>, PrevDepth<Depth>> ? never : `${K}/${OrderByFields<NonNullable<T[K]>, PrevDepth<Depth>> & string}`) : K;
}[Extract<keyof T, string>];
type SearchTerm = string & {
__type: "SearchTerm";
};
interface SearchPhrase {
phrase: string;
}
type SearchOperator = "AND" | "OR" | "NOT";
interface SearchGroup {
expression: SearchExpression;
}
type SearchExpressionPart = SearchTerm | SearchPhrase | SearchOperator | SearchGroup;
type SearchExpression = readonly SearchExpressionPart[];
/**
* Builder for constructing OData $search query expressions.
*
* Supports terms, phrases, boolean operators (AND, OR, NOT), and grouping.
* The builder is immutable - each method returns a new instance.
*
* @example
* // Simple term search
* new SearchExpressionBuilder()
* .term('coffee')
* .toString() // "coffee"
*
* @example
* // Multiple terms with AND
* new SearchExpressionBuilder()
* .term('coffee')
* .and()
* .term('shop')
* .toString() // "coffee AND shop"
*
* @example
* // Phrase search (exact match)
* new SearchExpressionBuilder()
* .phrase('coffee shop')
* .toString() // "\"coffee shop\""
*
* @example
* // Complex expression with grouping
* const inner = new SearchExpressionBuilder().term('latte').or().term('espresso');
* new SearchExpressionBuilder()
* .term('coffee')
* .and()
* .group(inner)
* .toString() // "coffee AND (latte OR espresso)"
*
* @example
* // Using with OdataQueryBuilder
* new OdataQueryBuilder<Product>()
* .search(new SearchExpressionBuilder().term('coffee').and().term('organic'))
* .toQuery() // "?$search=coffee%20AND%20organic"
*/
declare class SearchExpressionBuilder {
private readonly parts;
constructor(parts?: SearchExpression);
/**
* Adds a search term to the expression.
*
* Terms are single words that will be searched for in the data.
* Multiple terms can be combined with and() or or().
*
* @param term - The search term (must not be empty or whitespace only)
* @returns A new SearchExpressionBuilder with the term added
* @throws Error if term is empty or whitespace only
*
* @example
* builder.term('coffee') // Adds "coffee" to search
*/
term(term: string): SearchExpressionBuilder;
/**
* Adds an exact phrase to the expression.
*
* Phrases are enclosed in double quotes and search for exact matches.
* Use this when you need to match multiple words in sequence.
*
* @param phrase - The exact phrase to search for (must not be empty)
* @returns A new SearchExpressionBuilder with the phrase added
* @throws Error if phrase is empty or whitespace only
*
* @example
* builder.phrase('coffee shop') // Adds "\"coffee shop\"" to search
*/
phrase(phrase: string): SearchExpressionBuilder;
/**
* Adds an AND operator between search terms.
*
* Both terms on either side of AND must match for results to be returned.
*
* @returns A new SearchExpressionBuilder with AND added
*
* @example
* builder.term('coffee').and().term('organic') // "coffee AND organic"
*/
and(): SearchExpressionBuilder;
/**
* Adds an OR operator between search terms.
*
* Either term on either side of OR can match for results to be returned.
*
* @returns A new SearchExpressionBuilder with OR added
*
* @example
* builder.term('coffee').or().term('tea') // "coffee OR tea"
*/
or(): SearchExpressionBuilder;
/**
* Negates a search expression.
*
* Results matching the negated expression will be excluded.
*
* @param expressionBuilder - The expression to negate
* @returns A new SearchExpressionBuilder with the negated expression
*
* @example
* const decaf = new SearchExpressionBuilder().term('decaf');
* builder.term('coffee').and().not(decaf) // "coffee AND (NOT decaf)"
*/
not(expressionBuilder: SearchExpressionBuilder): SearchExpressionBuilder;
/**
* Groups an expression with parentheses for precedence control.
*
* Use this to ensure correct evaluation order in complex expressions.
*
* @param builder - The expression to group
* @returns A new SearchExpressionBuilder with the grouped expression
*
* @example
* const options = new SearchExpressionBuilder().term('latte').or().term('espresso');
* builder.term('coffee').and().group(options) // "coffee AND (latte OR espresso)"
*/
group(builder: SearchExpressionBuilder): SearchExpressionBuilder;
/**
* Builds and returns the raw search expression array.
*
* @returns The internal search expression representation
*/
build(): SearchExpression;
/**
* Converts the search expression to its OData string representation.
*
* This is the format used in the $search query parameter.
*
* @returns The OData search expression string
*
* @example
* new SearchExpressionBuilder()
* .term('coffee')
* .and()
* .phrase('fair trade')
* .toString() // 'coffee AND "fair trade"'
*/
toString(): string;
/**
* Compares this expression with another for equality.
*
* @param other - The other SearchExpressionBuilder to compare with
* @returns true if both expressions are structurally equal
*/
equals(other: SearchExpressionBuilder): boolean;
private stringifyPart;
}
// ============================================================================
// FilterExpression - Internal wrapper for filter objects
// ============================================================================
interface FilterExpression<T> {
readonly _type: "expression";
readonly _filter: QueryFilter<T> | CombinedFilter<T>;
}
// ============================================================================
// Field Operations - Type-specific operations available on each field type
// ============================================================================
/**
* Helper type: checks if null is part of T
*/
type IncludesNull<T> = null extends T ? true : false;
/**
* Helper type: conditionally adds null to V if AllowNull is true
*/
type MaybeNull<V, AllowNull extends boolean> = AllowNull extends true ? V | null : V;
/**
* Base operations available on all field types
* AllowNull determines whether eq/ne accept null (based on original field type)
*/
interface BaseFieldOperations<T, V, AllowNull extends boolean = false> {
eq(value: MaybeNull<V, AllowNull>): FilterExpression<T>;
ne(value: MaybeNull<V, AllowNull>): FilterExpression<T>;
/**
* Membership test using 'in' operator (OData 4.01)
* @throws Error if values array is empty
*/
in(values: MaybeNull<V, AllowNull>[]): FilterExpression<T>;
}
/**
* String-specific operations
* V preserves literal string types (e.g., 'open' | 'closed')
* AllowNull determines whether eq/ne accept null
*/
interface StringFieldOperations<T, V extends string = string, AllowNull extends boolean = false> extends BaseFieldOperations<T, V, AllowNull> {
// Case insensitivity modifier
ignoreCase(): StringFieldOperations<T, V, AllowNull>;
// String predicates (return boolean - no further comparison needed)
contains(value: string): FilterExpression<T>;
startswith(value: string): FilterExpression<T>;
endswith(value: string): FilterExpression<T>;
// String functions returning values (can be chained with comparison)
length(): NumberFieldOperations<T>;
indexof(value: string): NumberFieldOperations<T>;
substring(start: number, length?: number): StringFieldOperations<T, string, AllowNull>;
concat(...values: string[]): StringFieldOperations<T, string, AllowNull>;
// String transforms
tolower(): StringFieldOperations<T, V, AllowNull>;
toupper(): StringFieldOperations<T, V, AllowNull>;
trim(): StringFieldOperations<T, V, AllowNull>;
/**
* Enum flag check using 'has' operator
* Value must be a valid OData enum literal (e.g., "Sales.Color'Yellow'")
* The library passes the value through unchanged - caller is responsible for correct format.
*/
has(enumLiteral: string): FilterExpression<T>;
}
/**
* Number-specific operations
* AllowNull determines whether eq/ne accept null
*/
interface NumberFieldOperations<T, AllowNull extends boolean = false> extends BaseFieldOperations<T, number, AllowNull> {
// Comparison operators
gt(value: number): FilterExpression<T>;
ge(value: number): FilterExpression<T>;
lt(value: number): FilterExpression<T>;
le(value: number): FilterExpression<T>;
// Arithmetic functions
add(operand: number): NumberFieldOperations<T, AllowNull>;
sub(operand: number): NumberFieldOperations<T, AllowNull>;
mul(operand: number): NumberFieldOperations<T, AllowNull>;
div(operand: number): NumberFieldOperations<T, AllowNull>;
mod(operand: number): NumberFieldOperations<T, AllowNull>;
// Number transforms
round(): NumberFieldOperations<T, AllowNull>;
floor(): NumberFieldOperations<T, AllowNull>;
ceiling(): NumberFieldOperations<T, AllowNull>;
}
/**
* Date-specific operations
* AllowNull determines whether eq/ne accept null
*/
interface DateFieldOperations<T, AllowNull extends boolean = false> extends BaseFieldOperations<T, Date, AllowNull> {
// Comparison operators
gt(value: Date): FilterExpression<T>;
ge(value: Date): FilterExpression<T>;
lt(value: Date): FilterExpression<T>;
le(value: Date): FilterExpression<T>;
// Date extraction transforms (return number for comparison)
// Note: extracted values are never null, so AllowNull=false
year(): NumberFieldOperations<T>;
month(): NumberFieldOperations<T>;
day(): NumberFieldOperations<T>;
hour(): NumberFieldOperations<T>;
minute(): NumberFieldOperations<T>;
second(): NumberFieldOperations<T>;
}
/**
* Boolean-specific operations
* AllowNull determines whether eq/ne accept null
*/
interface BooleanFieldOperations<T, AllowNull extends boolean = false> extends BaseFieldOperations<T, boolean, AllowNull> {
// Convenience methods
isTrue(): FilterExpression<T>;
isFalse(): FilterExpression<T>;
}
/**
* Guid-specific operations
* AllowNull determines whether eq/ne accept null
*/
interface GuidFieldOperations<T, AllowNull extends boolean = false> extends BaseFieldOperations<T, Guid, AllowNull> {
// Guid modifiers
removeQuotes(): GuidFieldOperations<T, AllowNull>;
tolower(): GuidFieldOperations<T, AllowNull>;
}
/**
* Array-specific operations (for lambda filters)
*/
interface ArrayFieldOperations<T, E> {
any(predicate: (element: FieldProxy<E>) => FilterExpression<E>): FilterExpression<T>;
all(predicate: (element: FieldProxy<E>) => FilterExpression<E>): FilterExpression<T>;
}
// ============================================================================
// FieldProxy - Maps object properties to their type-specific operations
// ============================================================================
/**
* Internal type for primitive array element wrapper
* When array is string[], number[], etc., we wrap it as { s: T }
*/
type PrimitiveArrayWrapper<V> = {
s: V;
};
/**
* Determines the appropriate field operations type based on the field's value type
* Note: Guid must be checked before string since Guid extends string
* TRoot is the root entity type (for FilterExpression return type)
* V is the current field value type (after NonNullable)
* AllowNull indicates if the original field type included null
*
* Uses [V] extends [...] pattern to prevent distributive conditional types
* This ensures literal unions like 'open' | 'closed' are preserved as-is
*/
type FieldOperationsFor<TRoot, V, AllowNull extends boolean = false> = [
V
] extends [
Guid
] ? GuidFieldOperations<TRoot, AllowNull> : [
V
] extends [
string
] ? StringFieldOperations<TRoot, V & string, AllowNull> : [
V
] extends [
number
] ? NumberFieldOperations<TRoot, AllowNull> : [
V
] extends [
boolean
] ? BooleanFieldOperations<TRoot, AllowNull> : [
V
] extends [
Date
] ? DateFieldOperations<TRoot, AllowNull> : [
V
] extends [
Array<infer E>
] ? E extends object ? ArrayFieldOperations<TRoot, E> : ArrayFieldOperations<TRoot, PrimitiveArrayWrapper<E>> : [
V
] extends [
object
] ? NestedFieldProxy<TRoot, V> : never;
/**
* NestedFieldProxy for accessing nested object properties while preserving the root type
* TRoot is the root entity type (for FilterExpression return type)
* T is the current nested object type (for property access)
*/
type NestedFieldProxy<TRoot, T> = {
[K in keyof T]-?: FieldOperationsFor<TRoot, NonNullable<T[K]>, IncludesNull<T[K]>>;
};
/**
* The main FieldProxy type - provides type-safe property access with operations
*
* @example
* // For type User = { name: string; age: number; tags: string[]; address: { city: string } }
* // FieldProxy<User> provides:
* // - x.name -> StringFieldOperations<User, string, false>
* // - x.age -> NumberFieldOperations<User, false>
* // - x.tags -> ArrayFieldOperations<User, { s: string }>
* // - x.address.city -> StringFieldOperations<User, string, false> (Root type preserved!)
* // - x.nullableName (string | null) -> StringFieldOperations<User, string, true> (allows eq(null))
*/
type FieldProxy<T> = {
[K in keyof T]-?: FieldOperationsFor<T, NonNullable<T[K]>, IncludesNull<T[K]>>;
};
// ============================================================================
// FilterBuilder Input Types
// ============================================================================
/**
* Predicate function type for where/and/or methods
*/
type FilterPredicate<T> = (x: FieldProxy<T>) => FilterExpression<T>;
/**
* Input type that accepts either a predicate function or another FilterBuilder
*/
type FilterInput<T> = FilterPredicate<T> | FilterBuilderLike<T>;
/**
* Interface for FilterBuilder-like objects (allows type-safe composition)
*/
interface FilterBuilderLike<T> {
build(): QueryFilter<T> | CombinedFilter<T> | null;
}
// ============================================================================
// Internal Types for FilterBuilder
// ============================================================================
/**
* Internal representation of a filter part in the builder
*/
interface FilterPart<T> {
/** Logic operator connecting this part to the previous one */
logic?: "and" | "or";
/** The actual filter */
filter: QueryFilter<T> | CombinedFilter<T>;
}
// ============================================================================
// FilterBuilder - Fluent API for building type-safe OData filters
// ============================================================================
/**
* FilterBuilder provides a fluent, type-safe API for building OData filters.
*
* @example
* // Simple filter
* new FilterBuilder<User>()
* .where(x => x.name.eq('John'))
* .build();
*
* @example
* // Complex filter with AND/OR
* new FilterBuilder<User>()
* .where(x => x.name.eq('John'))
* .and(x => x.age.gt(18))
* .or(x => x.isAdmin.isTrue())
* .build();
*
* @example
* // Composing filters
* const activeFilter = new FilterBuilder<User>().where(x => x.isActive.isTrue());
* new FilterBuilder<User>()
* .where(x => x.name.eq('John'))
* .and(activeFilter)
* .build();
*/
declare class FilterBuilder<T> implements FilterBuilderLike<T> {
private readonly parts;
constructor(parts?: FilterPart<T>[]);
// ========================================================================
// Public API
// ========================================================================
/**
* Starts a new filter condition.
* This is typically the first method called when building a filter.
*
* @param input - Either a predicate function or another FilterBuilder
* @returns A new FilterBuilder with the condition added
*
* @example
* new FilterBuilder<User>().where(x => x.name.eq('John'))
*/
where(input: FilterInput<T>): FilterBuilder<T>;
/**
* Adds an AND condition to the filter.
*
* @param input - Either a predicate function or another FilterBuilder
* @returns A new FilterBuilder with the AND condition added
*
* @example
* builder.where(x => x.name.eq('John')).and(x => x.age.gt(18))
*/
and(input: FilterInput<T>): FilterBuilder<T>;
/**
* Adds an OR condition to the filter.
*
* @param input - Either a predicate function or another FilterBuilder
* @returns A new FilterBuilder with the OR condition added
*
* @example
* builder.where(x => x.name.eq('John')).or(x => x.name.eq('Jane'))
*/
or(input: FilterInput<T>): FilterBuilder<T>;
/**
* Negates the entire current filter expression.
*
* @returns A new FilterBuilder with the negated filter
* @throws Error if called on an empty builder
*
* @example
* // not (name eq 'John')
* builder.where(x => x.name.eq('John')).not()
*
* @example
* // not (name eq 'John' and age gt 18)
* builder.where(x => x.name.eq('John')).and(x => x.age.gt(18)).not()
*/
not(): FilterBuilder<T>;
/**
* Creates a grouped sub-expression.
* Useful for controlling operator precedence.
*
* @param builder - A FilterBuilder to group
* @returns A new FilterBuilder representing the grouped expression
*
* @example
* // (name eq 'John' and age gt 18) or isAdmin eq true
* builder
* .where(builder.group(
* new FilterBuilder<User>()
* .where(x => x.name.eq('John'))
* .and(x => x.age.gt(18))
* ))
* .or(x => x.isAdmin.isTrue())
*/
group(builder: FilterBuilder<T>): FilterBuilder<T>;
/**
* Builds the final filter object.
* Returns null if no conditions have been added.
*
* @returns QueryFilter<T> | CombinedFilter<T> | null
*/
build(): QueryFilter<T> | CombinedFilter<T> | null;
/**
* Builds all filters as an array (useful for the existing filter() method).
*
* @returns Array of QueryFilter<T> | CombinedFilter<T>
*/
buildArray(): Array<QueryFilter<T> | CombinedFilter<T>>;
/**
* Checks if the builder has any conditions.
*/
isEmpty(): boolean;
// ========================================================================
// Private Methods
// ========================================================================
/**
* Resolves a FilterInput to a QueryFilter or CombinedFilter.
*/
private resolveInput;
/**
* Type guard for FilterBuilderLike objects.
*/
private isFilterBuilderLike;
/**
* Builds the filter tree respecting operator precedence.
* AND has higher precedence than OR.
*/
private buildWithPrecedence;
}
// ============================================================================
// Factory Function
// ============================================================================
/**
* Creates a new FilterBuilder instance.
* This is a convenience function that can be used instead of `new FilterBuilder<T>()`.
*
* @example
* import { filter } from './filter-builder';
* const f = filter<User>().where(x => x.name.eq('John'));
*/
declare function filter<T>(): FilterBuilder<T>;
/**
* Options for OdataQueryBuilder
*/
interface OdataQueryBuilderOptions {
/**
* Use legacy 'or' fallback for 'in' operator (for OData 4.0 servers)
* Default: false (uses OData 4.01 'in' syntax)
*
* @example
* // OData 4.01 (default): name in ('A', 'B')
* // Legacy (4.0): (name eq 'A' or name eq 'B')
*/
legacyInOperator?: boolean;
}
/**
* Type-safe OData query builder for constructing OData v4.01 query strings.
*
* Supports all major OData query options: $filter, $select, $expand, $orderby,
* $top, $skip, $count, and $search.
*
* @typeParam T - The entity type to build queries for
*
* @example
* // Basic query with filter and select
* const query = new OdataQueryBuilder<User>()
* .filter(f => f.where(x => x.isActive.isTrue()))
* .select('name', 'email')
* .top(10)
* .toQuery();
* // "?$filter=isActive eq true&$top=10&$select=name, email"
*
* @example
* // Complex filter with type-safe field access
* new OdataQueryBuilder<User>()
* .filter(f => f
* .where(x => x.name.contains('John'))
* .and(x => x.age.gt(18))
* .or(x => x.tags.any(t => t.s.eq('admin')))
* )
* .toQuery();
*
* @example
* // Using in() operator (OData 4.01)
* new OdataQueryBuilder<User>()
* .filter(f => f.where(x => x.status.in(['active', 'pending'])))
* .toQuery();
* // "?$filter=status in ('active', 'pending')"
*
* @example
* // Legacy mode for OData 4.0 servers
* new OdataQueryBuilder<User>({ legacyInOperator: true })
* .filter(f => f.where(x => x.status.in(['active', 'pending'])))
* .toQuery();
* // "?$filter=(status eq 'active' or status eq 'pending')"
*
* @example
* // Using has() for enum flags
* new OdataQueryBuilder<Product>()
* .filter(f => f.where(x => x.color.has("Sales.Color'Yellow'")))
* .toQuery();
* // "?$filter=color has Sales.Color'Yellow'"
*
* @example
* // Negation with not()
* new OdataQueryBuilder<User>()
* .filter(f => f.where(x => x.name.contains('test')).not())
* .toQuery();
* // "?$filter=not (contains(name, 'test'))"
*/
declare class OdataQueryBuilder<T> {
private queryComponents;
private readonly filterContext;
/**
* Creates a new OdataQueryBuilder instance.
*
* @param options - Configuration options for query generation
*/
constructor(options?: OdataQueryBuilderOptions);
/**
* Limits the number of results returned.
*
* @param topCount - Maximum number of entities to return (must be positive)
* @returns This builder for chaining
* @throws Error if topCount is negative
*
* @example
* builder.top(10) // $top=10
*/
top(topCount: number): this;
/**
* Skips a number of results for pagination.
*
* @param skipCount - Number of entities to skip (must be positive)
* @returns This builder for chaining
* @throws Error if skipCount is negative
*
* @example
* builder.skip(20).top(10) // $skip=20&$top=10 (page 3)
*/
skip(skipCount: number): this;
/**
* Selects specific properties to return (projection).
*
* Supports nested property paths using '/' separator for type-safe
* selection of properties in complex types and navigation properties.
*
* @param selectProps - Property paths to include in the response
* @returns This builder for chaining
* @throws Error if any property name is invalid
*
* @example
* // Simple properties
* builder.select('name', 'email') // $select=name, email
*
* @example
* // Nested properties with IntelliSense support
* builder.select('name', 'address/city', 'address/zip')
* // $select=name, address/city, address/zip
*/
select(...selectProps: SelectFields<Required<T>>[]): this;
/**
* Adds filter conditions to the query.
*
* Supports two syntaxes:
* 1. Callback syntax with FilterBuilder (recommended for type safety)
* 2. Object syntax with raw filter objects
*
* @param filters - Filter objects or callback function
* @returns This builder for chaining
* @throws Error if filter is invalid
*
* @example
* // Callback syntax (recommended)
* builder.filter(f => f.where(x => x.name.eq('John')))
*
* @example
* // Object syntax
* builder.filter({ field: 'name', operator: 'eq', value: 'John' })
*/
filter(...filters: (CombinedFilter<Required<T>> | QueryFilter<Required<T>>)[]): this;
/**
* Adds filter conditions using FilterBuilder callback syntax.
*
* @param callback - Function that receives a FilterBuilder and returns it
* @returns This builder for chaining
*/
filter(callback: (f: FilterBuilder<Required<T>>) => FilterBuilder<Required<T>>): this;
/**
* Expands navigation properties to include related entities.
*
* @param expandFields - Navigation properties to expand
* @returns This builder for chaining
* @throws Error if any expand field is invalid
*
* @example
* builder.expand('orders') // $expand=orders
*
* @example
* // Nested expand with select
* builder.expand({ orders: { select: ['id', 'price'] } })
*/
expand(...expandFields: ExpandFields<T>[]): this;
/**
* Adds count to the query.
*
* @param countEntities - If true, returns only the count (/$count).
* If false, includes count in response ($count=true).
* @returns This builder for chaining
*
* @example
* builder.count() // $count=true (includes count with results)
*
* @example
* builder.count(true) // /$count (returns only the count number)
*/
count(countEntities?: boolean): this;
/**
* Orders the results by one or more properties.
*
* @param orderBy - Order descriptors with field and direction
* @returns This builder for chaining
*
* @example
* builder.orderBy({ field: 'name', order: 'asc' })
*
* @example
* // Multiple sort criteria
* builder.orderBy(
* { field: 'lastName', order: 'asc' },
* { field: 'firstName', order: 'asc' }
* )
*/
orderBy(...orderBy: OrderByDescriptor<Required<T>>[]): this;
/**
* Adds a free-text search to the query.
*
* Accepts either a simple string or a SearchExpressionBuilder for
* complex search expressions with AND, OR, NOT operators.
*
* @param searchExpression - Search string or SearchExpressionBuilder
* @returns This builder for chaining
* @throws Error if searchExpression is not a string or SearchExpressionBuilder
*
* @example
* builder.search('coffee') // $search=coffee
*
* @example
* // Complex search with SearchExpressionBuilder
* builder.search(
* new SearchExpressionBuilder()
* .term('coffee')
* .and()
* .term('organic')
* ) // $search=coffee%20AND%20organic
*/
search(searchExpression: string | SearchExpressionBuilder): this;
/**
* Builds and returns the OData query string.
*
* Combines all configured query options into a properly formatted
* OData query string ready to append to an endpoint URL.
*
* @returns The complete OData query string (e.g., "?$filter=...&$top=10")
* Returns empty string if no query options are set.
* Returns "/$count..." format if count(true) was used.
*
* @example
* new OdataQueryBuilder<User>()
* .filter(f => f.where(x => x.isActive.isTrue()))
* .top(10)
* .toQuery();
* // "?$filter=isActive eq true&$top=10"
*/
toQuery(): string;
private addComponent;
}
declare const isCombinedFilter: <T>(filters: unknown) => filters is CombinedFilter<T>;
declare const isQueryFilter: <T>(filter: unknown) => filter is QueryFilter<T>;
export { OdataQueryBuilder, SearchExpressionBuilder, FilterBuilder, filter, isCombinedFilter, isQueryFilter };
export type { FilterExpression, FieldProxy, NestedFieldProxy, FilterPredicate, FilterInput, FilterBuilderLike, OrderByDescriptor, OrderByFields, QueryFilter, FilterFields, FilterOperators, LambdaFilterFields, SearchExpression, SearchTerm, SearchPhrase, CombinedFilter, Guid, ExpandFields };