fraiseql_server/url_guard.rs
1//! Startup-time validation that the database URL scheme matches a known
2//! FraiseQL adapter, with feature-gated dispatch to the matching adapter.
3//!
4//! `fraiseql-server` and `fraiseql run` dispatch on the URL scheme at startup
5//! to pick the right [`DatabaseAdapter`] implementation. PostgreSQL is always
6//! available; MySQL, SQLite, and SQL Server are gated behind matching Cargo
7//! features (`mysql`, `sqlite`, `sqlserver`).
8//!
9//! Without this guard, pointing the binary at a URL whose scheme is unknown
10//! or whose adapter feature is not enabled would produce an opaque error from
11//! deep inside the driver layer (connection refused, protocol mismatch, or
12//! worse). This module fails fast at startup with a diagnostic naming the
13//! observed scheme so an operator can correct the configuration or rebuild
14//! with the required feature flag.
15//!
16//! [`DatabaseAdapter`]: fraiseql_core::db::DatabaseAdapter
17
18/// Operator-facing sentinel embedded in every guard error message.
19///
20/// Tests assert against this prefix so the diagnostic stays grep-able from
21/// logs even if surrounding wording is reflowed.
22pub const GUARD_MESSAGE_PREFIX: &str = "fraiseql-server: unsupported database URL";
23
24/// Database schemes that the `fraiseql-server` binary can dispatch to.
25///
26/// The enum is exhaustive: every variant corresponds to an adapter that
27/// `main()` / `fraiseql run` know how to construct (subject to Cargo feature
28/// flags). New schemes require an explicit code change here and matching
29/// arms in `build_adapter` / `run_once` / `run_watch_loop`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DatabaseScheme {
32 /// `postgresql://` or `postgres://` — always available.
33 Postgres,
34 /// `mysql://` — requires `mysql` Cargo feature.
35 MySql,
36 /// `sqlite://` — requires `sqlite` Cargo feature. Supports direct-SQL
37 /// Insert/Delete mutations; Update and custom / stored-procedure mutations
38 /// are rejected at startup (see [`guard_sqlite_mutations`]).
39 Sqlite,
40 /// `sqlserver://` — requires `sqlserver` Cargo feature.
41 SqlServer,
42}
43
44impl DatabaseScheme {
45 /// The Cargo feature flag required to build the matching adapter into the
46 /// server binary, or `None` for adapters that ship in the default feature
47 /// set.
48 #[must_use]
49 pub const fn required_feature(self) -> Option<&'static str> {
50 match self {
51 Self::Postgres => None,
52 Self::MySql => Some("mysql"),
53 Self::Sqlite => Some("sqlite"),
54 Self::SqlServer => Some("sqlserver"),
55 }
56 }
57}
58
59/// Refuse to start a SQLite-backed server when the compiled schema declares a
60/// mutation that the SQLite (`DirectSql`) strategy cannot execute.
61///
62/// SQLite executes direct-SQL **Insert** and **Delete** mutations via the
63/// executor (`MutationStrategy::DirectSql`). **Update** and custom /
64/// stored-procedure (`fn_*`) mutations are not supported on SQLite. Without this
65/// guard the server would start and then fail those requests at runtime; the
66/// diagnostic below tells the operator why and names the first few offenders.
67///
68/// # Errors
69///
70/// Returns `anyhow::Error` when the schema contains an Update or custom mutation.
71/// Callers should invoke this *before* constructing a SQLite adapter; the
72/// PostgreSQL / MySQL / SQL Server paths must not call it.
73pub fn guard_sqlite_mutations(
74 schema: &fraiseql_core::schema::CompiledSchema,
75) -> anyhow::Result<()> {
76 use fraiseql_core::schema::MutationOperation;
77
78 let unsupported: Vec<&str> = schema
79 .mutations
80 .iter()
81 .filter(|m| {
82 matches!(m.operation, MutationOperation::Update { .. } | MutationOperation::Custom)
83 })
84 .map(|m| m.name.as_str())
85 .collect();
86 if unsupported.is_empty() {
87 return Ok(());
88 }
89 let sample: Vec<&str> = unsupported.iter().take(3).copied().collect();
90 let suffix = if unsupported.len() > sample.len() {
91 format!(", … (+{} more)", unsupported.len() - sample.len())
92 } else {
93 String::new()
94 };
95 anyhow::bail!(
96 "fraiseql-server: SQLite supports only direct-SQL Insert/Delete mutations, but the \
97 compiled schema declares {} Update or custom mutation(s) which cannot be executed \
98 against a SQLite database. Use a postgresql:// / mysql:// / sqlserver:// URL, or remove \
99 those mutations from the schema. Affected: {}{}",
100 unsupported.len(),
101 sample.join(", "),
102 suffix,
103 )
104}
105
106/// Parse the URL scheme from a database URL and return the matching
107/// [`DatabaseScheme`].
108///
109/// # Errors
110///
111/// Returns `anyhow::Error` whose message starts with [`GUARD_MESSAGE_PREFIX`]
112/// when the URL has no scheme, an empty scheme, or a scheme that is not one
113/// of the supported four. The message names the observed scheme so the
114/// operator can correct their `fraiseql.toml` or `DATABASE_URL`.
115pub fn parse_database_url(url: &str) -> anyhow::Result<DatabaseScheme> {
116 let scheme = url.split("://").next().unwrap_or("");
117 match scheme {
118 "postgresql" | "postgres" => Ok(DatabaseScheme::Postgres),
119 "mysql" => Ok(DatabaseScheme::MySql),
120 "sqlite" => Ok(DatabaseScheme::Sqlite),
121 "sqlserver" => Ok(DatabaseScheme::SqlServer),
122 "" => anyhow::bail!(
123 "{GUARD_MESSAGE_PREFIX} — the URL has no scheme. Expected one of \
124 postgresql:// | postgres:// | mysql:// | sqlite:// | sqlserver://."
125 ),
126 other => anyhow::bail!(
127 "{GUARD_MESSAGE_PREFIX} (observed URL scheme: {other:?}). The \
128 fraiseql-server binary dispatches on the URL scheme and supports \
129 postgresql:// | postgres:// | mysql:// | sqlite:// | sqlserver:// \
130 only."
131 ),
132 }
133}
134
135#[cfg(test)]
136mod tests;