fraiseql_db/types/sql_hints.rs
1//! Shared SQL types used across the compiler, schema, and database layers.
2//!
3//! These types are defined here (rather than in `compiler::aggregation` or
4//! `schema`) so that `db/` can import them without creating a dependency on
5//! the compilation or schema layers — a prerequisite for eventually extracting
6//! `db/` into its own crate (`fraiseql-db`).
7
8use fraiseql_error::{FraiseQLError, Result};
9use serde::{Deserialize, Serialize};
10
11use crate::{types::db_types::DatabaseType, utils::to_snake_case};
12
13/// Declared scalar type of a JSON field, used to pick the SQL cast.
14///
15/// A JSON/JSONB extraction is always `text`, so both ORDER BY and WHERE have to
16/// cast it before the database can compare it as anything else. Sorting `"9"`
17/// after `"10"` is the visible symptom for numbers; for WHERE the symptom is a
18/// wrong row set (or a hard cast error).
19///
20/// The variant → SQL-type-name mapping lives in exactly one place —
21/// [`SqlDialect::cast_type_name`] — so ORDER BY and WHERE cannot drift apart.
22///
23/// [`SqlDialect::cast_type_name`]: crate::dialect::SqlDialect::cast_type_name
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[non_exhaustive]
26pub enum ScalarFieldType {
27 /// No cast — text comparison (correct for strings, UUIDs, IDs, enum values).
28 #[default]
29 Text,
30 /// Integer (`::bigint` / `CAST(… AS BIGINT)`).
31 Integer,
32 /// Fixed/floating point (`::numeric` / `CAST(… AS DECIMAL(38,12))`).
33 Numeric,
34 /// Boolean (`::boolean`).
35 Boolean,
36 /// Instant (`::timestamptz` / `CAST(… AS DATETIME2)`).
37 DateTime,
38 /// Calendar date (`::date`).
39 Date,
40 /// Wall-clock time (`::time`).
41 Time,
42 /// Identity comparison (`ID`/`UUID`) — **no cast**.
43 ///
44 /// Renders exactly like [`Self::Text`]; the variant exists to mark *which*
45 /// fields are identities, not to introduce a cast. `cast_type_name` returns
46 /// `None` for it deliberately.
47 ///
48 /// Casting would be the obvious repair and is the wrong one:
49 /// `(data->>'id')::uuid` is evaluated **per row**, so one row holding a
50 /// non-UUID identity raises SQLSTATE 22P02 for every query — and `ID` is
51 /// documented as intentionally spanning uuid / integer / text keys
52 /// (`docs/adr/0017-entity-identity-contract.md`), with fixtures holding
53 /// `'user-1'` and a BIGINT pk. Equality is instead made case-insensitive at
54 /// the *literal*, which needs no knowledge of the column's type.
55 Uuid,
56 /// A declared pgvector field — `vector(N)`, `halfvec(N)`, `sparsevec(N)` or
57 /// `bit(N)` (#1117).
58 ///
59 /// Like [`Self::Uuid`], this marks *which* fields are of a kind rather than
60 /// introducing a cast: `cast_type_name` returns `None` for it, so every
61 /// ordinary comparison against a vector field renders exactly as it did.
62 ///
63 /// What it decides is the **operand a distance predicate reads**. The
64 /// storage contract requires the backing view to expose a vector as a native
65 /// column, and `nearest` has always ordered by it; the threshold operators
66 /// could not tell a vector field from any other, so they resolved it through
67 /// `data->>` and re-parsed every row's embedding out of text — 2667 ms
68 /// against the column's 22 ms, on the same 400 rows.
69 ///
70 /// One variant covers all four pgvector types on purpose: which pgvector
71 /// type the *literal* is cast to is decided by the operand's own shape (an
72 /// array is `::vector`, `{1:0.5}/1000` is `::sparsevec`, the binary metrics
73 /// are `::varbit`), and that is unchanged by this. What the schema is being
74 /// asked here is only *"is there a native column to read instead of the
75 /// JSONB text?"*.
76 Vector,
77}
78
79/// ORDER BY clause with optional type and native column information.
80///
81/// The SQL generator uses `field_type` to emit the correct type cast for
82/// JSONB-extracted values, and `native_column` to bypass JSONB extraction
83/// entirely when the view exposes a dedicated typed column.
84///
85/// # Sort correctness by source
86///
87/// | Source | Text fields | Numeric fields | Date fields |
88/// |--------|------------|----------------|-------------|
89/// | JSONB (no cast) | Correct | **Wrong** (lexicographic) | Correct (ISO-8601) |
90/// | JSONB (with cast) | Correct | Correct | Correct |
91/// | Native column | Correct | Correct | Correct + indexable |
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct OrderByClause {
95 /// Field to order by (GraphQL camelCase name).
96 pub field: String,
97 /// Sort direction.
98 pub direction: OrderDirection,
99 /// Field type for SQL cast generation. `Text` (default) means no cast.
100 #[serde(default)]
101 pub field_type: ScalarFieldType,
102 /// Native column name if the view exposes this field as a typed column.
103 /// When set, ORDER BY uses this column directly instead of JSONB extraction,
104 /// enabling index support and correct typing without casts.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub native_column: Option<String>,
107 /// Vector-distance ordering (#386): when set, this clause orders by
108 /// `{column} {operator} '{query_vector}'::vector` — the pgvector ANN shape.
109 /// Requires [`native_column`](Self::native_column) (a JSONB-extracted text
110 /// value would defeat every vector index and re-parse per row).
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub vector: Option<VectorDistanceOrder>,
113 /// Full-text relevance ordering (#1284): when set, this clause orders by
114 /// `ts_rank(to_tsvector(document), websearch_to_tsquery($n))`, where the
115 /// document is built from the searchable fields the same `?search=`
116 /// predicate matched on.
117 ///
118 /// [`field`](Self::field) is **not read** for such a clause — there is no
119 /// single field a relevance rank belongs to — and is empty by construction.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub relevance: Option<RelevanceOrder>,
122 /// This clause **is** the entity identity, whatever it is spelled as (#1303).
123 ///
124 /// Set only by [`identity`](Self::identity), which the runtime uses to lower
125 /// the compiled `pagination_order` into an ordering. Read only by the
126 /// tie-breaker, which appends nothing to an ordering that already carries one.
127 ///
128 /// A flag rather than a name comparison because the identity is a *schema*
129 /// fact: it may be `data->>'id'`, a native `id`, or `pk_user`, and this module
130 /// cannot tell the last from any other column. Without the flag the renderer
131 /// would append its own `data->>'id'` on top of a `pk_user` ordering — a
132 /// second, redundant sort key on every paged read of every Trinity view.
133 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
134 pub identity: bool,
135}
136
137/// The full-text operand of an ORDER BY clause (#1284).
138///
139/// Carries the search text as a *value*, not as SQL: unlike
140/// [`VectorDistanceOrder`], whose literal is built from parsed numbers and is
141/// therefore safe to interpolate, this is arbitrary client input and is bound
142/// as a parameter by the renderer.
143///
144/// # Why the fields are storage keys
145///
146/// The rank has to be computed over the same expression the search predicate
147/// matched on, or the ordering describes a different document from the one that
148/// was searched. `build_fts_where_clause` emits a `WhereClause::Field`, whose
149/// path `WhereClause::from_graphql_json` lowers to the snake_case storage key
150/// and the generator renders as `data->>'key'` — so these are storage keys and
151/// the renderer extracts them the same way.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct RelevanceOrder {
154 /// The snake_case storage keys of the searchable fields the rank is computed
155 /// over, in the order the predicate ORs them. Never empty — a rank over no
156 /// document is refused at render time.
157 pub fields: Vec<String>,
158 /// The raw `websearch_to_tsquery` text, exactly as the client sent it.
159 pub query: String,
160}
161
162/// The vector-distance operand of an ORDER BY clause (#386).
163///
164/// `query_vector` is carried as the *body* of a pgvector text literal
165/// (`[0.1,0.2,…]` for floats, `1011…` for bits) rather than a bind parameter:
166/// it is constructed exclusively by formatting values parsed from the request,
167/// so it can only contain digits, `.`, `-`, `e`, commas and brackets — and the
168/// SQL builder re-validates that character set before interpolating (defence in
169/// depth).
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct VectorDistanceOrder {
172 /// pgvector distance operator: `<=>` (cosine), `<->` (L2), `<#>` (inner
173 /// product), `<~>` (hamming), `<%>` (jaccard).
174 pub operator: String,
175 /// The query vector literal body, e.g. `[0.1,0.2,0.3]` or `1011`.
176 pub query_vector: String,
177 /// Which pgvector operand type the literal is cast to (#959).
178 #[serde(default)]
179 pub kind: VectorOperandKind,
180}
181
182/// The operand type a vector-distance comparison is made in (#959).
183///
184/// The cast is load-bearing on the binary side: `'1011'::bit` is `bit(1)`, so a
185/// length-less cast silently keeps the first bit and reports the distance
186/// between two one-bit values. `varbit` carries whatever width the value has,
187/// which makes a width disagreement `different bit lengths 8 and 4` from the
188/// server rather than a plausible wrong answer.
189#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191#[non_exhaustive]
192pub enum VectorOperandKind {
193 /// Float vector: `'[…]'::vector`, ordered by `<=>` / `<->` / `<#>`.
194 #[default]
195 Float,
196 /// Binary vector: `'1011'::varbit`, ordered by `<~>` / `<%>`.
197 Bit,
198 /// Sparse float vector: `'{1:0.5,7:0.25}/1000'::sparsevec`, same operators
199 /// as [`Self::Float`] (#959).
200 ///
201 /// A sparse literal has no other reading: `'{1:1}/5'::vector` is
202 /// `Vector contents must start with "["`, so this kind is the difference
203 /// between a query and an error.
204 Sparse,
205}
206
207impl VectorOperandKind {
208 /// The pgvector type a literal of this kind is cast to.
209 ///
210 /// `varbit` and not `bit` for the binary kind — see the type's own note.
211 ///
212 /// There is deliberately **no half-precision kind**: a `halfvec` column
213 /// compared against `'[…]'::vector` resolves the literal to `halfvec` and
214 /// uses the same `halfvec_*_ops` index — verified on the rig, where both
215 /// spellings produce the identical plan. A kind that changes nothing the
216 /// server can see would be a distinction the code carries and the database
217 /// ignores.
218 #[must_use]
219 pub const fn cast(self) -> &'static str {
220 match self {
221 Self::Bit => "varbit",
222 Self::Sparse => "sparsevec",
223 _ => "vector",
224 }
225 }
226}
227
228/// Sort direction
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[non_exhaustive]
231pub enum OrderDirection {
232 /// Ascending (A-Z, 0-9)
233 Asc,
234 /// Descending (Z-A, 9-0)
235 Desc,
236}
237
238impl OrderDirection {
239 /// Return the SQL keyword for this direction.
240 #[must_use]
241 pub const fn as_sql(self) -> &'static str {
242 match self {
243 Self::Asc => "ASC",
244 Self::Desc => "DESC",
245 }
246 }
247}
248
249impl OrderByClause {
250 /// Create a new `OrderByClause` with default field type (text) and no native column.
251 #[must_use]
252 pub fn new(field: String, direction: OrderDirection) -> Self {
253 Self {
254 field,
255 direction,
256 field_type: ScalarFieldType::default(),
257 native_column: None,
258 vector: None,
259 relevance: None,
260 identity: false,
261 }
262 }
263
264 /// A clause that orders by the entity identity, ascending (#1303).
265 ///
266 /// `native_column` when the relation exposes the identity as a column
267 /// (`id`, `pk_user`), `None` for the JSONB extraction `data->>'id'`. Which
268 /// one is a compile-time decision recorded in `QueryDefinition::pagination_order`;
269 /// this is its lowering.
270 ///
271 /// **ASC unconditionally.** A total order is all this has to be, and which of
272 /// two otherwise-tied rows comes first is not a property any client asked
273 /// about. Matching a requested clause's direction would suggest it is.
274 #[must_use]
275 pub const fn identity(field: String, native_column: Option<String>) -> Self {
276 Self {
277 field,
278 direction: OrderDirection::Asc,
279 field_type: ScalarFieldType::Text,
280 native_column,
281 vector: None,
282 relevance: None,
283 identity: true,
284 }
285 }
286
287 /// A clause that orders by full-text relevance, most relevant first (#1284).
288 ///
289 /// [`field`](Self::field) is left empty deliberately. A relevance rank is
290 /// computed over several fields at once, so naming one of them would be a
291 /// lie, and naming a sentinel like `_relevance` would put the sort kind back
292 /// into a string — which is the defect this constructor exists to replace:
293 /// `[{"_relevance": "desc"}]` type-checked at every layer that touched it
294 /// and failed only in the ORDER BY parser, three layers below the handler
295 /// that wrote it.
296 #[must_use]
297 pub fn by_relevance(relevance: RelevanceOrder) -> Self {
298 Self {
299 field: String::new(),
300 direction: OrderDirection::Desc,
301 field_type: ScalarFieldType::default(),
302 native_column: None,
303 vector: None,
304 relevance: Some(relevance),
305 identity: false,
306 }
307 }
308
309 /// Whether rendering this clause binds a SQL parameter.
310 ///
311 /// True only for full-text relevance (#1284), whose operand is arbitrary
312 /// client text and is therefore bound rather than interpolated. A SQL
313 /// builder that assembles `ORDER BY` as a bare string — the relay keyset
314 /// query, the fraiseql-wire adapter — asks this before rendering, so that
315 /// "this ordering cannot be expressed here" is a named refusal instead of a
316 /// dropped sort or an escaped literal.
317 #[must_use]
318 pub const fn binds_parameter(&self) -> bool {
319 self.relevance.is_some()
320 }
321
322 /// Convert the GraphQL camelCase field name to the JSONB snake_case storage key.
323 ///
324 /// # Examples
325 ///
326 /// ```
327 /// use fraiseql_db::OrderByClause;
328 /// use fraiseql_db::OrderDirection;
329 ///
330 /// let clause = OrderByClause::new("createdAt".to_string(), OrderDirection::Asc);
331 /// assert_eq!(clause.storage_key(), "created_at");
332 /// ```
333 #[must_use]
334 pub fn storage_key(&self) -> String {
335 to_snake_case(&self.field)
336 }
337
338 /// Validate that a field name matches the GraphQL identifier pattern `[_A-Za-z][_0-9A-Za-z]*`.
339 ///
340 /// This is a security boundary: field names are interpolated into SQL `data->>'field'`
341 /// expressions. Any character outside the GraphQL identifier set must be rejected before
342 /// the `OrderByClause` is constructed.
343 ///
344 /// # Errors
345 ///
346 /// Returns `FraiseQLError::Validation` if the field contains invalid characters.
347 pub fn validate_field_name(field: &str) -> Result<()> {
348 crate::utils::validate_graphql_identifier(field, "orderBy")
349 }
350
351 /// Parse `orderBy` from a GraphQL variables JSON value.
352 ///
353 /// Accepts two formats:
354 /// - Object: `{ "name": "DESC", "created_at": "ASC" }`
355 /// - Array: `[{ "field": "name", "direction": "DESC" }]`
356 ///
357 /// Direction strings are case-insensitive.
358 ///
359 /// # Errors
360 ///
361 /// Returns `FraiseQLError::Validation` for invalid structure or direction values.
362 pub fn from_graphql_json(value: &serde_json::Value) -> Result<Vec<Self>> {
363 if let Some(obj) = value.as_object() {
364 // Object format: { "name": "DESC", "created_at": "ASC" }
365 obj.iter()
366 .map(|(field, dir_val)| {
367 let dir_str = dir_val.as_str().ok_or_else(|| FraiseQLError::Validation {
368 message: format!("orderBy direction for '{field}' must be a string"),
369 path: None,
370 })?;
371 let direction = match dir_str.to_ascii_uppercase().as_str() {
372 "ASC" => OrderDirection::Asc,
373 "DESC" => OrderDirection::Desc,
374 _ => {
375 return Err(FraiseQLError::Validation {
376 message: format!(
377 "orderBy direction '{dir_str}' must be ASC or DESC"
378 ),
379 path: None,
380 });
381 },
382 };
383 Self::validate_field_name(field)?;
384 Ok(Self::new(field.clone(), direction))
385 })
386 .collect()
387 } else if let Some(arr) = value.as_array() {
388 // Array format: [{ "field": "name", "direction": "DESC" }]
389 arr.iter()
390 .map(|item| {
391 let obj = item.as_object().ok_or_else(|| FraiseQLError::Validation {
392 message: "orderBy array items must be objects".to_string(),
393 path: None,
394 })?;
395 let field = obj
396 .get("field")
397 .and_then(|v| v.as_str())
398 .ok_or_else(|| FraiseQLError::Validation {
399 message: "orderBy item missing 'field' string".to_string(),
400 path: None,
401 })?
402 .to_string();
403 let dir_str = obj.get("direction").and_then(|v| v.as_str()).unwrap_or("ASC");
404 let direction = match dir_str.to_ascii_uppercase().as_str() {
405 "ASC" => OrderDirection::Asc,
406 "DESC" => OrderDirection::Desc,
407 _ => {
408 return Err(FraiseQLError::Validation {
409 message: format!(
410 "orderBy direction '{dir_str}' must be ASC or DESC"
411 ),
412 path: None,
413 });
414 },
415 };
416 Self::validate_field_name(&field)?;
417 Ok(Self::new(field, direction))
418 })
419 .collect()
420 } else {
421 Err(FraiseQLError::Validation {
422 message: "orderBy must be an object or array".to_string(),
423 path: None,
424 })
425 }
426 }
427}
428
429/// SQL projection hint for database-specific field projection optimization.
430///
431/// When a type has a large JSONB payload, the compiler can generate
432/// SQL that projects only the requested fields, reducing network payload
433/// and JSON deserialization overhead.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[non_exhaustive]
436pub struct SqlProjectionHint {
437 /// Database type — typed to prevent silent typos (e.g. `"postgresq"`) that
438 /// would cause adapters to silently ignore the hint.
439 pub database: DatabaseType,
440
441 /// The projection SQL template.
442 /// Example for PostgreSQL:
443 /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
444 pub projection_template: String,
445
446 /// Estimated reduction in payload size (percentage 0-100).
447 pub estimated_reduction_percent: u32,
448}
449
450impl SqlProjectionHint {
451 /// Creates a new `SqlProjectionHint`.
452 #[must_use]
453 pub const fn new(
454 database: DatabaseType,
455 projection_template: String,
456 estimated_reduction_percent: u32,
457 ) -> Self {
458 Self {
459 database,
460 projection_template,
461 estimated_reduction_percent,
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests;