Skip to main content

fraiseql_core/schema/compiled/
query.rs

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