Skip to content

Query Builder

The QueryBuilder provides a fluent API for constructing OData queries.

Basic Usage

from fc_selector.django.selector import QueryBuilder

query = QueryBuilder()
query.filter("status eq 'published'")
query.select("id", "title")
query.top(10)

# Get the query string
query_string = query.build_query_string()
# "$filter=status eq 'published'&$select=id,title&$top=10"

Method Chaining

All methods return self, enabling fluent chaining:

query = (
    QueryBuilder()
    .filter("status eq 'published'")
    .select("id", "title", "author")
    .expand("author")
    .orderby("created_at desc")
    .top(10)
    .skip(20)
)

Initialize from Existing Query

Parse an existing OData query string:

# From request query string
query = QueryBuilder(request.META['QUERY_STRING'])

# Add more filters
query.and_filter("featured eq true")

Filter Methods

filter()

Set or replace the $filter:

query.filter("status eq 'published'")
# $filter=status eq 'published'

and_filter()

Add an AND condition:

query = QueryBuilder()
query.filter("status eq 'published'")
query.and_filter("featured eq true")
# $filter=(status eq 'published') and (featured eq true)

or_filter()

Add an OR condition:

query = QueryBuilder()
query.filter("status eq 'published'")
query.or_filter("status eq 'featured'")
# $filter=(status eq 'published') or (status eq 'featured')

Select

Choose which fields to return:

# Multiple arguments
query.select("id", "title", "status")

# Comma-separated string
query.select("id,title,status")

# Both produce: $select=id,title,status

Expand

Include related entities:

# Multiple arguments
query.expand("author", "categories")

# Comma-separated string
query.expand("author,categories")

# Both produce: $expand=author,categories

Order By

Sort results:

# Single field
query.orderby("created_at desc")

# Multiple fields
query.orderby("featured desc", "created_at desc")

# Comma-separated
query.orderby("featured desc,created_at desc")

Pagination

top()

Limit the number of results:

query.top(10)
# $top=10

skip()

Skip a number of results:

query.skip(20)
# $skip=20

Combined pagination

# Page 3 with 10 items per page
query.top(10).skip(20)
# $top=10&$skip=20

Count

Request total count:

query.count(True)
# $count=true

Output Methods

build_query_string()

Get the OData query string:

query = QueryBuilder().filter("status eq 'published'").top(10)
query.build_query_string()
# "$filter=status eq 'published'&$top=10"

to_dict()

Get as dictionary:

query = QueryBuilder().filter("status eq 'published'").top(10)
query.to_dict()
# {'$filter': "status eq 'published'", '$top': '10'}

String representation

str(query)  # Same as build_query_string()
repr(query) # "QueryBuilder('$filter=status eq 'published'&$top=10')"

Common Patterns

Build query from request and add filters

def get_posts(request, author_id: int = None):
    query = QueryBuilder(request.META.get('QUERY_STRING', ''))

    # Always filter by published
    query.and_filter("status eq 'published'")

    # Optional author filter
    if author_id:
        query.and_filter(f"author/id eq {author_id}")

    return selector.get_many(query)

Reusable query builders

def published_posts_query():
    return QueryBuilder().filter("status eq 'published'")

def featured_query():
    return QueryBuilder().filter("featured eq true")

# Combine
query = published_posts_query()
query.and_filter("featured eq true")

Dynamic field selection

def get_posts(request, include_content: bool = False):
    query = QueryBuilder(request.META.get('QUERY_STRING', ''))

    fields = ["id", "title", "status"]
    if include_content:
        fields.append("content")

    query.select(*fields)
    return selector.get_many(query)

Complete Example

from fc_selector.django.selector import QueryBuilder

# Build a complex query
query = (
    QueryBuilder()
    .filter("status eq 'published' and rating gt 4.0")
    .select("id", "title", "rating", "author")
    .expand("author")
    .orderby("rating desc", "created_at desc")
    .top(10)
    .skip(0)
    .count(True)
)

# Use with selector
selector = BlogPostSelector()
posts = selector.get_many(query)

# Or get the query string for API
query_string = query.build_query_string()
# "$filter=status eq 'published' and rating gt 4.0&$select=id,title,rating,author&$expand=author&$orderby=rating desc,created_at desc&$top=10&$skip=0&$count=true"

Type-Safe Fluent API

For better IDE support, type safety, and reduced errors, use the fluent API classes instead of strings.

Imports

from fc_selector.django.selector import QueryBuilder
from fc_selector.core.filters import Field, Expand, OrderBy

Field - Type-Safe Filters

Build filter expressions with full IDE autocompletion:

# Comparisons
Field("name").eq("John")           # name eq 'John'
Field("age").gt(18)                # age gt 18
Field("price").ge(100)             # price ge 100
Field("count").lt(10)              # count lt 10
Field("rating").le(5)              # rating le 5
Field("status").ne("deleted")      # status ne 'deleted'

# Null checks
Field("deleted_at").is_null()      # deleted_at eq null
Field("email").is_not_null()       # email ne null

# String operations
Field("name").contains("john")     # contains(name, 'john')
Field("email").startswith("admin") # startswith(email, 'admin')
Field("email").endswith("@x.com")  # endswith(email, '@x.com')
Field("code").matches("^[A-Z]+$")  # matchesPattern(code, '^[A-Z]+$')

# Collections
Field("status").is_in(["active", "pending"])  # status in ('active', 'pending')
Field("role").not_in(["guest", "banned"])     # not (role in ('guest', 'banned'))

# Range
Field("price").between(10, 100)    # price ge 10 and price le 100

# Nested fields
Field("author.name").eq("John")    # author/name eq 'John'
Field("author").profile.city.eq("Barcelona")  # author/profile/city eq 'Barcelona'

Logical Operators

Combine expressions with Python operators:

# AND: use &
expr = Field("status").eq("active") & Field("age").gt(18)

# OR: use |
expr = Field("role").eq("admin") | Field("role").eq("superuser")

# NOT: use ~
expr = ~Field("deleted").eq(True)

# Complex expressions with parentheses
expr = (
    (Field("status").eq("active") & Field("age").gt(18))
    | Field("vip").eq(True)
)

where() - Type-Safe Filter Method

Use where() instead of filter() for type-safe expressions:

query = (
    QueryBuilder()
    .where(Field("status").eq("active") & Field("age").gt(18))
    .select("id", "name")
    .top(10)
)

and_where() / or_where()

Add conditions to existing filters:

# Combine with existing filter
query = (
    QueryBuilder("$filter=base eq 'value'")
    .and_where(Field("extra").gt(5))
    .or_where(Field("override").eq(True))
)

Expand - Type-Safe Relations

Build expand expressions with nested options:

# Simple expand
Expand("author")

# Expand with nested select
Expand("author").select("id", "name", "email")

# Expand with nested filter
Expand("comments").filter(Field("approved").eq(True))

# Expand with pagination and ordering
Expand("comments").top(5).skip(10).orderby(OrderBy("created_at").desc())

# Nested expand
Expand("author").expand(
    Expand("profile").select("avatar", "bio")
)

# Full example
Expand("comments")
    .select("id", "text", "created_at")
    .filter(Field("approved").eq(True))
    .orderby(OrderBy("created_at").desc())
    .top(5)

OrderBy - Type-Safe Ordering

# Ascending (default)
OrderBy("name")
OrderBy("name").asc()

# Descending
OrderBy("created_at").desc()

# Multiple in query
query.orderby(
    OrderBy("featured").desc(),
    OrderBy("created_at").desc(),
    OrderBy("title").asc()
)

Complete Type-Safe Example

from fc_selector.django.selector import QueryBuilder
from fc_selector.core.filters import Field, Expand, OrderBy

# Build a fully type-safe query
intent = (
    QueryBuilder()
    .where(
        Field("status").eq("published") &
        Field("rating").gt(4.0)
    )
    .select("id", "title", "rating")
    .expand(
        Expand("author").select("id", "name", "avatar"),
        Expand("comments")
            .filter(Field("approved").eq(True))
            .orderby(OrderBy("created_at").desc())
            .top(5)
    )
    .orderby(
        OrderBy("rating").desc(),
        OrderBy("created_at").desc()
    )
    .top(10)
    .build()
)

# Execute directly (protocol-agnostic)
selector = BlogPostSelector()
results = selector.execute(intent)

QueryIntent - Protocol-Agnostic Output

The build() method returns a QueryIntent object instead of a string. This decouples your code from the OData protocol:

# Build returns QueryIntent (not string)
intent = query.build()

# Execute directly without parsing
results = selector.execute(intent)

# Or convert to OData string if needed
odata_string = query.build_query_string()

Mixing String and Fluent APIs

Both APIs can be combined:

query = (
    QueryBuilder("$filter=legacy eq true")  # Parse existing query
    .and_where(Field("new_field").gt(5))         # Add type-safe filter
    .select("id", "name")                         # String-based select
    .expand(Expand("author").select("name"))      # Type-safe expand
    .orderby("created_at desc")                   # String-based orderby
    .top(10)
    .build()
)