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}