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 /// A function-backed field is **not cached**: the result cache keys and invalidates
186 /// rows of a relation, and this field reads none, so every request runs the
187 /// function. [`cache_ttl_seconds`](Self::cache_ttl_seconds) and
188 /// [`additional_views`](Self::additional_views) are compile errors beside a
189 /// `function` rather than settings that are accepted and ignored.
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub function: Option<String>,
192
193 /// Description.
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub description: Option<String>,
196
197 /// Auto-wired parameters (where, orderBy, limit, offset).
198 #[serde(default)]
199 pub auto_params: AutoParams,
200
201 /// Deprecation information (from @deprecated directive).
202 /// When set, this query is marked as deprecated in the schema.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub deprecation: Option<DeprecationInfo>,
205
206 /// JSONB column name (e.g., "data").
207 /// Used to extract data from JSONB columns in query results.
208 #[serde(default = "default_jsonb_column")]
209 pub jsonb_column: String,
210
211 /// Whether this query is a Relay connection query.
212 ///
213 /// When `true`, the compiler wraps the result in `XxxConnection` with
214 /// `edges { cursor node { ... } }` and `pageInfo` fields, using keyset
215 /// pagination on `pk_{snake_case(return_type)}` (BIGINT).
216 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217 pub relay: bool,
218
219 /// Keyset pagination column for relay queries.
220 ///
221 /// Derived from the return type name: `User` → `pk_user`.
222 /// This BIGINT column lives in the view (`sql_source`) and is used as the
223 /// stable sort key for cursor-based keyset pagination:
224 /// - Forward: `WHERE {col} > $cursor ORDER BY {col} ASC LIMIT $first`
225 /// - Backward: `WHERE {col} < $cursor ORDER BY {col} DESC LIMIT $last`
226 ///
227 /// Only set when `relay = true`.
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub relay_cursor_column: Option<String>,
230
231 /// Type of the keyset cursor column.
232 ///
233 /// Defaults to `Int64` for backward compatibility with schemas that use `pk_{type}`
234 /// BIGINT columns. Set to `Uuid` when the cursor column has a UUID type.
235 ///
236 /// Only meaningful when `relay = true`.
237 #[serde(default, skip_serializing_if = "is_default_cursor_type")]
238 pub relay_cursor_type: CursorType,
239
240 /// The order an offset-paginated read of this query falls back to when the
241 /// client requests none (#1303).
242 ///
243 /// Resolved at compile time for every list query that paginates, and `None`
244 /// for every query that does not — plus the one that does and whose author
245 /// declared `pagination_order = "none"`, which is how a view carrying its
246 /// own `ORDER BY` keeps it. That opt-out is *declared* rather than inferred:
247 /// the compiler cannot see a view's body, and ordering unconditionally would
248 /// silently destroy the escape hatch its own `auto_param_warnings` tells
249 /// authors to use.
250 ///
251 /// See [`PaginationOrder`] for why this is decided here and not by the SQL
252 /// layer, and for what each variant costs.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub pagination_order: Option<PaginationOrder>,
255
256 /// Server-side parameters injected from JWT claims at runtime.
257 ///
258 /// Keys are SQL column names. Values describe where to source the runtime value.
259 /// These params are NOT exposed as GraphQL arguments.
260 ///
261 /// For queries: adds a `WHERE key = $value` condition per entry using the same
262 /// `WhereClause` mechanism as `TenantEnforcer`. Works on all adapters.
263 ///
264 /// Clients cannot override these values.
265 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
266 pub inject_params: IndexMap<String, InjectedParamSource>,
267
268 /// Where this query's reads may be served from (#957).
269 ///
270 /// Read-replica routing is otherwise a whole-server decision: the pin window
271 /// and the staleness budget apply to every query alike, so an operator sizing
272 /// them for the strictest query gives up the offload on all the others, and
273 /// sizing them for the common case silently serves the strict one stale.
274 ///
275 /// FraiseQL defines and enforces this shape; an authoring language emits it —
276 /// A `@reads_from(...)` directive is one spelling of it.
277 /// Replica **topology** deliberately stays out of the compiled artifact: URLs
278 /// are server configuration and secrets.
279 ///
280 /// See [`ReadRouting`] for what each answer guarantees, including why
281 /// `primary` also bypasses the result cache.
282 #[serde(default, skip_serializing_if = "ReadRouting::is_default")]
283 pub read_routing: ReadRouting,
284
285 /// Per-query result cache TTL in seconds.
286 ///
287 /// Overrides the global `CacheConfig::ttl_seconds` for this query's view.
288 /// Common use-cases:
289 /// - Reference data (countries, currencies): `3600` (1 h)
290 /// - Live / real-time data: `0` (bypass cache entirely)
291 ///
292 /// `None` → use the global cache TTL.
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub cache_ttl_seconds: Option<u64>,
295
296 /// Additional database views this query reads beyond the primary `sql_source`.
297 ///
298 /// When this query JOINs or queries multiple views, list all secondary views here
299 /// so that mutations touching those views correctly invalidate this query's cache
300 /// entries.
301 ///
302 /// Without this list, only `sql_source` is registered for invalidation. Any mutation
303 /// that modifies a secondary view will NOT invalidate this query's cache — silently
304 /// serving stale data.
305 ///
306 /// Each entry must be a valid SQL identifier (letters, digits, `_`) validated by the
307 /// CLI compiler at schema compile time.
308 ///
309 /// # Example
310 ///
311 /// ```python
312 /// @fraiseql.query(
313 /// sql_source="v_user_with_posts",
314 /// additional_views=["v_post"],
315 /// )
316 /// def users_with_posts() -> list[UserWithPosts]: ...
317 /// ```
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub additional_views: Vec<String>,
320
321 /// Role required to execute this query and see it in introspection.
322 ///
323 /// When set, only users with this role can discover and execute this query.
324 /// Users without the role receive `"Unknown query"` (not `FORBIDDEN`)
325 /// to prevent role enumeration.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub requires_role: Option<String>,
328
329 /// Actor classes permitted to execute this query — an **allow-list** (#966).
330 ///
331 /// Empty (the default) means unrestricted. Non-empty means the request's
332 /// derived [`ActorType`](crate::security::ActorType) must be one of these, or
333 /// the operation is refused — regardless of what roles the caller holds. That
334 /// independence is the point: "autonomous agents may not read this" is a
335 /// statement about the *kind* of principal, not about its permissions.
336 ///
337 /// # An allow-list, not a deny-list
338 ///
339 /// A deny-list would admit every actor class added after it was written. An
340 /// allow-list refuses them, which is the fail-closed direction: a new
341 /// [`ActorType`](crate::security::ActorType) variant is in nobody's list until
342 /// an author puts it there.
343 ///
344 /// # Delegation is deliberately not consulted
345 ///
346 /// For a delegated request (RFC 8693: an agent acting for a human), the
347 /// classification is `AiAgent` and the check does **not** fall back to the
348 /// permissions of the human in `acting_for`. Composite authorization already
349 /// happens through roles — a delegated token carries the human's roles, so
350 /// `requires_role` consults them — and having `requires_actor` do the same
351 /// would make it a no-op for exactly the case it exists for.
352 ///
353 /// # Anonymous
354 ///
355 /// A request with no security context has no classification, so a non-empty
356 /// list refuses it. Fail-closed, and it does not depend on
357 /// [`ActorType::default`](crate::security::ActorType) being the benign variant.
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub requires_actor: Vec<crate::security::ActorType>,
360
361 /// Custom REST path override (e.g., `"/users/{id}/posts"`).
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub rest_path: Option<String>,
364
365 /// REST HTTP method override (e.g., `"GET"`).
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub rest_method: Option<String>,
368
369 /// Whether this query may be exported as a stream over REST (#958).
370 ///
371 /// Default `false`. `Accept: application/x-ndjson`, `text/csv` and the XLSX type
372 /// are refused with `406` on a route that does not opt in — the JSON
373 /// representation is unaffected.
374 ///
375 /// # Why a per-route opt-in rather than a transport-wide switch
376 ///
377 /// A streamed export is not a bigger page. It reads the **whole** filtered
378 /// relation, bypasses `max_page_size` by construction (an export total is not a
379 /// page), and holds a pooled database connection for as long as the client takes
380 /// to read it. Those are reasonable properties for a route meant to hand over a
381 /// dataset and unreasonable ones for the other kind of route, which is most of
382 /// them — so which routes have them is the schema author's decision, made per
383 /// route, rather than a consequence of the REST transport being on.
384 ///
385 /// ```python
386 /// @fraiseql.query(sql_source="v_invoice", rest_stream=True)
387 /// def invoices() -> list[Invoice]: ...
388 /// ```
389 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390 pub rest_stream: bool,
391
392 /// Native columns detected at compile time for direct query arguments.
393 ///
394 /// Maps argument name → PostgreSQL cast suffix (e.g., `"uuid"`, `"int4"`, `""`).
395 /// An empty string means the column exists but needs no type cast (e.g. `text`).
396 ///
397 /// At runtime, arguments present in this map generate `WHERE col = $N` (native column
398 /// lookup) instead of `WHERE data->>'col' = $N` (JSONB extraction), enabling B-tree
399 /// index usage for single-entity lookups.
400 ///
401 /// Only populated when `fraiseql compile --database <url>` is used. Schemas compiled
402 /// without a database URL omit this field and fall back to JSONB extraction.
403 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
404 pub native_columns: HashMap<String, String>,
405}
406
407impl QueryDefinition {
408 /// Create a new query definition.
409 #[must_use]
410 pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
411 Self {
412 name: name.into(),
413 return_type: return_type.into(),
414 returns_list: false,
415 returns_count: false,
416 nullable: false,
417 arguments: Vec::new(),
418 sql_source: None,
419 function: None,
420 description: None,
421 auto_params: AutoParams::default(),
422 deprecation: None,
423 jsonb_column: "data".to_string(),
424 relay: false,
425 relay_cursor_column: None,
426 relay_cursor_type: CursorType::Int64,
427 pagination_order: None,
428 inject_params: IndexMap::new(),
429 read_routing: ReadRouting::default(),
430 cache_ttl_seconds: None,
431 additional_views: Vec::new(),
432 requires_role: None,
433 requires_actor: Vec::new(),
434 rest_path: None,
435 rest_method: None,
436 rest_stream: false,
437 native_columns: HashMap::new(),
438 }
439 }
440
441 /// Set this query to return a list.
442 #[must_use]
443 pub const fn returning_list(mut self) -> Self {
444 self.returns_list = true;
445 self
446 }
447
448 /// Set the SQL source.
449 #[must_use]
450 pub fn with_sql_source(mut self, source: impl Into<String>) -> Self {
451 self.sql_source = Some(source.into());
452 self
453 }
454
455 /// Back this root field with a declared function instead of a relation (#1329).
456 ///
457 /// See [`function`](Self::function) for what the engine does with the name and
458 /// what it costs.
459 #[must_use]
460 pub fn with_function(mut self, function: impl Into<String>) -> Self {
461 self.function = Some(function.into());
462 self
463 }
464
465 /// Derive the `<name>Count` sibling of this list query (#938).
466 ///
467 /// This is the **only** way a count query is built. A sibling query that
468 /// answers "how many rows match?" against the same view is a second door
469 /// onto the same rows, and a second door that forgets one of the first's
470 /// restrictions is an oracle: a count that ignores `inject_params` reports
471 /// other tenants' row totals, and one that ignores `requires_role` answers
472 /// callers who cannot see the list at all. Neither leaks a row, which is
473 /// exactly why it would survive review — so the inheritance is centralised
474 /// here rather than spelled out at each construction site (the `_entities`
475 /// lesson, #1030).
476 ///
477 /// Inherited, deliberately: the entity `return_type` and `sql_source` (same
478 /// rows), `inject_params` (same tenant scoping), `requires_role` (same
479 /// visibility), explicitly declared `arguments` (they lower into the same
480 /// `WHERE`), `native_columns` (same casts, so the count uses the same
481 /// indexes), `additional_views` (same cache invalidation) and `deprecation`.
482 ///
483 /// Dropped, deliberately: `limit`/`offset`/`orderBy` — a total that moved
484 /// with the page would answer a question nobody asked; `pagination_order`
485 /// with them, since a scalar total has no rows to order; `relay`, which has
486 /// its own `totalCount`; and the REST overrides, since the REST surface
487 /// already counts through `Prefer: count=exact`.
488 ///
489 /// `function` is dropped for a different reason than the rest: a count is
490 /// `SELECT COUNT(*)` over a relation, and a function-backed field has none —
491 /// which is why `count = true` beside `function` is a compile error (#1329).
492 /// Dropping it here means a sibling hand-built through this constructor fails
493 /// as "no SQL source" rather than invoking the function and projecting its
494 /// object as a scalar total.
495 #[must_use]
496 pub fn count_sibling(&self) -> Self {
497 Self {
498 name: format!("{}Count", self.name),
499 function: None,
500 returns_list: false,
501 returns_count: true,
502 nullable: false,
503 auto_params: AutoParams {
504 has_where: self.auto_params.has_where,
505 has_order_by: false,
506 has_limit: false,
507 has_offset: false,
508 },
509 relay: false,
510 relay_cursor_column: None,
511 relay_cursor_type: CursorType::Int64,
512 pagination_order: None,
513 rest_path: None,
514 rest_method: None,
515 description: Some(format!(
516 "Total number of `{}` rows matching `where`, independent of any \
517 limit/offset applied to `{}`.",
518 self.return_type, self.name
519 )),
520 ..self.clone()
521 }
522 }
523
524 /// Mark this query as deprecated.
525 ///
526 /// # Example
527 ///
528 /// ```
529 /// use fraiseql_core::schema::QueryDefinition;
530 ///
531 /// let query = QueryDefinition::new("oldUsers", "User")
532 /// .deprecated(Some("Use 'users' instead".to_string()));
533 /// assert!(query.is_deprecated());
534 /// ```
535 #[must_use]
536 pub fn deprecated(mut self, reason: Option<String>) -> Self {
537 self.deprecation = Some(DeprecationInfo { reason });
538 self
539 }
540
541 /// Check if this query is deprecated.
542 #[must_use]
543 pub const fn is_deprecated(&self) -> bool {
544 self.deprecation.is_some()
545 }
546
547 /// Get the deprecation reason if deprecated.
548 #[must_use]
549 pub fn deprecation_reason(&self) -> Option<&str> {
550 self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
551 }
552
553 /// The full set of GraphQL arguments this query accepts, for rendering into
554 /// the federation `_service` SDL, generated clients, and introspection.
555 ///
556 /// The auto-wired `where`/`orderBy`/`limit`/`offset` arguments are gated by
557 /// [`auto_params`](Self::auto_params) and read directly from the argument map
558 /// at runtime, so they are deliberately *not* stored in
559 /// [`arguments`](Self::arguments) (where the runtime would otherwise mistake a
560 /// synthesized `limit`/`offset` for an explicit column filter). This method
561 /// materialises them so every consumer that renders from the argument list can
562 /// surface — and a generated client can actually pass — them.
563 ///
564 /// `where`/`orderBy` are typed against the derived filter surface —
565 /// `{Entity}WhereInput` and `[{Entity}OrderByInput!]` — when `schema` carries
566 /// those types, and fall back to the `JSON` scalar when it does not. Either
567 /// way the runtime parses the raw value via `WhereClause::from_graphql_json`
568 /// / `OrderByClause::from_graphql_json`; the type is what clients see, not
569 /// what the executor reads.
570 ///
571 /// # Why this takes a schema
572 ///
573 /// The published type of an auto-wired argument is a property of the schema,
574 /// not of the query alone: [`derived_inputs::derive`] emits
575 /// `{Entity}WhereInput` only for a return type whose fields the schema can
576 /// adjudicate, and skips any name the author already declared. Reading the
577 /// answer from the same place that produced it is what makes a dangling type
578 /// reference unrepresentable — the argument is typed **iff** the type exists.
579 /// A cached answer on `QueryDefinition` would let a caller publish the name
580 /// without the schema that justifies it.
581 ///
582 /// An explicit argument always wins: if the query already declares an argument
583 /// of the same name it is left untouched and no duplicate is synthesized.
584 ///
585 /// Relay connection queries are returned unchanged — their pagination surface
586 /// (`first`/`after`/`last`/`before`) is owned by each renderer's dedicated
587 /// relay path, not by `auto_params`.
588 ///
589 /// [`derived_inputs::derive`]: crate::schema::derived_inputs::derive
590 #[must_use]
591 pub fn graphql_arguments(
592 &self,
593 schema: &super::schema::CompiledSchema,
594 ) -> Vec<ArgumentDefinition> {
595 let mut args = self.arguments.clone();
596 if self.relay {
597 return args;
598 }
599
600 let declared = |name: &str| self.arguments.iter().any(|a| a.name == name);
601 let ap = &self.auto_params;
602
603 if ap.has_where && !declared("where") {
604 args.push(
605 ArgumentDefinition::optional("where", self.where_argument_type(schema))
606 .with_description(
607 "Filter predicate: a nested object of `{ field: { operator: value } }`, \
608 combined with `_and`/`_or`/`_not`.",
609 ),
610 );
611 }
612 if ap.has_order_by && !declared("orderBy") {
613 args.push(
614 ArgumentDefinition::optional("orderBy", self.order_by_argument_type(schema))
615 .with_description(
616 "Sort order: a list of `{ field, direction }` keys, applied in order.",
617 ),
618 );
619 }
620 if ap.has_limit && !declared("limit") {
621 args.push(
622 ArgumentDefinition::optional("limit", FieldType::Int)
623 .with_description("Maximum number of items to return."),
624 );
625 }
626 if ap.has_offset && !declared("offset") {
627 args.push(
628 ArgumentDefinition::optional("offset", FieldType::Int)
629 .with_description("Number of items to skip before returning results."),
630 );
631 }
632
633 args
634 }
635
636 /// The type `where` publishes: the derived `{Entity}WhereInput` when the
637 /// schema carries it, `JSON` when it does not.
638 ///
639 /// The fallback is not a degraded mode to be fixed later — it is the honest
640 /// answer for a schema whose return type carries no field list, where a
641 /// derived input object would positively forbid every key the engine still
642 /// accepts.
643 fn where_argument_type(&self, schema: &super::schema::CompiledSchema) -> FieldType {
644 let name = crate::schema::derived_inputs::where_input_type_name(&self.return_type);
645 if schema.find_input_type(&name).is_some() {
646 FieldType::Input(name)
647 } else {
648 FieldType::Json
649 }
650 }
651
652 /// The type `orderBy` publishes: `[{Entity}OrderByInput!]` when the schema
653 /// carries the item type, `JSON` when it does not.
654 ///
655 /// A list, because `OrderByClause::from_graphql_json`'s array branch is what
656 /// takes `{field, direction}` items and applies them in order. The object
657 /// branch (`{name: "DESC"}`) keeps executing but has no expression in this
658 /// type — its key order is not something a JSON object can promise.
659 fn order_by_argument_type(&self, schema: &super::schema::CompiledSchema) -> FieldType {
660 let name = crate::schema::derived_inputs::order_by_input_type_name(&self.return_type);
661 if schema.find_input_type(&name).is_some() {
662 FieldType::List(Box::new(FieldType::Input(name)))
663 } else {
664 FieldType::Json
665 }
666 }
667
668 /// The argument names a client document may write on this field
669 /// (GraphQL § 5.4.1), for [`validate_argument_names`].
670 ///
671 /// [`graphql_arguments`](Self::graphql_arguments) is what introspection
672 /// *publishes*; this is what the runtime actually *reads*, which is wider in
673 /// two places:
674 ///
675 /// * A relay connection's cursor window (`first`/`after`/`last`/`before`) is owned by each
676 /// renderer's relay path rather than by `auto_params`, and the relay runner also reads
677 /// `where`/`orderBy` from the merged argument map when the matching auto-param is on.
678 /// * `nearest` (#386, #959) is a runtime-only similarity-search argument. Accepting it here is
679 /// what lets its own diagnostics — not eligible on relay, needs a list query, unknown metric,
680 /// wrong dimension — be what the client sees, instead of a blanket "unknown argument". It is
681 /// refused on a count sibling, which never reads it.
682 ///
683 /// [`validate_argument_names`]: crate::runtime::validate_argument_names
684 #[must_use]
685 pub fn accepted_argument_names(&self, schema: &super::schema::CompiledSchema) -> Vec<String> {
686 let mut names: Vec<String> =
687 self.graphql_arguments(schema).into_iter().map(|arg| arg.name).collect();
688
689 if self.relay {
690 names.extend(["first", "after", "last", "before"].map(String::from));
691 if self.auto_params.has_where && !names.iter().any(|n| n == "where") {
692 names.push("where".to_string());
693 }
694 if self.auto_params.has_order_by && !names.iter().any(|n| n == "orderBy") {
695 names.push("orderBy".to_string());
696 }
697 }
698
699 if !self.returns_count {
700 names.push("nearest".to_string());
701 }
702
703 names
704 }
705}
706
707impl Default for QueryDefinition {
708 fn default() -> Self {
709 Self::new("", "")
710 }
711}