1use fraiseql_error::FraiseQLError;
21
22use crate::types::DatabaseType;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum Feature {
32 JsonbPathOps,
34 Subscriptions,
36 Mutations,
38 WindowFunctions,
40 CommonTableExpressions,
42 FullTextSearch,
44 AdvisoryLocks,
46 StddevVariance,
48 Upsert,
50 ArrayTypes,
52 BackwardPagination,
54}
55
56impl Feature {
57 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
75impl DatabaseType {
80 #[must_use]
84 pub const fn supports(self, feature: Feature) -> bool {
85 match (self, feature) {
86 (Self::PostgreSQL, _) => true,
88
89 (
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 (
104 Self::SQLServer,
105 Feature::JsonbPathOps
106 | Feature::Subscriptions
107 | Feature::AdvisoryLocks
108 | Feature::ArrayTypes,
109 ) => false,
110 (Self::SQLServer, _) => true,
111
112 (Self::SQLite, Feature::CommonTableExpressions | Feature::Upsert) => true,
114 (Self::SQLite, _) => false,
115 }
116 }
117
118 #[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
144pub struct DialectCapabilityGuard;
154
155impl DialectCapabilityGuard {
156 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 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#[cfg(test)]
226mod tests {
227 #![allow(clippy::unwrap_used)] use super::*;
230
231 #[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 #[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, ],
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 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 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}