pub struct QueryDefinition {Show 26 fields
pub name: String,
pub return_type: String,
pub returns_list: bool,
pub returns_count: bool,
pub nullable: bool,
pub arguments: Vec<ArgumentDefinition>,
pub sql_source: Option<String>,
pub function: Option<String>,
pub description: Option<String>,
pub auto_params: AutoParams,
pub deprecation: Option<DeprecationInfo>,
pub jsonb_column: String,
pub relay: bool,
pub relay_cursor_column: Option<String>,
pub relay_cursor_type: CursorType,
pub pagination_order: Option<PaginationOrder>,
pub inject_params: IndexMap<String, InjectedParamSource>,
pub read_routing: ReadRouting,
pub cache_ttl_seconds: Option<u64>,
pub additional_views: Vec<String>,
pub requires_role: Option<String>,
pub requires_actor: Vec<ActorType>,
pub rest_path: Option<String>,
pub rest_method: Option<String>,
pub rest_stream: bool,
pub native_columns: HashMap<String, String>,
}Expand description
A query definition compiled from @fraiseql.query.
Queries are declarative bindings to database views/tables. They describe what to fetch, not how to fetch it.
§Example
use fraiseql_core::schema::QueryDefinition;
let query = QueryDefinition::new("users", "User");Fields§
§name: StringQuery name (e.g., “users”).
return_type: StringReturn type name (e.g., “User”).
returns_list: boolDoes this query return a list?
returns_count: boolDoes this query return COUNT(*) over sql_source
instead of rows?
Compiled as the sibling of a list query that opted in (count = true),
named <query>Count and exposed as Int!. It exists because a bare
[T] list has nowhere to hang a total, so an offset-paginated client had
no way to learn how many rows match its filter — totalCount was
reachable only through a Relay connection, and a Relay connection is
keyset-only, so obtaining the count cost random access (#938).
return_type stays the entity type rather than
Int: the filter machinery is keyed on it (where input types, native
column casts, RLS policy lookup), and re-pointing it at a scalar would
silently detach the count from the filters whose rows it is counting.
Only the rendered GraphQL return type differs.
The count reflects the full filtered set, independent of limit/offset
— which is the entire point — so the sibling carries where alone and
no pagination arguments.
nullable: boolIs the return value nullable?
arguments: Vec<ArgumentDefinition>Query arguments.
sql_source: Option<String>SQL source table/view (for direct table queries).
function: Option<String>The declared function that answers this root field, in place of reading a relation (#1329).
Names an entry in the compiled functions section whose trigger is
request:query. The engine invokes it with this field’s arguments, as the
requesting principal, and projects its result through this query’s
return_type — so a function-backed field is introspectable, typed, and
governed by the same requires_role / requires_actor / field-RBAC rules
as any other root field.
§Root fields only
There is no nested-field equivalent, deliberately. A function attached to a field inside a type would be invoked once per row, which is an N+1 in isolates rather than in queries — measurably worse. Bounding it to root fields makes “one invocation per query” a property of the shape rather than a guideline, and the compiler refuses the nested spelling rather than documenting against it.
§Mutually exclusive with sql_source
A field resolves one way. The compiler refuses a query declaring both, and refuses one declaring neither where the SQL path needs a relation — there is no precedence rule to remember and no silent winner.
§Cost
An invocation costs ~5–8 ms on top of the work the function itself does,
which is the same order as this repository’s documented cold read and
roughly 5× a cache hit. That is a defensible price for computation SQL
cannot express and a poor one for anything a view could answer; it is
stated here, and in docs/architecture/functions.md, so the choice is made
knowingly.
A function-backed field is not cached: the result cache keys and invalidates
rows of a relation, and this field reads none, so every request runs the
function. cache_ttl_seconds and
additional_views are compile errors beside a
function rather than settings that are accepted and ignored.
description: Option<String>Description.
auto_params: AutoParamsAuto-wired parameters (where, orderBy, limit, offset).
deprecation: Option<DeprecationInfo>Deprecation information (from @deprecated directive). When set, this query is marked as deprecated in the schema.
jsonb_column: StringJSONB column name (e.g., “data”). Used to extract data from JSONB columns in query results.
relay: boolWhether this query is a Relay connection query.
When true, the compiler wraps the result in XxxConnection with
edges { cursor node { ... } } and pageInfo fields, using keyset
pagination on pk_{snake_case(return_type)} (BIGINT).
relay_cursor_column: Option<String>Keyset pagination column for relay queries.
Derived from the return type name: User → pk_user.
This BIGINT column lives in the view (sql_source) and is used as the
stable sort key for cursor-based keyset pagination:
- Forward:
WHERE {col} > $cursor ORDER BY {col} ASC LIMIT $first - Backward:
WHERE {col} < $cursor ORDER BY {col} DESC LIMIT $last
Only set when relay = true.
relay_cursor_type: CursorTypeType of the keyset cursor column.
Defaults to Int64 for backward compatibility with schemas that use pk_{type}
BIGINT columns. Set to Uuid when the cursor column has a UUID type.
Only meaningful when relay = true.
pagination_order: Option<PaginationOrder>The order an offset-paginated read of this query falls back to when the client requests none (#1303).
Resolved at compile time for every list query that paginates, and None
for every query that does not — plus the one that does and whose author
declared pagination_order = "none", which is how a view carrying its
own ORDER BY keeps it. That opt-out is declared rather than inferred:
the compiler cannot see a view’s body, and ordering unconditionally would
silently destroy the escape hatch its own auto_param_warnings tells
authors to use.
See PaginationOrder for why this is decided here and not by the SQL
layer, and for what each variant costs.
inject_params: IndexMap<String, InjectedParamSource>Server-side parameters injected from JWT claims at runtime.
Keys are SQL column names. Values describe where to source the runtime value. These params are NOT exposed as GraphQL arguments.
For queries: adds a WHERE key = $value condition per entry using the same
WhereClause mechanism as TenantEnforcer. Works on all adapters.
Clients cannot override these values.
read_routing: ReadRoutingWhere this query’s reads may be served from (#957).
Read-replica routing is otherwise a whole-server decision: the pin window and the staleness budget apply to every query alike, so an operator sizing them for the strictest query gives up the offload on all the others, and sizing them for the common case silently serves the strict one stale.
FraiseQL defines and enforces this shape; an authoring language emits it —
A @reads_from(...) directive is one spelling of it.
Replica topology deliberately stays out of the compiled artifact: URLs
are server configuration and secrets.
See ReadRouting for what each answer guarantees, including why
primary also bypasses the result cache.
cache_ttl_seconds: Option<u64>Per-query result cache TTL in seconds.
Overrides the global CacheConfig::ttl_seconds for this query’s view.
Common use-cases:
- Reference data (countries, currencies):
3600(1 h) - Live / real-time data:
0(bypass cache entirely)
None → use the global cache TTL.
additional_views: Vec<String>Additional database views this query reads beyond the primary sql_source.
When this query JOINs or queries multiple views, list all secondary views here so that mutations touching those views correctly invalidate this query’s cache entries.
Without this list, only sql_source is registered for invalidation. Any mutation
that modifies a secondary view will NOT invalidate this query’s cache — silently
serving stale data.
Each entry must be a valid SQL identifier (letters, digits, _) validated by the
CLI compiler at schema compile time.
§Example
@fraiseql.query(
sql_source="v_user_with_posts",
additional_views=["v_post"],
)
def users_with_posts() -> list[UserWithPosts]: ...requires_role: Option<String>Role required to execute this query and see it in introspection.
When set, only users with this role can discover and execute this query.
Users without the role receive "Unknown query" (not FORBIDDEN)
to prevent role enumeration.
requires_actor: Vec<ActorType>Actor classes permitted to execute this query — an allow-list (#966).
Empty (the default) means unrestricted. Non-empty means the request’s
derived ActorType must be one of these, or
the operation is refused — regardless of what roles the caller holds. That
independence is the point: “autonomous agents may not read this” is a
statement about the kind of principal, not about its permissions.
§An allow-list, not a deny-list
A deny-list would admit every actor class added after it was written. An
allow-list refuses them, which is the fail-closed direction: a new
ActorType variant is in nobody’s list until
an author puts it there.
§Delegation is deliberately not consulted
For a delegated request (RFC 8693: an agent acting for a human), the
classification is AiAgent and the check does not fall back to the
permissions of the human in acting_for. Composite authorization already
happens through roles — a delegated token carries the human’s roles, so
requires_role consults them — and having requires_actor do the same
would make it a no-op for exactly the case it exists for.
§Anonymous
A request with no security context has no classification, so a non-empty
list refuses it. Fail-closed, and it does not depend on
ActorType::default being the benign variant.
rest_path: Option<String>Custom REST path override (e.g., "/users/{id}/posts").
rest_method: Option<String>REST HTTP method override (e.g., "GET").
rest_stream: boolWhether this query may be exported as a stream over REST (#958).
Default false. Accept: application/x-ndjson, text/csv and the XLSX type
are refused with 406 on a route that does not opt in — the JSON
representation is unaffected.
§Why a per-route opt-in rather than a transport-wide switch
A streamed export is not a bigger page. It reads the whole filtered
relation, bypasses max_page_size by construction (an export total is not a
page), and holds a pooled database connection for as long as the client takes
to read it. Those are reasonable properties for a route meant to hand over a
dataset and unreasonable ones for the other kind of route, which is most of
them — so which routes have them is the schema author’s decision, made per
route, rather than a consequence of the REST transport being on.
@fraiseql.query(sql_source="v_invoice", rest_stream=True)
def invoices() -> list[Invoice]: ...native_columns: HashMap<String, String>Native columns detected at compile time for direct query arguments.
Maps argument name → PostgreSQL cast suffix (e.g., "uuid", "int4", "").
An empty string means the column exists but needs no type cast (e.g. text).
At runtime, arguments present in this map generate WHERE col = $N (native column
lookup) instead of WHERE data->>'col' = $N (JSONB extraction), enabling B-tree
index usage for single-entity lookups.
Only populated when fraiseql compile --database <url> is used. Schemas compiled
without a database URL omit this field and fall back to JSONB extraction.
Implementations§
Source§impl QueryDefinition
impl QueryDefinition
Sourcepub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self
pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self
Create a new query definition.
Sourcepub const fn returning_list(self) -> Self
pub const fn returning_list(self) -> Self
Set this query to return a list.
Sourcepub fn with_sql_source(self, source: impl Into<String>) -> Self
pub fn with_sql_source(self, source: impl Into<String>) -> Self
Set the SQL source.
Sourcepub fn with_function(self, function: impl Into<String>) -> Self
pub fn with_function(self, function: impl Into<String>) -> Self
Back this root field with a declared function instead of a relation (#1329).
See function for what the engine does with the name and
what it costs.
Sourcepub fn count_sibling(&self) -> Self
pub fn count_sibling(&self) -> Self
Derive the <name>Count sibling of this list query (#938).
This is the only way a count query is built. A sibling query that
answers “how many rows match?” against the same view is a second door
onto the same rows, and a second door that forgets one of the first’s
restrictions is an oracle: a count that ignores inject_params reports
other tenants’ row totals, and one that ignores requires_role answers
callers who cannot see the list at all. Neither leaks a row, which is
exactly why it would survive review — so the inheritance is centralised
here rather than spelled out at each construction site (the _entities
lesson, #1030).
Inherited, deliberately: the entity return_type and sql_source (same
rows), inject_params (same tenant scoping), requires_role (same
visibility), explicitly declared arguments (they lower into the same
WHERE), native_columns (same casts, so the count uses the same
indexes), additional_views (same cache invalidation) and deprecation.
Dropped, deliberately: limit/offset/orderBy — a total that moved
with the page would answer a question nobody asked; pagination_order
with them, since a scalar total has no rows to order; relay, which has
its own totalCount; and the REST overrides, since the REST surface
already counts through Prefer: count=exact.
function is dropped for a different reason than the rest: a count is
SELECT COUNT(*) over a relation, and a function-backed field has none —
which is why count = true beside function is a compile error (#1329).
Dropping it here means a sibling hand-built through this constructor fails
as “no SQL source” rather than invoking the function and projecting its
object as a scalar total.
Sourcepub fn deprecated(self, reason: Option<String>) -> Self
pub fn deprecated(self, reason: Option<String>) -> Self
Mark this query as deprecated.
§Example
use fraiseql_core::schema::QueryDefinition;
let query = QueryDefinition::new("oldUsers", "User")
.deprecated(Some("Use 'users' instead".to_string()));
assert!(query.is_deprecated());Sourcepub const fn is_deprecated(&self) -> bool
pub const fn is_deprecated(&self) -> bool
Check if this query is deprecated.
Sourcepub fn deprecation_reason(&self) -> Option<&str>
pub fn deprecation_reason(&self) -> Option<&str>
Get the deprecation reason if deprecated.
Sourcepub fn graphql_arguments(
&self,
schema: &CompiledSchema,
) -> Vec<ArgumentDefinition>
pub fn graphql_arguments( &self, schema: &CompiledSchema, ) -> Vec<ArgumentDefinition>
The full set of GraphQL arguments this query accepts, for rendering into
the federation _service SDL, generated clients, and introspection.
The auto-wired where/orderBy/limit/offset arguments are gated by
auto_params and read directly from the argument map
at runtime, so they are deliberately not stored in
arguments (where the runtime would otherwise mistake a
synthesized limit/offset for an explicit column filter). This method
materialises them so every consumer that renders from the argument list can
surface — and a generated client can actually pass — them.
where/orderBy are typed against the derived filter surface —
{Entity}WhereInput and [{Entity}OrderByInput!] — when schema carries
those types, and fall back to the JSON scalar when it does not. Either
way the runtime parses the raw value via WhereClause::from_graphql_json
/ OrderByClause::from_graphql_json; the type is what clients see, not
what the executor reads.
§Why this takes a schema
The published type of an auto-wired argument is a property of the schema,
not of the query alone: derived_inputs::derive emits
{Entity}WhereInput only for a return type whose fields the schema can
adjudicate, and skips any name the author already declared. Reading the
answer from the same place that produced it is what makes a dangling type
reference unrepresentable — the argument is typed iff the type exists.
A cached answer on QueryDefinition would let a caller publish the name
without the schema that justifies it.
An explicit argument always wins: if the query already declares an argument of the same name it is left untouched and no duplicate is synthesized.
Relay connection queries are returned unchanged — their pagination surface
(first/after/last/before) is owned by each renderer’s dedicated
relay path, not by auto_params.
Sourcepub fn accepted_argument_names(&self, schema: &CompiledSchema) -> Vec<String>
pub fn accepted_argument_names(&self, schema: &CompiledSchema) -> Vec<String>
The argument names a client document may write on this field
(GraphQL § 5.4.1), for validate_argument_names.
graphql_arguments is what introspection
publishes; this is what the runtime actually reads, which is wider in
two places:
- A relay connection’s cursor window (
first/after/last/before) is owned by each renderer’s relay path rather than byauto_params, and the relay runner also readswhere/orderByfrom the merged argument map when the matching auto-param is on. nearest(#386, #959) is a runtime-only similarity-search argument. Accepting it here is what lets its own diagnostics — not eligible on relay, needs a list query, unknown metric, wrong dimension — be what the client sees, instead of a blanket “unknown argument”. It is refused on a count sibling, which never reads it.