fraiseql_core/schema/compiled/query.rs
1use std::collections::HashMap;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use super::argument::{ArgumentDefinition, AutoParams};
7use crate::{
8 backend::types::ReadRouting,
9 schema::{
10 field_type::{DeprecationInfo, FieldType},
11 graphql_type_defs::default_jsonb_column,
12 security_config::InjectedParamSource,
13 },
14};
15
16/// The type of column used as the keyset cursor for relay pagination.
17///
18/// Determines how the cursor value is encoded/decoded and how the SQL comparison
19/// is emitted (`bigint` vs `uuid` cast).
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum CursorType {
24 /// BIGINT / INTEGER column (default, backward-compatible).
25 /// Cursor is `base64(decimal_string)`.
26 #[default]
27 Int64,
28 /// UUID column.
29 /// Cursor is `base64(uuid_string)`.
30 Uuid,
31}
32
33pub(super) fn is_default_cursor_type(ct: &CursorType) -> bool {
34 *ct == CursorType::Int64
35}
36
37/// The total order a `LIMIT`/`OFFSET` page falls back to when the client asked
38/// for none (#1303).
39///
40/// A page is a *slice of a sequence*, and a read with no `ORDER BY` is not a
41/// sequence: PostgreSQL may return the same relation in a different physical
42/// order for each page, so a client walking `?offset=` sees some rows twice and
43/// never sees others — under `200`, with no error anywhere. #1287 closed the
44/// half of that where the client *did* order and the ordering was not total;
45/// this closes the half where it ordered by nothing at all.
46///
47/// # Why the compiler decides and not the SQL layer
48///
49/// A tie-breaker and a default ordering are different things. A tie-breaker has
50/// no semantic content — any total order disambiguates ties *inside* an ordering
51/// the client chose — so the SQL layer may pick one unaided. A default ordering
52/// decides what "page 2" **means** when nobody said, which is the schema
53/// author's call, and choosing well needs the schema: measured against
54/// PostgreSQL 16 over 200 000 rows at a deep offset, `data->>'id'` costs 7.7×
55/// the unordered plan, a native `id` column 4.2×, and `pk_<type>` 1.7×. The
56/// only one of the three a request-time rule can reach without help is the
57/// worst.
58///
59/// This is why the field is derived at compile time and sits beside
60/// [`relay_cursor_column`](QueryDefinition::relay_cursor_column): the other
61/// pagination family already declares its ordering key in the compiled schema,
62/// for the same reason.
63///
64/// # Deliberately not `#[non_exhaustive]`
65///
66/// A downstream `match` over this enum must be exhaustive, so a third way to
67/// reach an identity is a compile error at every site that lowers one rather
68/// than a wildcard arm that silently renders nothing.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum PaginationOrder {
72 /// `ORDER BY data->>'id' ASC` — the entity identity extracted from the JSONB
73 /// document as text.
74 ///
75 /// Correct for every type under ADR-0017, which is why it is the answer a
76 /// compile with no `--database` can always give: `id` is an enforced
77 /// invariant, and comparing it as text serves uuid, integer and text keys
78 /// alike without knowing which one a type declares. It is also the most
79 /// expensive of the three — no index applies — so a compile that *can*
80 /// introspect the relation replaces it with [`Column`](Self::Column).
81 JsonIdentity,
82 /// `ORDER BY <column> ASC` — a native column on the relation.
83 ///
84 /// Either declared by the author or discovered by introspection
85 /// (`pk_<type>`, then `id`). The column must be **unique** over the
86 /// relation: a non-unique one leaves the order partial, which is the defect
87 /// this field exists to remove rather than a cheaper way to fix it. Nothing
88 /// in the catalog reports uniqueness for a view, so a declared column is
89 /// checked for existence and taken on trust.
90 Column(String),
91}
92
93/// A query definition compiled from `@fraiseql.query`.
94///
95/// Queries are declarative bindings to database views/tables.
96/// They describe *what* to fetch, not *how* to fetch it.
97///
98/// # Example
99///
100/// ```
101/// use fraiseql_core::schema::QueryDefinition;
102///
103/// let query = QueryDefinition::new("users", "User");
104/// ```
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct QueryDefinition {
107 /// Query name (e.g., "users").
108 pub name: String,
109
110 /// Return type name (e.g., "User").
111 pub return_type: String,
112
113 /// Does this query return a list?
114 #[serde(default)]
115 pub returns_list: bool,
116
117 /// Does this query return `COUNT(*)` over [`sql_source`](Self::sql_source)
118 /// instead of rows?
119 ///
120 /// Compiled as the sibling of a list query that opted in (`count = true`),
121 /// named `<query>Count` and exposed as `Int!`. It exists because a bare
122 /// `[T]` list has nowhere to hang a total, so an offset-paginated client had
123 /// no way to learn how many rows match its filter — `totalCount` was
124 /// reachable only through a Relay connection, and a Relay connection is
125 /// keyset-only, so obtaining the count cost random access (#938).
126 ///
127 /// [`return_type`](Self::return_type) stays the **entity** type rather than
128 /// `Int`: the filter machinery is keyed on it (`where` input types, native
129 /// column casts, RLS policy lookup), and re-pointing it at a scalar would
130 /// silently detach the count from the filters whose rows it is counting.
131 /// Only the *rendered* GraphQL return type differs.
132 ///
133 /// The count reflects the full filtered set, independent of `limit`/`offset`
134 /// — which is the entire point — so the sibling carries `where` alone and
135 /// no pagination arguments.
136 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
137 pub returns_count: bool,
138
139 /// Is the return value nullable?
140 #[serde(default)]
141 pub nullable: bool,
142
143 /// Query arguments.
144 #[serde(default)]
145 pub arguments: Vec<ArgumentDefinition>,
146
147 /// SQL source table/view (for direct table queries).
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub sql_source: Option<String>,
150
151 /// The declared function that answers this root field, in place of reading a
152 /// relation (#1329).
153 ///
154 /// Names an entry in the compiled `functions` section whose trigger is
155 /// `request:query`. The engine invokes it with this field's arguments, as the
156 /// requesting principal, and projects its result through this query's
157 /// `return_type` — so a function-backed field is introspectable, typed, and
158 /// governed by the same `requires_role` / `requires_actor` / field-RBAC rules
159 /// as any other root field.
160 ///
161 /// # Root fields only
162 ///
163 /// There is no nested-field equivalent, deliberately. A function attached to a
164 /// field *inside* a type would be invoked once per row, which is an N+1 in
165 /// isolates rather than in queries — measurably worse. Bounding it to root
166 /// fields makes "one invocation per query" a property of the shape rather than
167 /// a guideline, and the compiler refuses the nested spelling rather than
168 /// documenting against it.
169 ///
170 /// # Mutually exclusive with [`sql_source`](Self::sql_source)
171 ///
172 /// A field resolves one way. The compiler refuses a query declaring both, and
173 /// refuses one declaring neither where the SQL path needs a relation — there is
174 /// no precedence rule to remember and no silent winner.
175 ///
176 /// # Cost
177 ///
178 /// An invocation costs ~5–8 ms on top of the work the function itself does,
179 /// which is the same order as this repository's documented cold read and
180 /// roughly 5× a cache hit. That is a defensible price for computation SQL
181 /// cannot express and a poor one for anything a view could answer; it is
182 /// stated here, and in `docs/architecture/functions.md`, so the choice is made
183 /// knowingly.
184 ///
185 /// The **response** cache covers a function-backed field on the same terms as any
186 /// other read, at the server's global TTL, and
187 /// [`additional_views`](Self::additional_views) is how the field declares which
188 /// writes must evict it — there is no `sql_source` for the invalidator to infer
189 /// one from. [`cache_ttl_seconds`](Self::cache_ttl_seconds) is a *row*-cache TTL,
190 /// keyed by view, so it has nothing to apply to here and is a compile error beside
191 /// a `function` rather than a number that is accepted and ignored.
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub function: Option<String>,
194
195 /// Description.
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub description: Option<String>,
198
199 /// Auto-wired parameters (where, orderBy, limit, offset).
200 #[serde(default)]
201 pub auto_params: AutoParams,
202
203 /// Deprecation information (from @deprecated directive).
204 /// When set, this query is marked as deprecated in the schema.
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub deprecation: Option<DeprecationInfo>,
207
208 /// JSONB column name (e.g., "data").
209 /// Used to extract data from JSONB columns in query results.
210 #[serde(default = "default_jsonb_column")]
211 pub jsonb_column: String,
212
213 /// Whether this query is a Relay connection query.
214 ///
215 /// When `true`, the compiler wraps the result in `XxxConnection` with
216 /// `edges { cursor node { ... } }` and `pageInfo` fields, using keyset
217 /// pagination on `pk_{snake_case(return_type)}` (BIGINT).
218 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
219 pub relay: bool,
220
221 /// Keyset pagination column for relay queries.
222 ///
223 /// Derived from the return type name: `User` → `pk_user`.
224 /// This BIGINT column lives in the view (`sql_source`) and is used as the
225 /// stable sort key for cursor-based keyset pagination:
226 /// - Forward: `WHERE {col} > $cursor ORDER BY {col} ASC LIMIT $first`
227 /// - Backward: `WHERE {col} < $cursor ORDER BY {col} DESC LIMIT $last`
228 ///
229 /// Only set when `relay = true`.
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub relay_cursor_column: Option<String>,
232
233 /// Type of the keyset cursor column.
234 ///
235 /// Defaults to `Int64` for backward compatibility with schemas that use `pk_{type}`
236 /// BIGINT columns. Set to `Uuid` when the cursor column has a UUID type.
237 ///
238 /// Only meaningful when `relay = true`.
239 #[serde(default, skip_serializing_if = "is_default_cursor_type")]
240 pub relay_cursor_type: CursorType,
241
242 /// The order an offset-paginated read of this query falls back to when the
243 /// client requests none (#1303).
244 ///
245 /// Resolved at compile time for every list query that paginates, and `None`
246 /// for every query that does not — plus the one that does and whose author
247 /// declared `pagination_order = "none"`, which is how a view carrying its
248 /// own `ORDER BY` keeps it. That opt-out is *declared* rather than inferred:
249 /// the compiler cannot see a view's body, and ordering unconditionally would
250 /// silently destroy the escape hatch its own `auto_param_warnings` tells
251 /// authors to use.
252 ///
253 /// See [`PaginationOrder`] for why this is decided here and not by the SQL
254 /// layer, and for what each variant costs.
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub pagination_order: Option<PaginationOrder>,
257
258 /// Server-side parameters injected from JWT claims at runtime.
259 ///
260 /// Keys are SQL column names. Values describe where to source the runtime value.
261 /// These params are NOT exposed as GraphQL arguments.
262 ///
263 /// For queries: adds a `WHERE key = $value` condition per entry using the same
264 /// `WhereClause` mechanism as `TenantEnforcer`. Works on all adapters.
265 ///
266 /// Clients cannot override these values.
267 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
268 pub inject_params: IndexMap<String, InjectedParamSource>,
269
270 /// Where this query's reads may be served from (#957).
271 ///
272 /// Read-replica routing is otherwise a whole-server decision: the pin window
273 /// and the staleness budget apply to every query alike, so an operator sizing
274 /// them for the strictest query gives up the offload on all the others, and
275 /// sizing them for the common case silently serves the strict one stale.
276 ///
277 /// FraiseQL defines and enforces this shape; an authoring language emits it —
278 /// A `@reads_from(...)` directive is one spelling of it.
279 /// Replica **topology** deliberately stays out of the compiled artifact: URLs
280 /// are server configuration and secrets.
281 ///
282 /// See [`ReadRouting`] for what each answer guarantees, including why
283 /// `primary` also bypasses the result cache.
284 #[serde(default, skip_serializing_if = "ReadRouting::is_default")]
285 pub read_routing: ReadRouting,
286
287 /// Per-query result cache TTL in seconds.
288 ///
289 /// Overrides the global `CacheConfig::ttl_seconds` for this query's view.
290 /// Common use-cases:
291 /// - Reference data (countries, currencies): `3600` (1 h)
292 /// - Live / real-time data: `0` (bypass cache entirely)
293 ///
294 /// `None` → use the global cache TTL.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub cache_ttl_seconds: Option<u64>,
297
298 /// Additional database views this query reads beyond the primary `sql_source`.
299 ///
300 /// When this query JOINs or queries multiple views, list all secondary views here
301 /// so that mutations touching those views correctly invalidate this query's cache
302 /// entries.
303 ///
304 /// Without this list, only `sql_source` is registered for invalidation. Any mutation
305 /// that modifies a secondary view will NOT invalidate this query's cache — silently
306 /// serving stale data.
307 ///
308 /// Each entry must be a valid SQL identifier (letters, digits, `_`) validated by the
309 /// CLI compiler at schema compile time.
310 ///
311 /// # Example
312 ///
313 /// ```python
314 /// @fraiseql.query(
315 /// sql_source="v_user_with_posts",
316 /// additional_views=["v_post"],
317 /// )
318 /// def users_with_posts() -> list[UserWithPosts]: ...
319 /// ```
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub additional_views: Vec<String>,
322
323 /// Role required to execute this query and see it in introspection.
324 ///
325 /// When set, only users with this role can discover and execute this query.
326 /// Users without the role receive `"Unknown query"` (not `FORBIDDEN`)
327 /// to prevent role enumeration.
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub requires_role: Option<String>,
330
331 /// Actor classes permitted to execute this query — an **allow-list** (#966).
332 ///
333 /// Empty (the default) means unrestricted. Non-empty means the request's
334 /// derived [`ActorType`](crate::security::ActorType) must be one of these, or
335 /// the operation is refused — regardless of what roles the caller holds. That
336 /// independence is the point: "autonomous agents may not read this" is a
337 /// statement about the *kind* of principal, not about its permissions.
338 ///
339 /// # An allow-list, not a deny-list
340 ///
341 /// A deny-list would admit every actor class added after it was written. An
342 /// allow-list refuses them, which is the fail-closed direction: a new
343 /// [`ActorType`](crate::security::ActorType) variant is in nobody's list until
344 /// an author puts it there.
345 ///
346 /// # Delegation is deliberately not consulted
347 ///
348 /// For a delegated request (RFC 8693: an agent acting for a human), the
349 /// classification is `AiAgent` and the check does **not** fall back to the
350 /// permissions of the human in `acting_for`. Composite authorization already
351 /// happens through roles — a delegated token carries the human's roles, so
352 /// `requires_role` consults them — and having `requires_actor` do the same
353 /// would make it a no-op for exactly the case it exists for.
354 ///
355 /// # Anonymous
356 ///
357 /// A request with no security context has no classification, so a non-empty
358 /// list refuses it. Fail-closed, and it does not depend on
359 /// [`ActorType::default`](crate::security::ActorType) being the benign variant.
360 #[serde(default, skip_serializing_if = "Vec::is_empty")]
361 pub requires_actor: Vec<crate::security::ActorType>,
362
363 /// Custom REST path override (e.g., `"/users/{id}/posts"`).
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub rest_path: Option<String>,
366
367 /// REST HTTP method override (e.g., `"GET"`).
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub rest_method: Option<String>,
370
371 /// Whether this query may be exported as a stream over REST (#958).
372 ///
373 /// Default `false`. `Accept: application/x-ndjson`, `text/csv` and the XLSX type
374 /// are refused with `406` on a route that does not opt in — the JSON
375 /// representation is unaffected.
376 ///
377 /// # Why a per-route opt-in rather than a transport-wide switch
378 ///
379 /// A streamed export is not a bigger page. It reads the **whole** filtered
380 /// relation, bypasses `max_page_size` by construction (an export total is not a
381 /// page), and holds a pooled database connection for as long as the client takes
382 /// to read it. Those are reasonable properties for a route meant to hand over a
383 /// dataset and unreasonable ones for the other kind of route, which is most of
384 /// them — so which routes have them is the schema author's decision, made per
385 /// route, rather than a consequence of the REST transport being on.
386 ///
387 /// ```python
388 /// @fraiseql.query(sql_source="v_invoice", rest_stream=True)
389 /// def invoices() -> list[Invoice]: ...
390 /// ```
391 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
392 pub rest_stream: bool,
393
394 /// Native columns detected at compile time for direct query arguments.
395 ///
396 /// Maps argument name → PostgreSQL cast suffix (e.g., `"uuid"`, `"int4"`, `""`).
397 /// An empty string means the column exists but needs no type cast (e.g. `text`).
398 ///
399 /// At runtime, arguments present in this map generate `WHERE col = $N` (native column
400 /// lookup) instead of `WHERE data->>'col' = $N` (JSONB extraction), enabling B-tree
401 /// index usage for single-entity lookups.
402 ///
403 /// Only populated when `fraiseql compile --database <url>` is used. Schemas compiled
404 /// without a database URL omit this field and fall back to JSONB extraction.
405 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
406 pub native_columns: HashMap<String, String>,
407}
408
409impl QueryDefinition {
410 /// Create a new query definition.
411 #[must_use]
412 pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
413 Self {
414 name: name.into(),
415 return_type: return_type.into(),
416 returns_list: false,
417 returns_count: false,
418 nullable: false,
419 arguments: Vec::new(),
420 sql_source: None,
421 function: None,
422 description: None,
423 auto_params: AutoParams::default(),
424 deprecation: None,
425 jsonb_column: "data".to_string(),
426 relay: false,
427 relay_cursor_column: None,
428 relay_cursor_type: CursorType::Int64,
429 pagination_order: None,
430 inject_params: IndexMap::new(),
431 read_routing: ReadRouting::default(),
432 cache_ttl_seconds: None,
433 additional_views: Vec::new(),
434 requires_role: None,
435 requires_actor: Vec::new(),
436 rest_path: None,
437 rest_method: None,
438 rest_stream: false,
439 native_columns: HashMap::new(),
440 }
441 }
442
443 /// Set this query to return a list.
444 #[must_use]
445 pub const fn returning_list(mut self) -> Self {
446 self.returns_list = true;
447 self
448 }
449
450 /// Set the SQL source.
451 #[must_use]
452 pub fn with_sql_source(mut self, source: impl Into<String>) -> Self {
453 self.sql_source = Some(source.into());
454 self
455 }
456
457 /// Back this root field with a declared function instead of a relation (#1329).
458 ///
459 /// See [`function`](Self::function) for what the engine does with the name and
460 /// what it costs.
461 #[must_use]
462 pub fn with_function(mut self, function: impl Into<String>) -> Self {
463 self.function = Some(function.into());
464 self
465 }
466
467 /// Derive the `<name>Count` sibling of this list query (#938).
468 ///
469 /// This is the **only** way a count query is built. A sibling query that
470 /// answers "how many rows match?" against the same view is a second door
471 /// onto the same rows, and a second door that forgets one of the first's
472 /// restrictions is an oracle: a count that ignores `inject_params` reports
473 /// other tenants' row totals, and one that ignores `requires_role` answers
474 /// callers who cannot see the list at all. Neither leaks a row, which is
475 /// exactly why it would survive review — so the inheritance is centralised
476 /// here rather than spelled out at each construction site (the `_entities`
477 /// lesson, #1030).
478 ///
479 /// Inherited, deliberately: the entity `return_type` and `sql_source` (same
480 /// rows), `inject_params` (same tenant scoping), `requires_role` (same
481 /// visibility), explicitly declared `arguments` (they lower into the same
482 /// `WHERE`), `native_columns` (same casts, so the count uses the same
483 /// indexes), `additional_views` (same cache invalidation) and `deprecation`.
484 ///
485 /// Dropped, deliberately: `limit`/`offset`/`orderBy` — a total that moved
486 /// with the page would answer a question nobody asked; `pagination_order`
487 /// with them, since a scalar total has no rows to order; `relay`, which has
488 /// its own `totalCount`; and the REST overrides, since the REST surface
489 /// already counts through `Prefer: count=exact`.
490 ///
491 /// `function` is dropped for a different reason than the rest: a count is
492 /// `SELECT COUNT(*)` over a relation, and a function-backed field has none —
493 /// which is why `count = true` beside `function` is a compile error (#1329).
494 /// Dropping it here means a sibling hand-built through this constructor fails
495 /// as "no SQL source" rather than invoking the function and projecting its
496 /// object as a scalar total.
497 #[must_use]
498 pub fn count_sibling(&self) -> Self {
499 Self {
500 name: format!("{}Count", self.name),
501 function: None,
502 returns_list: false,
503 returns_count: true,
504 nullable: false,
505 auto_params: AutoParams {
506 has_where: self.auto_params.has_where,
507 has_order_by: false,
508 has_limit: false,
509 has_offset: false,
510 },
511 relay: false,
512 relay_cursor_column: None,
513 relay_cursor_type: CursorType::Int64,
514 pagination_order: None,
515 rest_path: None,
516 rest_method: None,
517 description: Some(format!(
518 "Total number of `{}` rows matching `where`, independent of any \
519 limit/offset applied to `{}`.",
520 self.return_type, self.name
521 )),
522 ..self.clone()
523 }
524 }
525
526 /// Mark this query as deprecated.
527 ///
528 /// # Example
529 ///
530 /// ```
531 /// use fraiseql_core::schema::QueryDefinition;
532 ///
533 /// let query = QueryDefinition::new("oldUsers", "User")
534 /// .deprecated(Some("Use 'users' instead".to_string()));
535 /// assert!(query.is_deprecated());
536 /// ```
537 #[must_use]
538 pub fn deprecated(mut self, reason: Option<String>) -> Self {
539 self.deprecation = Some(DeprecationInfo { reason });
540 self
541 }
542
543 /// Check if this query is deprecated.
544 #[must_use]
545 pub const fn is_deprecated(&self) -> bool {
546 self.deprecation.is_some()
547 }
548
549 /// Get the deprecation reason if deprecated.
550 #[must_use]
551 pub fn deprecation_reason(&self) -> Option<&str> {
552 self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
553 }
554
555 /// The full set of GraphQL arguments this query accepts, for rendering into
556 /// the federation `_service` SDL, generated clients, and introspection.
557 ///
558 /// The auto-wired `where`/`orderBy`/`limit`/`offset` arguments are gated by
559 /// [`auto_params`](Self::auto_params) and read directly from the argument map
560 /// at runtime, so they are deliberately *not* stored in
561 /// [`arguments`](Self::arguments) (where the runtime would otherwise mistake a
562 /// synthesized `limit`/`offset` for an explicit column filter). This method
563 /// materialises them so every consumer that renders from the argument list can
564 /// surface — and a generated client can actually pass — them.
565 ///
566 /// `where`/`orderBy` are typed against the derived filter surface —
567 /// `{Entity}WhereInput` and `[{Entity}OrderByInput!]` — when `schema` carries
568 /// those types, and fall back to the `JSON` scalar when it does not. Either
569 /// way the runtime parses the raw value via `WhereClause::from_graphql_json`
570 /// / `OrderByClause::from_graphql_json`; the type is what clients see, not
571 /// what the executor reads.
572 ///
573 /// # Why this takes a schema
574 ///
575 /// The published type of an auto-wired argument is a property of the schema,
576 /// not of the query alone: [`derived_inputs::derive`] emits
577 /// `{Entity}WhereInput` only for a return type whose fields the schema can
578 /// adjudicate, and skips any name the author already declared. Reading the
579 /// answer from the same place that produced it is what makes a dangling type
580 /// reference unrepresentable — the argument is typed **iff** the type exists.
581 /// A cached answer on `QueryDefinition` would let a caller publish the name
582 /// without the schema that justifies it.
583 ///
584 /// An explicit argument always wins: if the query already declares an argument
585 /// of the same name it is left untouched and no duplicate is synthesized.
586 ///
587 /// Relay connection queries are returned unchanged — their pagination surface
588 /// (`first`/`after`/`last`/`before`) is owned by each renderer's dedicated
589 /// relay path, not by `auto_params`.
590 ///
591 /// [`derived_inputs::derive`]: crate::schema::derived_inputs::derive
592 #[must_use]
593 pub fn graphql_arguments(
594 &self,
595 schema: &super::schema::CompiledSchema,
596 ) -> Vec<ArgumentDefinition> {
597 let mut args = self.arguments.clone();
598 if self.relay {
599 return args;
600 }
601
602 let declared = |name: &str| self.arguments.iter().any(|a| a.name == name);
603 let ap = &self.auto_params;
604
605 if ap.has_where && !declared("where") {
606 args.push(
607 ArgumentDefinition::optional("where", self.where_argument_type(schema))
608 .with_description(
609 "Filter predicate: a nested object of `{ field: { operator: value } }`, \
610 combined with `_and`/`_or`/`_not`.",
611 ),
612 );
613 }
614 if ap.has_order_by && !declared("orderBy") {
615 args.push(
616 ArgumentDefinition::optional("orderBy", self.order_by_argument_type(schema))
617 .with_description(
618 "Sort order: a list of `{ field, direction }` keys, applied in order.",
619 ),
620 );
621 }
622 if ap.has_limit && !declared("limit") {
623 args.push(
624 ArgumentDefinition::optional("limit", FieldType::Int)
625 .with_description("Maximum number of items to return."),
626 );
627 }
628 if ap.has_offset && !declared("offset") {
629 args.push(
630 ArgumentDefinition::optional("offset", FieldType::Int)
631 .with_description("Number of items to skip before returning results."),
632 );
633 }
634
635 args
636 }
637
638 /// The type `where` publishes: the derived `{Entity}WhereInput` when the
639 /// schema carries it, `JSON` when it does not.
640 ///
641 /// The fallback is not a degraded mode to be fixed later — it is the honest
642 /// answer for a schema whose return type carries no field list, where a
643 /// derived input object would positively forbid every key the engine still
644 /// accepts.
645 fn where_argument_type(&self, schema: &super::schema::CompiledSchema) -> FieldType {
646 let name = crate::schema::derived_inputs::where_input_type_name(&self.return_type);
647 if schema.find_input_type(&name).is_some() {
648 FieldType::Input(name)
649 } else {
650 FieldType::Json
651 }
652 }
653
654 /// The type `orderBy` publishes: `[{Entity}OrderByInput!]` when the schema
655 /// carries the item type, `JSON` when it does not.
656 ///
657 /// A list, because `OrderByClause::from_graphql_json`'s array branch is what
658 /// takes `{field, direction}` items and applies them in order. The object
659 /// branch (`{name: "DESC"}`) keeps executing but has no expression in this
660 /// type — its key order is not something a JSON object can promise.
661 fn order_by_argument_type(&self, schema: &super::schema::CompiledSchema) -> FieldType {
662 let name = crate::schema::derived_inputs::order_by_input_type_name(&self.return_type);
663 if schema.find_input_type(&name).is_some() {
664 FieldType::List(Box::new(FieldType::Input(name)))
665 } else {
666 FieldType::Json
667 }
668 }
669
670 /// The argument names a client document may write on this field
671 /// (GraphQL § 5.4.1), for [`validate_argument_names`].
672 ///
673 /// [`graphql_arguments`](Self::graphql_arguments) is what introspection
674 /// *publishes*; this is what the runtime actually *reads*, which is wider in
675 /// two places:
676 ///
677 /// * A relay connection's cursor window (`first`/`after`/`last`/`before`) is owned by each
678 /// renderer's relay path rather than by `auto_params`, and the relay runner also reads
679 /// `where`/`orderBy` from the merged argument map when the matching auto-param is on.
680 /// * `nearest` (#386, #959) is a runtime-only similarity-search argument. Accepting it here is
681 /// what lets its own diagnostics — not eligible on relay, needs a list query, unknown metric,
682 /// wrong dimension — be what the client sees, instead of a blanket "unknown argument". It is
683 /// refused on a count sibling, which never reads it.
684 ///
685 /// [`validate_argument_names`]: crate::runtime::validate_argument_names
686 #[must_use]
687 pub fn accepted_argument_names(&self, schema: &super::schema::CompiledSchema) -> Vec<String> {
688 let mut names: Vec<String> =
689 self.graphql_arguments(schema).into_iter().map(|arg| arg.name).collect();
690
691 if self.relay {
692 names.extend(["first", "after", "last", "before"].map(String::from));
693 if self.auto_params.has_where && !names.iter().any(|n| n == "where") {
694 names.push("where".to_string());
695 }
696 if self.auto_params.has_order_by && !names.iter().any(|n| n == "orderBy") {
697 names.push("orderBy".to_string());
698 }
699 }
700
701 if !self.returns_count {
702 names.push("nearest".to_string());
703 }
704
705 names
706 }
707}
708
709impl Default for QueryDefinition {
710 fn default() -> Self {
711 Self::new("", "")
712 }
713}