Usage guide¶
This guide shows how to use Paramora in real FastAPI applications with both MongoDB and SQL backends. It also explains strict mode, loose mode, contracts, operators, sorting, pagination, aliases, required fields, and error handling.
Mental model¶
Paramora follows this pipeline:
HTTP query parameters
→ Query / QueryContract
→ parser and type coercion
→ backend-neutral QueryAst
→ emitter
→ backend-specific output
For MongoDB, the backend output is MongoQuery.
For SQL, the backend output is SqlQuery.
Your route handler receives:
where T is the backend output type.
query.output # MongoQuery, SqlQuery, or a custom emitter output
query.ast # backend-neutral AST, useful for debugging and custom tooling
Installation¶
or:
Strict mode with MongoDB¶
Strict mode is the recommended mode for public APIs. It is enabled automatically
when you pass a QueryContract to Query.
from datetime import datetime
from typing import Annotated
from fastapi import Depends, FastAPI
from paramora import CompiledQuery, MongoQuery, Query, QueryContract, query_field
app = FastAPI()
class ItemQuery(QueryContract):
status: Annotated[str, query_field("eq", "in", "nin")]
active: bool
created_at: Annotated[
datetime,
query_field("gte", "lte", sortable=True),
]
price: Annotated[float, query_field("eq", "gt", "gte", "lt", "lte")]
item_query: Query[MongoQuery] = Query(ItemQuery, default_limit=20, max_limit=100)
@app.get("/items")
def list_items(query: CompiledQuery[MongoQuery] = Depends(item_query)):
mongo = query.output
docs = (
collection
.find(mongo.filter)
.sort(mongo.sort)
.skip(mongo.offset)
.limit(mongo.limit)
)
return list(docs)
A request like this:
emits a MongoDB query object similar to:
MongoQuery(
filter={
"status": {"$in": ["free", "busy"]},
"active": True,
"price": {"$gte": 10.0},
},
sort=[("created_at", -1)],
limit=20,
offset=0,
)
Strict mode validates all of these decisions:
statusis declaredinis allowed forstatusactiveis parsed as a booleanpriceis parsed as a floatcreated_atis sortablelimitis not larger thanmax_limit
If the request contains an unknown field, unsupported operator, invalid value,
or non-sortable field, Paramora returns a structured FastAPI 422 response.
Strict mode with SQL¶
SQL support uses the same contract model. You only change the emitter.
from datetime import datetime
from typing import Annotated
from fastapi import Depends, FastAPI
from paramora import (
CompiledQuery,
Query,
QueryContract,
SqlQuery,
SqliteEmitter,
query_field,
)
app = FastAPI()
class ItemQuery(QueryContract):
status: Annotated[str, query_field("eq", "in", "nin")]
active: bool
created_at: Annotated[datetime, query_field("gte", "lte", sortable=True)]
price: Annotated[float, query_field("eq", "gte", "lte")]
item_query: Query[SqlQuery] = Query(
ItemQuery,
emitter=SqliteEmitter(),
default_limit=20,
max_limit=100,
)
@app.get("/items")
def list_items(query: CompiledQuery[SqlQuery] = Depends(item_query)):
sql = query.output
statement = sql.select_statement(
"items",
columns=("id", "status", "active", "created_at", "price"),
)
rows = connection.execute(statement.sql, statement.params).fetchall()
return [dict(row) for row in rows]
For this request:
Paramora emits:
SqlQuery(
where='"status" IN (?, ?) AND "active" = ? AND "price" <= ?',
params=("free", "busy", True, 100.0),
order_by=('"created_at" DESC',),
limit=20,
offset=0,
)
SqlQuery.where is a fragment without the leading WHERE. SqlQuery.order_by
contains fragments without the leading ORDER BY. Your application composes the
final SQL statement.
Loose mode with MongoDB¶
Loose mode is enabled when no contract is provided:
from fastapi import Depends, FastAPI
from paramora import CompiledQuery, MongoQuery, Query
app = FastAPI()
loose_query: Query[MongoQuery] = Query(default_limit=20, max_limit=100)
@app.get("/admin/items")
def list_items(query: CompiledQuery[MongoQuery] = Depends(loose_query)):
mongo = query.output
return list(
collection
.find(mongo.filter)
.sort(mongo.sort)
.skip(mongo.offset)
.limit(mongo.limit)
)
Request:
Because there is no contract, unknown fields are accepted. Values for unknown fields remain strings, except list operators split comma-separated values:
MongoQuery(
filter={
"status": "free",
"price": {"$gte": "10"},
},
sort=[("created_at", -1)],
limit=20,
offset=0,
)
Loose mode is useful for internal tools, prototypes, and trusted admin APIs. It is not recommended for public APIs because unknown field names and operators are not checked against a contract.
Loose mode is still not raw backend passthrough. Paramora rejects raw backend operator syntax such as:
Loose mode with SQL¶
Loose SQL mode can be useful for internal dashboards, but use it carefully. Unknown field names become SQL identifiers if they pass Paramora's identifier safety checks.
from paramora import CompiledQuery, Query, SqlQuery, SqliteEmitter
loose_sql_query: Query[SqlQuery] = Query(
emitter=SqliteEmitter(),
default_limit=50,
max_limit=500,
)
Request:
Possible output:
SqlQuery(
where='"status" = ? AND "price" >= ?',
params=("free", "10"),
order_by=('"created_at" DESC',),
limit=50,
offset=0,
)
For public SQL-backed endpoints, prefer strict contracts. SQL identifiers cannot be bound as parameters, so the safest API is one where every field that can become a SQL identifier is declared by your application.
Defining contract fields¶
A contract is a class that inherits from QueryContract.
Bare annotations accept only equality filters. This means active=true works,
but active__ne=true does not unless ne is explicitly allowed.
Use Annotated and query_field(...) for extra metadata:
class ItemQuery(QueryContract):
status: Annotated[str, query_field("eq", "in", "nin")]
price: Annotated[float, query_field("eq", "gt", "gte", "lt", "lte")]
created_at: Annotated[datetime, query_field("gte", "lte", sortable=True)]
Required filters¶
Required fields are useful for multi-tenant APIs or endpoints that must always receive a safety filter.
class ItemQuery(QueryContract):
tenant_id: Annotated[str, query_field(required=True)]
status: Annotated[str, query_field("eq", "in")]
If tenant_id is missing, strict mode raises:
{
"detail": [
{
"loc": ["query", "tenant_id"],
"msg": "Required filter field is missing.",
"type": "query.required"
}
]
}
Backend aliases¶
Aliases let your public query parameter name differ from the backend field name.
MongoDB example:
class ItemQuery(QueryContract):
created_at: Annotated[
datetime,
query_field("gte", "lte", sortable=True, alias="createdAt"),
]
Request:
Mongo output uses createdAt:
MongoQuery(
filter={"createdAt": {"$gte": datetime(...)}},
sort=[("createdAt", -1)],
limit=50,
offset=0,
)
SQL example:
class ItemQuery(QueryContract):
created_at: Annotated[
datetime,
query_field("gte", "lte", sortable=True, alias="items.created_at"),
]
SQL output uses a quoted identifier:
Supported types¶
Paramora currently supports these contract field types:
strintfloatbooldatetime.datetime- simple
enum.Enumsubclasses
For in and nin, Paramora parses comma-separated lists and coerces each item
using the declared field type.
from enum import Enum
from typing import Annotated
class Status(Enum):
FREE = "free"
BUSY = "busy"
class ItemQuery(QueryContract):
status: Annotated[Status, query_field("eq", "in")]
Request:
The emitted values are enum instances.
Boolean parsing¶
Boolean parsing is case-insensitive. Accepted true values:
Accepted false values:
Invalid values raise query.type_error.bool.
Datetime parsing¶
Datetime fields use standard-library ISO-8601 parsing. A trailing Z is treated
as UTC.
Invalid values raise query.type_error.datetime.
Sorting¶
Sorting uses the reserved sort query parameter.
In strict mode, the field must be declared as sortable:
class ItemQuery(QueryContract):
created_at: Annotated[datetime, query_field("gte", "lte", sortable=True)]
Fields are not sortable by default. This is intentional because sorting can have performance and indexing implications.
Pagination¶
Paramora supports limit and offset:
The Query object controls defaults and maximums:
Rules:
- missing
limitusesdefault_limit - missing
offsetuses0 limitmust be an integer greater than or equal to zerooffsetmust be an integer greater than or equal to zerolimitcannot exceedmax_limit
Direct parsing outside FastAPI¶
Query can also be used directly in tests, scripts, and service layers.
item_query: Query[MongoQuery] = Query(ItemQuery)
compiled = item_query.parse({
"status__in": "free,busy",
"price__gte": "10",
"sort": "-created_at",
})
mongo = compiled.output
Direct parsing raises QueryValidationError instead of HTTPException:
from paramora import QueryValidationError
try:
item_query.parse({"price": "not-a-number"})
except QueryValidationError as exc:
print(exc.to_list())
Choosing MongoDB or SQL¶
Use the default emitter for MongoDB:
Use SqliteEmitter or PostgresEmitter for raw SQL fragments:
The route typing follows the configured backend output:
def list_items(query: CompiledQuery[MongoQuery] = Depends(mongo_query)):
mongo = query.output
def list_items_sql(query: CompiledQuery[SqlQuery] = Depends(sql_query)):
sql = query.output
Security guidance¶
For public APIs:
- prefer strict contracts
- explicitly allow only the operators you need
- make fields sortable only when there is an index or a clear product need
- use required tenant/user filters when appropriate
- keep raw MongoDB operators and raw SQL out of request syntax
- pass SQL values as bound parameters, never string-format them into SQL
Loose mode is helpful, but it should be used deliberately.