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    /// 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}