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    #[must_use]
127    pub fn typed_json_field_expr(self, key: &str, field_type: OrderByFieldType) -> String {
128        use OrderByFieldType as F;
129
130        // Text needs no cast — the raw extraction is already text.
131        if field_type == F::Text {
132            return self.json_field_expr(key);
133        }
134
135        let base = self.json_field_expr(key);
136
137        match self {
138            Self::PostgreSQL => {
139                let pg_type = match field_type {
140                    F::Text => unreachable!(),
141                    F::Integer => "bigint",
142                    F::Numeric => "numeric",
143                    F::Boolean => "boolean",
144                    F::DateTime => "timestamptz",
145                    F::Date => "date",
146                    F::Time => "time",
147                };
148                format!("({base})::{pg_type}")
149            },
150            Self::MySQL => {
151                let mysql_type = match field_type {
152                    F::Text => unreachable!(),
153                    F::Integer => "SIGNED",
154                    F::Numeric => "DECIMAL(38,12)",
155                    F::Boolean => "UNSIGNED",
156                    F::DateTime => "DATETIME",
157                    F::Date => "DATE",
158                    F::Time => "TIME",
159                };
160                format!("CAST({base} AS {mysql_type})")
161            },
162            Self::SQLite => {
163                // SQLite has limited type affinity; CAST works for REAL/INTEGER.
164                let sqlite_type = match field_type {
165                    F::Text => unreachable!(),
166                    F::Integer | F::Boolean => "INTEGER",
167                    F::Numeric => "REAL",
168                    F::DateTime | F::Date | F::Time => "TEXT", // ISO-8601 sorts correctly as text
169                };
170                format!("CAST({base} AS {sqlite_type})")
171            },
172            Self::SQLServer => {
173                let sqlserver_type = match field_type {
174                    F::Text => unreachable!(),
175                    F::Integer => "BIGINT",
176                    F::Numeric => "DECIMAL(38,12)",
177                    F::Boolean => "BIT",
178                    F::DateTime => "DATETIME2",
179                    F::Date => "DATE",
180                    F::Time => "TIME",
181                };
182                format!("CAST({base} AS {sqlserver_type})")
183            },
184        }
185    }
186
187    /// Check whether this dialect supports `feature`.
188    ///
189    /// All checks are `const`-friendly and zero-cost at runtime.
190    #[must_use]
191    pub const fn supports(self, feature: Feature) -> bool {
192        match (self, feature) {
193            // PostgreSQL: fully featured
194            (Self::PostgreSQL, _) => true,
195
196            // MySQL 8+: no JSONB path ops, subscriptions, advisory locks,
197            // STDDEV, array types. Everything else is supported.
198            (
199                Self::MySQL,
200                Feature::JsonbPathOps
201                | Feature::Subscriptions
202                | Feature::AdvisoryLocks
203                | Feature::StddevVariance
204                | Feature::ArrayTypes,
205            ) => false,
206            (Self::MySQL, _) => true,
207
208            // SQL Server: no JSONB path ops, subscriptions, advisory locks,
209            // array types. Everything else is supported.
210            (
211                Self::SQLServer,
212                Feature::JsonbPathOps
213                | Feature::Subscriptions
214                | Feature::AdvisoryLocks
215                | Feature::ArrayTypes,
216            ) => false,
217            (Self::SQLServer, _) => true,
218
219            // SQLite: very limited — only CTEs and Upsert are supported
220            (Self::SQLite, Feature::CommonTableExpressions | Feature::Upsert) => true,
221            (Self::SQLite, _) => false,
222        }
223    }
224
225    /// Return a human-readable migration suggestion for an unsupported feature.
226    ///
227    /// `None` means no specific guidance is available beyond the error message.
228    #[must_use]
229    pub const fn suggestion_for(self, feature: Feature) -> Option<&'static str> {
230        match (self, feature) {
231            (Self::MySQL, Feature::JsonbPathOps) => {
232                Some("Use `json_extract(column, '$.key')` syntax instead of JSONB path operators.")
233            },
234            (Self::MySQL, Feature::StddevVariance) => {
235                Some("MySQL does not provide STDDEV/VARIANCE; compute them in application code.")
236            },
237            (Self::SQLite, Feature::Mutations) => Some(
238                "SQLite mutations are not supported. Use PostgreSQL or MySQL for mutation support.",
239            ),
240            (Self::SQLite, Feature::WindowFunctions) => Some(
241                "SQLite 3.25+ supports basic window functions; upgrade your SQLite version or use PostgreSQL.",
242            ),
243            (Self::SQLite, Feature::Subscriptions) => {
244                Some("Subscriptions require a database with LISTEN/NOTIFY. Use PostgreSQL.")
245            },
246            _ => None,
247        }
248    }
249}
250
251// ============================================================================
252// Guard
253// ============================================================================
254
255/// Fail-fast guard that checks database dialect capabilities before SQL generation.
256///
257/// Call [`DialectCapabilityGuard::check`] during query planning to produce
258/// a `FraiseQLError::Unsupported` with actionable guidance instead of a
259/// cryptic driver error.
260pub struct DialectCapabilityGuard;
261
262impl DialectCapabilityGuard {
263    /// Check that `dialect` supports `feature`.
264    ///
265    /// Returns `Ok(())` if the feature is supported, or
266    /// `Err(FraiseQLError::Unsupported)` with a human-readable message.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`FraiseQLError::Unsupported`] when the feature is not available
271    /// on the specified dialect.
272    pub fn check(dialect: DatabaseType, feature: Feature) -> Result<(), FraiseQLError> {
273        if dialect.supports(feature) {
274            return Ok(());
275        }
276
277        let suggestion =
278            dialect.suggestion_for(feature).map(|s| format!(" {s}")).unwrap_or_default();
279
280        Err(FraiseQLError::Unsupported {
281            message: format!(
282                "{} is not supported on {}.{suggestion} \
283                 See docs/database-compatibility.md for the full feature matrix.",
284                feature.display_name(),
285                dialect.as_str(),
286            ),
287        })
288    }
289
290    /// Check multiple features at once and return **all** unsupported ones.
291    ///
292    /// Unlike [`check`], this collects all failures before returning, giving
293    /// the developer a complete picture in a single error message.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`FraiseQLError::Unsupported`] listing all unsupported features
298    /// if any are unsupported.
299    ///
300    /// [`check`]: Self::check
301    pub fn check_all(dialect: DatabaseType, features: &[Feature]) -> Result<(), FraiseQLError> {
302        let failures: Vec<String> = features
303            .iter()
304            .copied()
305            .filter(|&f| !dialect.supports(f))
306            .map(|f| {
307                let suggestion =
308                    dialect.suggestion_for(f).map(|s| format!(" {s}")).unwrap_or_default();
309                format!("- {}{suggestion}", f.display_name())
310            })
311            .collect();
312
313        if failures.is_empty() {
314            return Ok(());
315        }
316
317        Err(FraiseQLError::Unsupported {
318            message: format!(
319                "The following features are not supported on {}:\n{}\n\
320                 See docs/database-compatibility.md for the full feature matrix.",
321                dialect.as_str(),
322                failures.join("\n"),
323            ),
324        })
325    }
326}
327
328// ============================================================================
329// Tests
330// ============================================================================
331
332#[cfg(test)]
333mod tests {
334    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
335
336    use super::*;
337
338    // --- DatabaseType::supports ---
339
340    #[test]
341    fn test_postgres_supports_all_features() {
342        for feature in all_features() {
343            assert!(
344                DatabaseType::PostgreSQL.supports(feature),
345                "PostgreSQL should support {feature:?}"
346            );
347        }
348    }
349
350    #[test]
351    fn test_mysql_does_not_support_jsonb() {
352        assert!(!DatabaseType::MySQL.supports(Feature::JsonbPathOps));
353    }
354
355    #[test]
356    fn test_mysql_supports_mutations() {
357        assert!(DatabaseType::MySQL.supports(Feature::Mutations));
358    }
359
360    #[test]
361    fn test_mysql_supports_window_functions() {
362        assert!(DatabaseType::MySQL.supports(Feature::WindowFunctions));
363    }
364
365    #[test]
366    fn test_mysql_does_not_support_stddev() {
367        assert!(!DatabaseType::MySQL.supports(Feature::StddevVariance));
368    }
369
370    #[test]
371    fn test_sqlite_supports_cte() {
372        assert!(DatabaseType::SQLite.supports(Feature::CommonTableExpressions));
373    }
374
375    #[test]
376    fn test_sqlite_does_not_support_mutations() {
377        assert!(!DatabaseType::SQLite.supports(Feature::Mutations));
378    }
379
380    #[test]
381    fn test_sqlite_does_not_support_subscriptions() {
382        assert!(!DatabaseType::SQLite.supports(Feature::Subscriptions));
383    }
384
385    #[test]
386    fn test_sqlite_does_not_support_window_functions() {
387        assert!(!DatabaseType::SQLite.supports(Feature::WindowFunctions));
388    }
389
390    #[test]
391    fn test_sqlserver_does_not_support_jsonb() {
392        assert!(!DatabaseType::SQLServer.supports(Feature::JsonbPathOps));
393    }
394
395    #[test]
396    fn test_sqlserver_supports_mutations() {
397        assert!(DatabaseType::SQLServer.supports(Feature::Mutations));
398    }
399
400    // --- DialectCapabilityGuard::check ---
401
402    #[test]
403    fn test_guard_ok_when_supported() {
404        assert!(DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::Mutations).is_ok());
405    }
406
407    #[test]
408    fn test_guard_err_when_unsupported() {
409        let result = DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps);
410        assert!(matches!(result, Err(FraiseQLError::Unsupported { .. })));
411    }
412
413    #[test]
414    fn test_guard_error_mentions_feature_and_dialect() {
415        let err =
416            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
417        let msg = err.to_string();
418        assert!(msg.contains("JSONB"), "message should mention feature: {msg}");
419        assert!(msg.contains("mysql"), "message should mention dialect: {msg}");
420    }
421
422    #[test]
423    fn test_guard_error_includes_suggestion() {
424        let err =
425            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
426        let msg = err.to_string();
427        assert!(msg.contains("json_extract"), "message should include suggestion: {msg}");
428    }
429
430    #[test]
431    fn test_guard_check_all_returns_all_failures() {
432        let result = DialectCapabilityGuard::check_all(
433            DatabaseType::SQLite,
434            &[
435                Feature::Mutations,
436                Feature::WindowFunctions,
437                Feature::CommonTableExpressions, // supported
438            ],
439        );
440        let err = result.unwrap_err();
441        let msg = err.to_string();
442        assert!(msg.contains("Mutations"), "should mention mutations: {msg}");
443        assert!(msg.contains("Window"), "should mention window functions: {msg}");
444        // CTE is supported — must NOT appear in the error
445        assert!(!msg.contains("Common Table"), "should not mention CTEs: {msg}");
446    }
447
448    #[test]
449    fn test_guard_check_all_ok_when_all_supported() {
450        assert!(
451            DialectCapabilityGuard::check_all(
452                DatabaseType::PostgreSQL,
453                &[
454                    Feature::JsonbPathOps,
455                    Feature::Subscriptions,
456                    Feature::Mutations
457                ],
458            )
459            .is_ok()
460        );
461    }
462
463    #[test]
464    fn test_guard_error_links_to_compatibility_docs() {
465        let err =
466            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
467        let msg = err.to_string();
468        assert!(
469            msg.contains("docs/database-compatibility.md"),
470            "unsupported feature error must link to compatibility docs: {msg}"
471        );
472    }
473
474    #[test]
475    fn test_guard_check_all_error_links_to_compatibility_docs() {
476        let err = DialectCapabilityGuard::check_all(
477            DatabaseType::SQLite,
478            &[Feature::Mutations, Feature::WindowFunctions],
479        )
480        .unwrap_err();
481        let msg = err.to_string();
482        assert!(
483            msg.contains("docs/database-compatibility.md"),
484            "check_all error must link to compatibility docs: {msg}"
485        );
486    }
487
488    // --- DatabaseType::json_field_expr ---
489
490    #[test]
491    fn test_json_field_expr_postgres() {
492        assert_eq!(DatabaseType::PostgreSQL.json_field_expr("created_at"), "data->>'created_at'");
493    }
494
495    #[test]
496    fn test_json_field_expr_mysql() {
497        assert_eq!(
498            DatabaseType::MySQL.json_field_expr("name"),
499            "JSON_UNQUOTE(JSON_EXTRACT(data, '$.name'))"
500        );
501    }
502
503    #[test]
504    fn test_json_field_expr_sqlite() {
505        assert_eq!(DatabaseType::SQLite.json_field_expr("email"), "json_extract(data, '$.email')");
506    }
507
508    #[test]
509    fn test_json_field_expr_sqlserver() {
510        assert_eq!(
511            DatabaseType::SQLServer.json_field_expr("status"),
512            "JSON_VALUE(data, '$.status')"
513        );
514    }
515
516    // --- DatabaseType::typed_json_field_expr ---
517
518    #[test]
519    fn test_typed_expr_text_is_plain_extraction() {
520        // Text type should produce the same result as json_field_expr
521        assert_eq!(
522            DatabaseType::PostgreSQL.typed_json_field_expr("name", OrderByFieldType::Text),
523            DatabaseType::PostgreSQL.json_field_expr("name"),
524        );
525    }
526
527    #[test]
528    fn test_typed_expr_postgres_numeric() {
529        assert_eq!(
530            DatabaseType::PostgreSQL.typed_json_field_expr("amount", OrderByFieldType::Numeric),
531            "(data->>'amount')::numeric"
532        );
533    }
534
535    #[test]
536    fn test_typed_expr_postgres_integer() {
537        assert_eq!(
538            DatabaseType::PostgreSQL.typed_json_field_expr("count", OrderByFieldType::Integer),
539            "(data->>'count')::bigint"
540        );
541    }
542
543    #[test]
544    fn test_typed_expr_postgres_datetime() {
545        assert_eq!(
546            DatabaseType::PostgreSQL
547                .typed_json_field_expr("created_at", OrderByFieldType::DateTime),
548            "(data->>'created_at')::timestamptz"
549        );
550    }
551
552    #[test]
553    fn test_typed_expr_postgres_boolean() {
554        assert_eq!(
555            DatabaseType::PostgreSQL.typed_json_field_expr("active", OrderByFieldType::Boolean),
556            "(data->>'active')::boolean"
557        );
558    }
559
560    #[test]
561    fn test_typed_expr_mysql_numeric() {
562        assert_eq!(
563            DatabaseType::MySQL.typed_json_field_expr("amount", OrderByFieldType::Numeric),
564            "CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.amount')) AS DECIMAL(38,12))"
565        );
566    }
567
568    #[test]
569    fn test_typed_expr_mysql_integer() {
570        assert_eq!(
571            DatabaseType::MySQL.typed_json_field_expr("count", OrderByFieldType::Integer),
572            "CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.count')) AS SIGNED)"
573        );
574    }
575
576    #[test]
577    fn test_typed_expr_sqlite_numeric() {
578        assert_eq!(
579            DatabaseType::SQLite.typed_json_field_expr("amount", OrderByFieldType::Numeric),
580            "CAST(json_extract(data, '$.amount') AS REAL)"
581        );
582    }
583
584    #[test]
585    fn test_typed_expr_sqlite_datetime_is_text() {
586        // SQLite: ISO-8601 dates sort correctly as TEXT
587        assert_eq!(
588            DatabaseType::SQLite.typed_json_field_expr("created_at", OrderByFieldType::DateTime),
589            "CAST(json_extract(data, '$.created_at') AS TEXT)"
590        );
591    }
592
593    #[test]
594    fn test_typed_expr_sqlserver_numeric() {
595        assert_eq!(
596            DatabaseType::SQLServer.typed_json_field_expr("amount", OrderByFieldType::Numeric),
597            "CAST(JSON_VALUE(data, '$.amount') AS DECIMAL(38,12))"
598        );
599    }
600
601    #[test]
602    fn test_typed_expr_sqlserver_datetime() {
603        assert_eq!(
604            DatabaseType::SQLServer.typed_json_field_expr("created_at", OrderByFieldType::DateTime),
605            "CAST(JSON_VALUE(data, '$.created_at') AS DATETIME2)"
606        );
607    }
608
609    // Helper: iterate all Feature variants
610    fn all_features() -> impl Iterator<Item = Feature> {
611        [
612            Feature::JsonbPathOps,
613            Feature::Subscriptions,
614            Feature::Mutations,
615            Feature::WindowFunctions,
616            Feature::CommonTableExpressions,
617            Feature::FullTextSearch,
618            Feature::AdvisoryLocks,
619            Feature::StddevVariance,
620            Feature::Upsert,
621            Feature::ArrayTypes,
622            Feature::BackwardPagination,
623        ]
624        .into_iter()
625    }
626}