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}