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;
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    /// Check whether this dialect supports `feature`.
81    ///
82    /// All checks are `const`-friendly and zero-cost at runtime.
83    #[must_use]
84    pub const fn supports(self, feature: Feature) -> bool {
85        match (self, feature) {
86            // PostgreSQL: fully featured
87            (Self::PostgreSQL, _) => true,
88
89            // MySQL 8+: no JSONB path ops, subscriptions, advisory locks,
90            // STDDEV, array types. Everything else is supported.
91            (
92                Self::MySQL,
93                Feature::JsonbPathOps
94                | Feature::Subscriptions
95                | Feature::AdvisoryLocks
96                | Feature::StddevVariance
97                | Feature::ArrayTypes,
98            ) => false,
99            (Self::MySQL, _) => true,
100
101            // SQL Server: no JSONB path ops, subscriptions, advisory locks,
102            // array types. Everything else is supported.
103            (
104                Self::SQLServer,
105                Feature::JsonbPathOps
106                | Feature::Subscriptions
107                | Feature::AdvisoryLocks
108                | Feature::ArrayTypes,
109            ) => false,
110            (Self::SQLServer, _) => true,
111
112            // SQLite: very limited — only CTEs and Upsert are supported
113            (Self::SQLite, Feature::CommonTableExpressions | Feature::Upsert) => true,
114            (Self::SQLite, _) => false,
115        }
116    }
117
118    /// Return a human-readable migration suggestion for an unsupported feature.
119    ///
120    /// `None` means no specific guidance is available beyond the error message.
121    #[must_use]
122    pub const fn suggestion_for(self, feature: Feature) -> Option<&'static str> {
123        match (self, feature) {
124            (Self::MySQL, Feature::JsonbPathOps) => {
125                Some("Use `json_extract(column, '$.key')` syntax instead of JSONB path operators.")
126            },
127            (Self::MySQL, Feature::StddevVariance) => {
128                Some("MySQL does not provide STDDEV/VARIANCE; compute them in application code.")
129            },
130            (Self::SQLite, Feature::Mutations) => Some(
131                "SQLite mutations are not supported. Use PostgreSQL or MySQL for mutation support.",
132            ),
133            (Self::SQLite, Feature::WindowFunctions) => Some(
134                "SQLite 3.25+ supports basic window functions; upgrade your SQLite version or use PostgreSQL.",
135            ),
136            (Self::SQLite, Feature::Subscriptions) => {
137                Some("Subscriptions require a database with LISTEN/NOTIFY. Use PostgreSQL.")
138            },
139            _ => None,
140        }
141    }
142}
143
144// ============================================================================
145// Guard
146// ============================================================================
147
148/// Fail-fast guard that checks database dialect capabilities before SQL generation.
149///
150/// Call [`DialectCapabilityGuard::check`] during query planning to produce
151/// a `FraiseQLError::Unsupported` with actionable guidance instead of a
152/// cryptic driver error.
153pub struct DialectCapabilityGuard;
154
155impl DialectCapabilityGuard {
156    /// Check that `dialect` supports `feature`.
157    ///
158    /// Returns `Ok(())` if the feature is supported, or
159    /// `Err(FraiseQLError::Unsupported)` with a human-readable message.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`FraiseQLError::Unsupported`] when the feature is not available
164    /// on the specified dialect.
165    pub fn check(dialect: DatabaseType, feature: Feature) -> Result<(), FraiseQLError> {
166        if dialect.supports(feature) {
167            return Ok(());
168        }
169
170        let suggestion =
171            dialect.suggestion_for(feature).map(|s| format!(" {s}")).unwrap_or_default();
172
173        Err(FraiseQLError::Unsupported {
174            message: format!(
175                "{} is not supported on {}.{suggestion} \
176                 See docs/database-compatibility.md for the full feature matrix.",
177                feature.display_name(),
178                dialect.as_str(),
179            ),
180        })
181    }
182
183    /// Check multiple features at once and return **all** unsupported ones.
184    ///
185    /// Unlike [`check`], this collects all failures before returning, giving
186    /// the developer a complete picture in a single error message.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`FraiseQLError::Unsupported`] listing all unsupported features
191    /// if any are unsupported.
192    ///
193    /// [`check`]: Self::check
194    pub fn check_all(dialect: DatabaseType, features: &[Feature]) -> Result<(), FraiseQLError> {
195        let failures: Vec<String> = features
196            .iter()
197            .copied()
198            .filter(|&f| !dialect.supports(f))
199            .map(|f| {
200                let suggestion =
201                    dialect.suggestion_for(f).map(|s| format!(" {s}")).unwrap_or_default();
202                format!("- {}{suggestion}", f.display_name())
203            })
204            .collect();
205
206        if failures.is_empty() {
207            return Ok(());
208        }
209
210        Err(FraiseQLError::Unsupported {
211            message: format!(
212                "The following features are not supported on {}:\n{}\n\
213                 See docs/database-compatibility.md for the full feature matrix.",
214                dialect.as_str(),
215                failures.join("\n"),
216            ),
217        })
218    }
219}
220
221// ============================================================================
222// Tests
223// ============================================================================
224
225#[cfg(test)]
226mod tests {
227    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
228
229    use super::*;
230
231    // --- DatabaseType::supports ---
232
233    #[test]
234    fn test_postgres_supports_all_features() {
235        for feature in all_features() {
236            assert!(
237                DatabaseType::PostgreSQL.supports(feature),
238                "PostgreSQL should support {feature:?}"
239            );
240        }
241    }
242
243    #[test]
244    fn test_mysql_does_not_support_jsonb() {
245        assert!(!DatabaseType::MySQL.supports(Feature::JsonbPathOps));
246    }
247
248    #[test]
249    fn test_mysql_supports_mutations() {
250        assert!(DatabaseType::MySQL.supports(Feature::Mutations));
251    }
252
253    #[test]
254    fn test_mysql_supports_window_functions() {
255        assert!(DatabaseType::MySQL.supports(Feature::WindowFunctions));
256    }
257
258    #[test]
259    fn test_mysql_does_not_support_stddev() {
260        assert!(!DatabaseType::MySQL.supports(Feature::StddevVariance));
261    }
262
263    #[test]
264    fn test_sqlite_supports_cte() {
265        assert!(DatabaseType::SQLite.supports(Feature::CommonTableExpressions));
266    }
267
268    #[test]
269    fn test_sqlite_does_not_support_mutations() {
270        assert!(!DatabaseType::SQLite.supports(Feature::Mutations));
271    }
272
273    #[test]
274    fn test_sqlite_does_not_support_subscriptions() {
275        assert!(!DatabaseType::SQLite.supports(Feature::Subscriptions));
276    }
277
278    #[test]
279    fn test_sqlite_does_not_support_window_functions() {
280        assert!(!DatabaseType::SQLite.supports(Feature::WindowFunctions));
281    }
282
283    #[test]
284    fn test_sqlserver_does_not_support_jsonb() {
285        assert!(!DatabaseType::SQLServer.supports(Feature::JsonbPathOps));
286    }
287
288    #[test]
289    fn test_sqlserver_supports_mutations() {
290        assert!(DatabaseType::SQLServer.supports(Feature::Mutations));
291    }
292
293    // --- DialectCapabilityGuard::check ---
294
295    #[test]
296    fn test_guard_ok_when_supported() {
297        assert!(DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::Mutations).is_ok());
298    }
299
300    #[test]
301    fn test_guard_err_when_unsupported() {
302        let result = DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps);
303        assert!(matches!(result, Err(FraiseQLError::Unsupported { .. })));
304    }
305
306    #[test]
307    fn test_guard_error_mentions_feature_and_dialect() {
308        let err =
309            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
310        let msg = err.to_string();
311        assert!(msg.contains("JSONB"), "message should mention feature: {msg}");
312        assert!(msg.contains("mysql"), "message should mention dialect: {msg}");
313    }
314
315    #[test]
316    fn test_guard_error_includes_suggestion() {
317        let err =
318            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
319        let msg = err.to_string();
320        assert!(msg.contains("json_extract"), "message should include suggestion: {msg}");
321    }
322
323    #[test]
324    fn test_guard_check_all_returns_all_failures() {
325        let result = DialectCapabilityGuard::check_all(
326            DatabaseType::SQLite,
327            &[
328                Feature::Mutations,
329                Feature::WindowFunctions,
330                Feature::CommonTableExpressions, // supported
331            ],
332        );
333        let err = result.unwrap_err();
334        let msg = err.to_string();
335        assert!(msg.contains("Mutations"), "should mention mutations: {msg}");
336        assert!(msg.contains("Window"), "should mention window functions: {msg}");
337        // CTE is supported — must NOT appear in the error
338        assert!(!msg.contains("Common Table"), "should not mention CTEs: {msg}");
339    }
340
341    #[test]
342    fn test_guard_check_all_ok_when_all_supported() {
343        assert!(
344            DialectCapabilityGuard::check_all(
345                DatabaseType::PostgreSQL,
346                &[
347                    Feature::JsonbPathOps,
348                    Feature::Subscriptions,
349                    Feature::Mutations
350                ],
351            )
352            .is_ok()
353        );
354    }
355
356    #[test]
357    fn test_guard_error_links_to_compatibility_docs() {
358        let err =
359            DialectCapabilityGuard::check(DatabaseType::MySQL, Feature::JsonbPathOps).unwrap_err();
360        let msg = err.to_string();
361        assert!(
362            msg.contains("docs/database-compatibility.md"),
363            "unsupported feature error must link to compatibility docs: {msg}"
364        );
365    }
366
367    #[test]
368    fn test_guard_check_all_error_links_to_compatibility_docs() {
369        let err = DialectCapabilityGuard::check_all(
370            DatabaseType::SQLite,
371            &[Feature::Mutations, Feature::WindowFunctions],
372        )
373        .unwrap_err();
374        let msg = err.to_string();
375        assert!(
376            msg.contains("docs/database-compatibility.md"),
377            "check_all error must link to compatibility docs: {msg}"
378        );
379    }
380
381    // Helper: iterate all Feature variants
382    fn all_features() -> impl Iterator<Item = Feature> {
383        [
384            Feature::JsonbPathOps,
385            Feature::Subscriptions,
386            Feature::Mutations,
387            Feature::WindowFunctions,
388            Feature::CommonTableExpressions,
389            Feature::FullTextSearch,
390            Feature::AdvisoryLocks,
391            Feature::StddevVariance,
392            Feature::Upsert,
393            Feature::ArrayTypes,
394            Feature::BackwardPagination,
395        ]
396        .into_iter()
397    }
398}