Skip to main content

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}