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::schema::{
8    field_type::{DeprecationInfo, FieldType},
9    graphql_type_defs::default_jsonb_column,
10    security_config::InjectedParamSource,
11};
12
13/// The type of column used as the keyset cursor for relay pagination.
14///
15/// Determines how the cursor value is encoded/decoded and how the SQL comparison
16/// is emitted (`bigint` vs `uuid` cast).
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum CursorType {
21    /// BIGINT / INTEGER column (default, backward-compatible).
22    /// Cursor is `base64(decimal_string)`.
23    #[default]
24    Int64,
25    /// UUID column.
26    /// Cursor is `base64(uuid_string)`.
27    Uuid,
28}
29
30pub(super) fn is_default_cursor_type(ct: &CursorType) -> bool {
31    *ct == CursorType::Int64
32}
33
34/// A query definition compiled from `@fraiseql.query`.
35///
36/// Queries are declarative bindings to database views/tables.
37/// They describe *what* to fetch, not *how* to fetch it.
38///
39/// # Example
40///
41/// ```
42/// use fraiseql_core::schema::QueryDefinition;
43///
44/// let query = QueryDefinition::new("users", "User");
45/// ```
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct QueryDefinition {
48    /// Query name (e.g., "users").
49    pub name: String,
50
51    /// Return type name (e.g., "User").
52    pub return_type: String,
53
54    /// Does this query return a list?
55    #[serde(default)]
56    pub returns_list: bool,
57
58    /// Is the return value nullable?
59    #[serde(default)]
60    pub nullable: bool,
61
62    /// Query arguments.
63    #[serde(default)]
64    pub arguments: Vec<ArgumentDefinition>,
65
66    /// SQL source table/view (for direct table queries).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub sql_source: Option<String>,
69
70    /// Description.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub description: Option<String>,
73
74    /// Auto-wired parameters (where, orderBy, limit, offset).
75    #[serde(default)]
76    pub auto_params: AutoParams,
77
78    /// Deprecation information (from @deprecated directive).
79    /// When set, this query is marked as deprecated in the schema.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub deprecation: Option<DeprecationInfo>,
82
83    /// JSONB column name (e.g., "data").
84    /// Used to extract data from JSONB columns in query results.
85    #[serde(default = "default_jsonb_column")]
86    pub jsonb_column: String,
87
88    /// Whether this query is a Relay connection query.
89    ///
90    /// When `true`, the compiler wraps the result in `XxxConnection` with
91    /// `edges { cursor node { ... } }` and `pageInfo` fields, using keyset
92    /// pagination on `pk_{snake_case(return_type)}` (BIGINT).
93    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94    pub relay: bool,
95
96    /// Keyset pagination column for relay queries.
97    ///
98    /// Derived from the return type name: `User` → `pk_user`.
99    /// This BIGINT column lives in the view (`sql_source`) and is used as the
100    /// stable sort key for cursor-based keyset pagination:
101    /// - Forward: `WHERE {col} > $cursor ORDER BY {col} ASC LIMIT $first`
102    /// - Backward: `WHERE {col} < $cursor ORDER BY {col} DESC LIMIT $last`
103    ///
104    /// Only set when `relay = true`.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub relay_cursor_column: Option<String>,
107
108    /// Type of the keyset cursor column.
109    ///
110    /// Defaults to `Int64` for backward compatibility with schemas that use `pk_{type}`
111    /// BIGINT columns. Set to `Uuid` when the cursor column has a UUID type.
112    ///
113    /// Only meaningful when `relay = true`.
114    #[serde(default, skip_serializing_if = "is_default_cursor_type")]
115    pub relay_cursor_type: CursorType,
116
117    /// Server-side parameters injected from JWT claims at runtime.
118    ///
119    /// Keys are SQL column names. Values describe where to source the runtime value.
120    /// These params are NOT exposed as GraphQL arguments.
121    ///
122    /// For queries: adds a `WHERE key = $value` condition per entry using the same
123    /// `WhereClause` mechanism as `TenantEnforcer`. Works on all adapters.
124    ///
125    /// Clients cannot override these values.
126    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
127    pub inject_params: IndexMap<String, InjectedParamSource>,
128
129    /// Per-query result cache TTL in seconds.
130    ///
131    /// Overrides the global `CacheConfig::ttl_seconds` for this query's view.
132    /// Common use-cases:
133    /// - Reference data (countries, currencies): `3600` (1 h)
134    /// - Live / real-time data: `0` (bypass cache entirely)
135    ///
136    /// `None` → use the global cache TTL.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub cache_ttl_seconds: Option<u64>,
139
140    /// Additional database views this query reads beyond the primary `sql_source`.
141    ///
142    /// When this query JOINs or queries multiple views, list all secondary views here
143    /// so that mutations touching those views correctly invalidate this query's cache
144    /// entries.
145    ///
146    /// Without this list, only `sql_source` is registered for invalidation. Any mutation
147    /// that modifies a secondary view will NOT invalidate this query's cache — silently
148    /// serving stale data.
149    ///
150    /// Each entry must be a valid SQL identifier (letters, digits, `_`) validated by the
151    /// CLI compiler at schema compile time.
152    ///
153    /// # Example
154    ///
155    /// ```python
156    /// @fraiseql.query(
157    ///     sql_source="v_user_with_posts",
158    ///     additional_views=["v_post"],
159    /// )
160    /// def users_with_posts() -> list[UserWithPosts]: ...
161    /// ```
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub additional_views: Vec<String>,
164
165    /// Role required to execute this query and see it in introspection.
166    ///
167    /// When set, only users with this role can discover and execute this query.
168    /// Users without the role receive `"Unknown query"` (not `FORBIDDEN`)
169    /// to prevent role enumeration.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub requires_role: Option<String>,
172
173    /// Custom REST path override (e.g., `"/users/{id}/posts"`).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub rest_path: Option<String>,
176
177    /// REST HTTP method override (e.g., `"GET"`).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub rest_method: Option<String>,
180
181    /// Native columns detected at compile time for direct query arguments.
182    ///
183    /// Maps argument name → PostgreSQL cast suffix (e.g., `"uuid"`, `"int4"`, `""`).
184    /// An empty string means the column exists but needs no type cast (e.g. `text`).
185    ///
186    /// At runtime, arguments present in this map generate `WHERE col = $N` (native column
187    /// lookup) instead of `WHERE data->>'col' = $N` (JSONB extraction), enabling B-tree
188    /// index usage for single-entity lookups.
189    ///
190    /// Only populated when `fraiseql compile --database <url>` is used. Schemas compiled
191    /// without a database URL omit this field and fall back to JSONB extraction.
192    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
193    pub native_columns: HashMap<String, String>,
194}
195
196impl QueryDefinition {
197    /// Create a new query definition.
198    #[must_use]
199    pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
200        Self {
201            name:                name.into(),
202            return_type:         return_type.into(),
203            returns_list:        false,
204            nullable:            false,
205            arguments:           Vec::new(),
206            sql_source:          None,
207            description:         None,
208            auto_params:         AutoParams::default(),
209            deprecation:         None,
210            jsonb_column:        "data".to_string(),
211            relay:               false,
212            relay_cursor_column: None,
213            relay_cursor_type:   CursorType::Int64,
214            inject_params:       IndexMap::new(),
215            cache_ttl_seconds:   None,
216            additional_views:    Vec::new(),
217            requires_role:       None,
218            rest_path:           None,
219            rest_method:         None,
220            native_columns:      HashMap::new(),
221        }
222    }
223
224    /// Set this query to return a list.
225    #[must_use]
226    pub const fn returning_list(mut self) -> Self {
227        self.returns_list = true;
228        self
229    }
230
231    /// Set the SQL source.
232    #[must_use]
233    pub fn with_sql_source(mut self, source: impl Into<String>) -> Self {
234        self.sql_source = Some(source.into());
235        self
236    }
237
238    /// Mark this query as deprecated.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// use fraiseql_core::schema::QueryDefinition;
244    ///
245    /// let query = QueryDefinition::new("oldUsers", "User")
246    ///     .deprecated(Some("Use 'users' instead".to_string()));
247    /// assert!(query.is_deprecated());
248    /// ```
249    #[must_use]
250    pub fn deprecated(mut self, reason: Option<String>) -> Self {
251        self.deprecation = Some(DeprecationInfo { reason });
252        self
253    }
254
255    /// Check if this query is deprecated.
256    #[must_use]
257    pub const fn is_deprecated(&self) -> bool {
258        self.deprecation.is_some()
259    }
260
261    /// Get the deprecation reason if deprecated.
262    #[must_use]
263    pub fn deprecation_reason(&self) -> Option<&str> {
264        self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
265    }
266
267    /// The full set of GraphQL arguments this query accepts, for rendering into
268    /// the federation `_service` SDL, generated clients, and introspection.
269    ///
270    /// The auto-wired `where`/`orderBy`/`limit`/`offset` arguments are gated by
271    /// [`auto_params`](Self::auto_params) and read directly from the argument map
272    /// at runtime, so they are deliberately *not* stored in
273    /// [`arguments`](Self::arguments) (where the runtime would otherwise mistake a
274    /// synthesized `limit`/`offset` for an explicit column filter). This method
275    /// materialises them so every consumer that renders from the argument list can
276    /// surface — and a generated client can actually pass — them.
277    ///
278    /// `where`/`orderBy` carry dynamic, per-field shapes, so they are typed as the
279    /// `JSON` scalar; the runtime parses the raw value via
280    /// `WhereClause::from_graphql_json` / `OrderByClause::from_graphql_json`.
281    ///
282    /// An explicit argument always wins: if the query already declares an argument
283    /// of the same name it is left untouched and no duplicate is synthesized.
284    ///
285    /// Relay connection queries are returned unchanged — their pagination surface
286    /// (`first`/`after`/`last`/`before`) is owned by each renderer's dedicated
287    /// relay path, not by `auto_params`.
288    #[must_use]
289    pub fn graphql_arguments(&self) -> Vec<ArgumentDefinition> {
290        let mut args = self.arguments.clone();
291        if self.relay {
292            return args;
293        }
294
295        let declared = |name: &str| self.arguments.iter().any(|a| a.name == name);
296        let ap = &self.auto_params;
297
298        if ap.has_where && !declared("where") {
299            args.push(ArgumentDefinition::optional("where", FieldType::Json).with_description(
300                "Filter predicate: a nested object of `{ field: { operator: value } }`, \
301                 combined with `_and`/`_or`/`_not`.",
302            ));
303        }
304        if ap.has_order_by && !declared("orderBy") {
305            args.push(ArgumentDefinition::optional("orderBy", FieldType::Json).with_description(
306                "Sort order: `{ field: \"ASC\" | \"DESC\" }` or \
307                     `[{ field, direction }]`.",
308            ));
309        }
310        if ap.has_limit && !declared("limit") {
311            args.push(
312                ArgumentDefinition::optional("limit", FieldType::Int)
313                    .with_description("Maximum number of items to return."),
314            );
315        }
316        if ap.has_offset && !declared("offset") {
317            args.push(
318                ArgumentDefinition::optional("offset", FieldType::Int)
319                    .with_description("Number of items to skip before returning results."),
320            );
321        }
322
323        args
324    }
325}
326
327impl Default for QueryDefinition {
328    fn default() -> Self {
329        Self::new("", "")
330    }
331}