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}