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