1use fraiseql_error::FraiseQLError;
21
22use crate::types::{DatabaseType, sql_hints::OrderByFieldType};
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]
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 #[must_use]
127 pub fn typed_json_field_expr(self, key: &str, field_type: OrderByFieldType) -> String {
128 use OrderByFieldType as F;
129
130 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 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", };
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 #[must_use]
191 pub const fn supports(self, feature: Feature) -> bool {
192 match (self, feature) {
193 (Self::PostgreSQL, _) => true,
195
196 (
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 (
211 Self::SQLServer,
212 Feature::JsonbPathOps
213 | Feature::Subscriptions
214 | Feature::AdvisoryLocks
215 | Feature::ArrayTypes,
216 ) => false,
217 (Self::SQLServer, _) => true,
218
219 (Self::SQLite, Feature::CommonTableExpressions | Feature::Upsert) => true,
221 (Self::SQLite, _) => false,
222 }
223 }
224
225 #[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
251pub struct DialectCapabilityGuard;
261
262impl DialectCapabilityGuard {
263 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 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#[cfg(test)]
333mod tests {
334 #![allow(clippy::unwrap_used)] use super::*;
337
338 #[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 #[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, ],
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 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 #[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 #[test]
519 fn test_typed_expr_text_is_plain_extraction() {
520 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 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 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}