Skip to main content

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;