Skip to main content

fraiseql_db/dialect/
capability.rs

1//! Dialect capability matrix and fail-fast guard.
2//!
3//! [`DialectCapabilityGuard`] is called at query-planning time to verify that
4//! the requested feature is supported by the connected database dialect. If not,
5//! it returns `FraiseQLError::Unsupported` with a human-readable message and an
6//! optional migration suggestion — before SQL generation begins.
7//!
8//! This prevents cryptic driver errors ("syntax error near 'RETURNING'") and
9//! replaces them with actionable developer guidance.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! DialectCapabilityGuard::check(DatabaseType::SQLite, Feature::Mutations)?;
15//! // → Err(FraiseQLError::Unsupported { message: "Mutations (INSERT/UPDATE/DELETE
16//! //     via mutation_response) are not supported on SQLite. Use PostgreSQL or
17//! //     MySQL for mutation support." })
18//! ```
19
20use fraiseql_error::FraiseQLError;
21
22use crate::types::{DatabaseType, sql_hints::OrderByFieldType};
23
24// ============================================================================
25// Feature enum
26// ============================================================================
27
28/// A database feature that may not be supported on all dialects.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum Feature {
32    /// JSONB path expressions (`metadata->>'key'`, `@>`, `?`, etc.)
33    JsonbPathOps,
34    /// GraphQL subscriptions (real-time push over WebSocket/SSE)
35    Subscriptions,
36    /// Mutations (INSERT/UPDATE/DELETE via `mutation_response`)
37    Mutations,
38    /// Window functions (`RANK()`, `ROW_NUMBER()`, `LAG()`, etc.)
39    WindowFunctions,
40    /// Common Table Expressions (`WITH` clause)
41    CommonTableExpressions,
42    /// Full-text search (`MATCH`, `@@`, `CONTAINS`)
43    FullTextSearch,
44    /// Advisory locks (`pg_advisory_lock`, `GET_LOCK`)
45    AdvisoryLocks,
46    /// Standard deviation / variance aggregates (`STDDEV`, `VARIANCE`)
47    StddevVariance,
48    /// Upsert (`ON CONFLICT DO UPDATE`, `INSERT ... ON DUPLICATE KEY UPDATE`, `MERGE`)
49    Upsert,
50    /// Array column types (`text[]`, `integer[]`)
51    ArrayTypes,
52    /// Backward keyset pagination (requires stable sort with reversed direction)
53    BackwardPagination,
54}
55
56impl Feature {
57    /// Human-readable display name for error messages.
58    const fn display_name(self) -> &'static str {
59        match self {
60            Self::JsonbPathOps => "JSONB path expressions",
61            Self::Subscriptions => "Subscriptions (real-time push)",
62            Self::Mutations => "Mutations (INSERT/UPDATE/DELETE via mutation_response)",
63            Self::WindowFunctions => "Window functions (RANK, ROW_NUMBER, LAG, etc.)",
64            Self::CommonTableExpressions => "Common Table Expressions (WITH clause)",
65            Self::FullTextSearch => "Full-text search",
66            Self::AdvisoryLocks => "Advisory locks",
67            Self::StddevVariance => "STDDEV/VARIANCE aggregates",
68            Self::Upsert => "Upsert (ON CONFLICT / INSERT OR REPLACE)",
69            Self::ArrayTypes => "Array column types",
70            Self::BackwardPagination => "Backward keyset pagination",
71        }
72    }
73}
74
75// ============================================================================
76// Capability matrix
77// ============================================================================
78
79impl DatabaseType {
80    /// Return a SQL expression that extracts a text value from the `data` JSONB column.
81    ///
82    /// The `key` must already be validated via `OrderByClause::validate_field_name`
83    /// and converted to snake_case storage form via `OrderByClause::storage_key`.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use fraiseql_db::DatabaseType;
89    ///
90    /// assert_eq!(DatabaseType::PostgreSQL.json_field_expr("created_at"), "data->>'created_at'");
91    /// assert_eq!(DatabaseType::MySQL.json_field_expr("name"), "JSON_UNQUOTE(JSON_EXTRACT(data, '$.name'))");
92    /// ```
93    #[must_use]
94    pub fn json_field_expr(self, key: &str) -> String {
95        match self {
96            Self::PostgreSQL => format!("data->>'{key}'"),
97            Self::MySQL => format!("JSON_UNQUOTE(JSON_EXTRACT(data, '$.{key}'))"),
98            Self::SQLite => format!("json_extract(data, '$.{key}')"),
99            Self::SQLServer => format!("JSON_VALUE(data, '$.{key}')"),
100        }
101    }
102
103    /// Return a SQL expression that extracts and casts a value from the `data` JSONB
104    /// column for ORDER BY sorting.
105    ///
106    /// When `field_type` is [`OrderByFieldType::Text`] this is identical to
107    /// [`json_field_expr`](Self::json_field_expr). For numeric, date, and boolean
108    /// types the expression is wrapped in a dialect-specific cast so the database
109    /// sorts by the typed value instead of the raw text (`"9" > "10"` is wrong for
110    /// numbers).
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use fraiseql_db::{DatabaseType, OrderByFieldType};
116    ///
117    /// assert_eq!(
118    ///     DatabaseType::PostgreSQL.typed_json_field_expr("amount", OrderByFieldType::Numeric),
119    ///     "(data->>'amount')::numeric"
120    /// );
121    /// assert_eq!(
122    ///     DatabaseType::MySQL.typed_json_field_expr("amount", OrderByFieldType::Numeric),
123    ///     "CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.amount')) AS DECIMAL(38,12))"
124    /// );
125    /// ```
126    // Reason: each `unreachable!()` is for `F::Text` after the early return at
127    // the top of the function eliminates that variant. The `match` is repeated
128    // per dialect to map each remaining variant to a dialect-specific cast type.
129    #[allow(clippy::unreachable)]
130    #[must_use]
131    pub fn typed_json_field_expr(self, key: &str, field_type: OrderByFieldType) -> String {
132        use OrderByFieldType as F;
133
134        // Text needs no cast — the raw extraction is already text.
135        if field_type == F::Text {
136            return self.json_field_expr(key);
137        }
138
139        let base = self.json_field_expr(key);
140
141        match self {
142            Self::PostgreSQL => {
143                let pg_type = match field_type {
144                    F::Text => unreachable!("F::Text returned early at function top"),
145                    F::Integer => "bigint",
146                    F::Numeric => "numeric",
147                    F::Boolean => "boolean",
148                    F::DateTime => "timestamptz",
149                    F::Date => "date",
150                    F::Time => "time",
151                };
152                format!("({base})::{pg_type}")
153            },
154            Self::MySQL => {
155                let mysql_type = match field_type {
156                    F::Text => unreachable!("F::Text returned early at function top"),
157                    F::Integer => "SIGNED",
158                    F::Numeric => "DECIMAL(38,12)",
159                    F::Boolean => "UNSIGNED",
160                    F::DateTime => "DATETIME",
161                    F::Date => "DATE",
162                    F::Time => "TIME",
163                };
164                format!("CAST({base} AS {mysql_type})")
165            },
166            Self::SQLite => {
167                // SQLite has limited type affinity; CAST works for REAL/INTEGER.
168                let sqlite_type = match field_type {
169                    F::Text => unreachable!("F::Text returned early at function top"),
170                    F::Integer | F::Boolean => "INTEGER",
171                    F::Numeric => "REAL",
172                    F::DateTime | F::Date | F::Time => "TEXT", // ISO-8601 sorts correctly as text
173                };
174                format!("CAST({base} AS {sqlite_type})")
175            },
176            Self::SQLServer => {
177                let sqlserver_type = match field_type {
178                    F::Text => unreachable!("F::Text returned early at function top"),
179                    F::Integer => "BIGINT",
180                    F::Numeric => "DECIMAL(38,12)",
181                    F::Boolean => "BIT",
182                    F::DateTime => "DATETIME2",
183                    F::Date => "DATE",
184                    F::Time => "TIME",
185                };
186                format!("CAST({base} AS {sqlserver_type})")
187            },
188        }
189    }
190
191    /// Check whether this dialect supports `feature`.
192    ///
193    /// All checks are `const`-friendly and zero-cost at runtime.
194    #[must_use]
195    pub const fn supports(self, feature: Feature) -> bool {
196        match (self, feature) {
197            // PostgreSQL: fully featured
198            (Self::PostgreSQL, _) => true,
199
200            // MySQL 8+: no JSONB path ops, subscriptions, advisory locks,
201            // STDDEV, array types. Everything else is supported.
202            (
203                Self::MySQL,
204                Feature::JsonbPathOps
205                | Feature::Subscriptions
206                | Feature::AdvisoryLocks
207                | Feature::StddevVariance
208                | Feature::ArrayTypes,
209            ) => false,
210            (Self::MySQL, _) => true,
211
212            // SQL Server: no JSONB path ops, subscriptions, advisory locks,
213            // array types. Everything else is supported.
214            (
215                Self::SQLServer,
216                Feature::JsonbPathOps
217                | Feature::Subscriptions
218                | Feature::AdvisoryLocks
219                | Feature::ArrayTypes,
220            ) => false,
221            (Self::SQLServer, _) => true,
222
223            // SQLite: very limited — only CTEs and Upsert are supported
224            (Self::SQLite, Feature::CommonTableExpressions | Feature::Upsert) => true,
225            (Self::SQLite, _) => false,
226        }
227    }
228
229    /// Return a human-readable migration suggestion for an unsupported feature.
230    ///
231    /// `None` means no specific guidance is available beyond the error message.
232    #[must_use]
233    pub const fn suggestion_for(self, feature: Feature) -> Option<&'static str> {
234        match (self, feature) {
235            (Self::MySQL, Feature::JsonbPathOps) => {
236                Some("Use `json_extract(column, '$.key')` syntax instead of JSONB path operators.")
237            },
238            (Self::MySQL, Feature::StddevVariance) => {
239                Some("MySQL does not provide STDDEV/VARIANCE; compute them in application code.")
240            },
241            (Self::SQLite, Feature::Mutations) => Some(
242                "SQLite mutations are not supported. Use PostgreSQL or MySQL for mutation support.",
243            ),
244            (Self::SQLite, Feature::WindowFunctions) => Some(
245                "SQLite 3.25+ supports basic window functions; upgrade your SQLite version or use PostgreSQL.",
246            ),
247            (Self::SQLite, Feature::Subscriptions) => {
248                Some("Subscriptions require a database with LISTEN/NOTIFY. Use PostgreSQL.")
249            },
250            _ => None,
251        }
252    }
253}
254
255// ============================================================================
256// Guard
257// ============================================================================
258
259/// Fail-fast guard that checks database dialect capabilities before SQL generation.
260///
261/// Call [`DialectCapabilityGuard::check`] during query planning to produce
262/// a `FraiseQLError::Unsupported` with actionable guidance instead of a
263/// cryptic driver error.
264pub struct DialectCapabilityGuard;
265
266impl DialectCapabilityGuard {
267    /// Check that `dialect` supports `feature`.
268    ///
269    /// Returns `Ok(())` if the feature is supported, or
270    /// `Err(FraiseQLError::Unsupported)` with a human-readable message.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`FraiseQLError::Unsupported`] when the feature is not available
275    /// on the specified dialect.
276    pub fn check(dialect: DatabaseType, feature: Feature) -> Result<(), FraiseQLError> {
277        if dialect.supports(feature) {
278            return Ok(());
279        }
280
281        let suggestion =
282            dialect.suggestion_for(feature).map(|s| format!(" {s}")).unwrap_or_default();
283
284        Err(FraiseQLError::Unsupported {
285            message: format!(
286                "{} is not supported on {}.{suggestion} \
287                 See docs/database-compatibility.md for the full feature matrix.",
288                feature.display_name(),
289                dialect.as_str(),
290            ),
291        })
292    }
293
294    /// Check multiple features at once and return **all** unsupported ones.
295    ///
296    /// Unlike [`check`], this collects all failures before returning, giving
297    /// the developer a complete picture in a single error message.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`FraiseQLError::Unsupported`] listing all unsupported features
302    /// if any are unsupported.
303    ///
304    /// [`check`]: Self::check
305    pub fn check_all(dialect: DatabaseType, features: &[Feature]) -> Result<(), FraiseQLError> {
306        let failures: Vec<String> = features
307            .iter()
308            .copied()
309            .filter(|&f| !dialect.supports(f))
310            .map(|f| {
311                let suggestion =
312                    dialect.suggestion_for(f).map(|s| format!(" {s}")).unwrap_or_default();
313                format!("- {}{suggestion}", f.display_name())
314            })
315            .collect();
316
317        if failures.is_empty() {
318            return Ok(());
319        }
320
321        Err(FraiseQLError::Unsupported {
322            message: format!(
323                "The following features are not supported on {}:\n{}\n\
324                 See docs/database-compatibility.md for the full feature matrix.",
325                dialect.as_str(),
326                failures.join("\n"),
327            ),
328        })
329    }
330}
331
332// ============================================================================
333// Tests
334// ============================================================================
335
336#[cfg(test)]
337mod tests;