keelson_gen/queries/mod.rs
1//! Layer 4: hand-written SQL in, typed Rust out — and the same query usable
2//! as a *mod*.
3//!
4//! This is the sqlc-shaped half of keelson-gen. Layer 2 generates a model per
5//! table; this generates a module per **query file**: you write the SQL, and
6//! the generator reads it against the introspected schema to give you a typed
7//! parameter struct, a typed row struct, and an `async fn` against
8//! `&dyn Executor`.
9//!
10//! What makes it keelson's rather than sqlc's is that a generated query has
11//! **two faces**, both cut from one analysis:
12//!
13//! 1. **As a query** — a real `keelson_core::Query` over the file's own SQL,
14//! executed as written. It picks up the whole execution layer (`fetch_all`,
15//! tracing, transactions) and nests as a sub-select for free, because its
16//! placeholders are re-bound through the writer rather than copied.
17//! 2. **As a mod** — `<name>_mod(params)` is an `impl Mod<SelectQuery>` that
18//! slices the *same* byte ranges and feeds each clause into the host
19//! statement's corresponding clause. Its `WHERE` is `AND`ed onto the host's;
20//! its `FROM` (joins included) is contributed when the host has none. **It
21//! does not nest as a sub-query**: one flat statement comes out, which is
22//! the whole point —
23//!
24//! ```ignore
25//! users::table().query((
26//! queries::users::active_since_mod(cutoff), // hand-written SQL, merged flat
27//! users::status().eq("published"), // a typed model filter
28//! select::limit(20), // a Layer 1 mod
29//! ))
30//! ```
31//!
32//! Where the mod face cannot be honest — a set operation, a CTE, a
33//! non-`SELECT` — the generator emits the query face and a recorded refusal
34//! naming the reason. It never fakes flatness by nesting the query as a
35//! sub-select.
36//!
37//! # The pipeline
38//!
39//! `spec` (annotations + statement spans) → the dialect analyser (`psql`,
40//! `sqlite`) → [`ir::Analysis`] → `emit`. The analyser runs the dialect's own
41//! parser for structure **and** a token scan for byte offsets in one pass, so
42//! the row types and the clause slices are two readings of one analysis and
43//! cannot drift.
44//!
45//! # The nullability decision table
46//!
47//! Getting `Option<T>` right is the whole game, so every rule is numbered,
48//! carried on [`ir::OutputColumn::rule`], written into the generated file as a
49//! doc comment, and tested one by one.
50//!
51//! | id | the shape | result |
52//! |---|---|---|
53//! | N1 | a column of a table reached by `FROM` or an inner join | the DDL's nullability |
54//! | N2 | a column of a table on the nullable side of an outer join (`LEFT`'s right, `RIGHT`'s left, either side of `FULL`) | **nullable, even when the DDL says `NOT NULL`** |
55//! | N3 | `WHERE col IS NOT NULL` | *no effect*: a filter narrows the rows, not the type |
56//! | N4 | `COUNT(…)`, `COUNT(*)` | never NULL — an empty group counts zero |
57//! | N5 | every other aggregate (`SUM`, `AVG`, `MIN`, `MAX`, `STRING_AGG`, `BOOL_AND`, …) | nullable — an empty group has no value |
58//! | N7 | `COALESCE(a, b, …)` | nullable only when **every** argument is |
59//! | N8 | a literal, `NOW()`, `CURRENT_DATE`, `CONCAT(…)` | never NULL (a bare `NULL` literal is nullable and untyped) |
60//! | N9 | `CASE` | nullable when any arm is, or when there is no `ELSE` |
61//! | N10 | an operator (`=`, `<`, `+`, `\|\|`, `AND`, …) | nullable when **any** operand is — SQL's three-valued logic |
62//! | N11 | `IS NULL` / `IS NOT NULL` / `EXISTS` / `IN` | `bool`, never NULL |
63//! | N12 | a scalar sub-query in the select list | nullable — zero rows yields NULL |
64//! | N13 | `x::type` / `CAST(x AS t)` | the target type, the operand's nullability |
65//! | N14 | a column of a set operation (`UNION`/`INTERSECT`/`EXCEPT`) | nullable when **any** arm's column is; a type disagreement is refused |
66//! | N15 | a window function | `ROW_NUMBER`/`RANK`/`DENSE_RANK`/`NTILE` never NULL; the rest nullable |
67//! | N16 | `-- nullable: <col> true\|false` | wins over everything above |
68//! | A1 | `-- column: <col> <RustType>` | fixes the type (not the nullability) |
69//!
70//! And the parameter side, which the same machinery decides:
71//!
72//! | id | where the type came from |
73//! |---|---|
74//! | P1 | the column the placeholder is compared with (`WHERE user_id = $1`, `IN ($1, $2)`, `BETWEEN`, `LIKE`) |
75//! | P2 | an explicit cast on the placeholder (`$1::uuid`) |
76//! | P3 | the clause it sits in — `LIMIT`/`OFFSET` are `i64` |
77//! | A2 | `-- param: $n [name] <RustType>` |
78//!
79//! A bound parameter is a *value*, so rule N10 does not treat it as a source
80//! of NULL: `views > $1` over a `NOT NULL` column is a non-nullable boolean.
81//! Declare `-- param: $1 Option<T>` if the call site really can pass NULL.
82//!
83//! Rule **N6 is deliberately absent** and recorded here so the gap is visible:
84//! an aggregate's nullability under `GROUP BY` collapses into N4/N5 —
85//! `GROUP BY` changes which rows exist, never whether a value can be NULL.
86//!
87//! When a type cannot be inferred the generator **refuses**, naming the
88//! column and the annotation that would settle it. It never guesses `String`.
89//!
90//! # Dialects, honestly
91//!
92//! - **PostgreSQL** — complete: `SELECT` (set operations included) plus
93//! `INSERT`/`UPDATE`/`DELETE` typed from their `RETURNING` list. `pg_query`
94//! bundles the server's own parser, so the tree being read is PostgreSQL's.
95//! Pinned against a live PostgreSQL 17 under `--features live-docker`.
96//! - **SQLite** — complete for the same shapes, through `sqlite3-parser`
97//! (`lemon-rs`: SQLite's own `parse.y`, ported), with `UPDATE`/`DELETE`
98//! typed from their `RETURNING`. The nullability rules are identical; the
99//! *types* differ where SQLite's do — every integer is `i64`, a comparison
100//! is an integer rather than a boolean, `sum` does not widen — and a
101//! declared type carries less, so more queries need a `-- column:`
102//! annotation. That is the schema's limit, recorded, not a weaker analyser.
103//! - **MySQL** — [`GenError::Unsupported`], recorded: there is no trustworthy
104//! static parse tree for it in this workspace (`sqlparser` is a generic
105//! parser, not MySQL's), and the server will not describe a statement's
106//! result columns without executing it. An honest refusal beats an inferred
107//! type nobody can trust.
108//!
109//! # What the mod face refuses, and why
110//!
111//! A set operation, a `WITH` clause, a `FETCH`/`FOR UPDATE` tail, and any
112//! non-`SELECT` have no clause a host `SELECT` could absorb without changing
113//! meaning. Each keeps its query face and records the reason on
114//! [`ir::Clauses::unsupported`], which the generated module repeats as a doc
115//! comment. The generator never substitutes a sub-select for the flat merge.
116//!
117//! Two more rules of the merge, chosen and recorded rather than discovered:
118//!
119//! - the **select list is not contributed**, because the host statement owns
120//! its projection — that is what lets a typed model query and a mod sit in
121//! the same tuple;
122//! - the **`FROM` is contributed only when the host has none**, so a model
123//! query already reading the same table keeps its own; the joins ride
124//! inside that one `FROM` item, which is what keeps the result flat.
125//!
126//! # Nested rows
127//!
128//! Output column names carry structure, bob's way: `author__name` is a to-one
129//! nested field, `tags.name` is a to-many one, and `-- prefix:` switches the
130//! separator. See [`nest`].
131
132pub mod emit;
133pub mod ir;
134pub mod lex;
135pub mod nest;
136pub mod psql;
137pub mod spec;
138pub mod sqlite;
139
140use std::path::{Path, PathBuf};
141
142use serde::Deserialize;
143
144use crate::config::{Config, Dialect};
145use crate::error::{GenError, Result};
146use crate::schema::Schema;
147
148pub use ir::{Analysis, Clauses, Nesting, OutputColumn, Param, Span};
149pub use spec::{Cardinality, QueryFile, QuerySpec};
150
151/// The `[queries]` section: where the `.sql` files are, and where the
152/// generated modules go.
153#[derive(Debug, Clone, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct QueriesConfig {
156 /// The directory holding the `.sql` query files. Every `.sql` file
157 /// directly inside it is generated from, in sorted order.
158 pub dir: String,
159 /// Where the generated modules land. Defaults to `<dir>` is deliberately
160 /// *not* the rule — generated code and hand-written SQL stay apart.
161 pub out: String,
162 /// Path prefix the generated `include_str!` uses instead of the one
163 /// computed from `out` → `dir`. Set it when the two are not relatable
164 /// (different roots, or a build script writing outside the source tree).
165 #[serde(default)]
166 pub include_prefix: Option<String>,
167}
168
169/// Turn what the analysers found into the parameter list, in placeholder
170/// order.
171///
172/// Shared by both dialects because the policy is the same one either way: an
173/// explicit `-- param:` annotation wins, a type learnt from context comes
174/// next, and a placeholder with neither is a **refusal** naming the annotation
175/// that would settle it. Names are made unique by suffixing, so two
176/// placeholders compared with the same column still produce two fields.
177pub(crate) fn assemble_params(
178 spec: &QuerySpec,
179 placeholders: &[ir::Placeholder],
180 found: &std::collections::BTreeMap<usize, (String, String, &'static str)>,
181 spelling: char,
182) -> Result<Vec<Param>> {
183 let query = &spec.name;
184 let mut numbers: Vec<usize> = placeholders.iter().map(|p| p.number).collect();
185 numbers.sort_unstable();
186 numbers.dedup();
187
188 let mut used: Vec<String> = Vec::new();
189 let mut params = Vec::with_capacity(numbers.len());
190 for n in numbers {
191 let annotated = spec.param_types.get(&n);
192 let inferred = found.get(&n);
193 let (rust_type, rule) = match (annotated, inferred) {
194 (Some(t), _) => (t.clone(), "A2"),
195 (None, Some((_, t, r))) => (t.clone(), *r),
196 (None, None) => {
197 return Err(GenError::Config(format!(
198 "query `{query}`: the type of `{spelling}{n}` cannot be inferred from its \
199 context; add `-- param: {spelling}{n} <RustType>`"
200 )));
201 }
202 };
203 let base = spec
204 .param_names
205 .get(&n)
206 .cloned()
207 .or_else(|| inferred.map(|(name, _, _)| name.clone()))
208 .unwrap_or_else(|| format!("arg{n}"));
209 let mut name = sanitise(&base);
210 while used.contains(&name) {
211 name.push('_');
212 }
213 used.push(name.clone());
214 params.push(Param {
215 number: n,
216 name,
217 rust_type,
218 rule,
219 });
220 }
221 Ok(params)
222}
223
224/// A SQL name made into a Rust field name.
225fn sanitise(name: &str) -> String {
226 let mut out: String = name
227 .chars()
228 .map(|c| if c.is_alphanumeric() { c } else { '_' })
229 .collect();
230 if out.starts_with(|c: char| c.is_ascii_digit()) {
231 out.insert(0, '_');
232 }
233 if out.is_empty() {
234 out.push_str("arg");
235 }
236 out.to_lowercase()
237}
238
239/// Analyse one query file against the schema.
240pub fn analyse(schema: &Schema, config: &Config, file: &QueryFile) -> Result<Vec<Analysis>> {
241 file.queries
242 .iter()
243 .map(|spec| match config.dialect {
244 Dialect::Psql => psql::analyse(schema, config, spec, &file.source),
245 Dialect::Sqlite => sqlite::analyse(schema, config, spec, &file.source),
246 Dialect::Mysql => Err(emit::mysql_refusal()),
247 })
248 .collect()
249}
250
251/// Every `.sql` file the configuration points at, in sorted order.
252pub fn query_files(queries: &QueriesConfig) -> Result<Vec<QueryFile>> {
253 let dir = Path::new(&queries.dir);
254 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
255 .map_err(|e| GenError::Config(format!("{}: {e}", dir.display())))?
256 .map(|e| e.map(|e| e.path()))
257 .collect::<std::result::Result<Vec<_>, _>>()?
258 .into_iter()
259 .filter(|p| p.extension().is_some_and(|e| e == "sql"))
260 .collect();
261 paths.sort();
262 paths.iter().map(|p| spec::load(p)).collect()
263}
264
265/// Render every generated file as `(file name, contents)`, `mod.rs` first.
266pub fn generate_from_schema(schema: &Schema, config: &Config) -> Result<Vec<(String, String)>> {
267 let queries = config.queries.as_ref().ok_or_else(|| {
268 GenError::Config(
269 "no `[queries]` section in the config, so there is nothing to generate from".to_owned(),
270 )
271 })?;
272 let dial = emit::Dial::new(config.dialect)?;
273 let files = query_files(queries)?;
274
275 let mut out = Vec::with_capacity(files.len() + 1);
276 let mut modules: Vec<String> = files.iter().map(|f| f.module.clone()).collect();
277 modules.sort();
278 modules.dedup();
279 out.push(("mod.rs".to_owned(), mod_rs(&modules)));
280
281 for file in &files {
282 let analyses = analyse(schema, config, file)?;
283 let include = include_path(queries, &file.path)?;
284 let tokens = emit::module(file, &analyses, &include, &dial)?;
285 out.push((format!("{}.rs", file.module), render(tokens)?));
286 }
287 Ok(out)
288}
289
290/// Introspect, then render — without touching the filesystem.
291pub fn generate(config: &Config) -> Result<Vec<(String, String)>> {
292 let mut schema = crate::introspect::introspect(config)?;
293 crate::introspect::canonicalise(&mut schema);
294 generate_from_schema(&schema, config)
295}
296
297/// Introspect, render, write into `[queries] out`. What the CLI's
298/// `--queries` flag calls.
299pub fn run(config: &Config) -> Result<Vec<PathBuf>> {
300 let queries = config
301 .queries
302 .as_ref()
303 .ok_or_else(|| GenError::Config("no `[queries]` section in the config".to_owned()))?;
304 let files = generate(config)?;
305 crate::write_files(Path::new(&queries.out), &files)
306}
307
308const HEADER: &str = "// @generated by keelson-gen. DO NOT EDIT.\n\
309 // Regenerate from the .sql files instead; the SQL is the source of truth\n\
310 // and lives outside this directory.\n";
311
312fn mod_rs(modules: &[String]) -> String {
313 let mut out = String::from(HEADER);
314 out.push_str("\n//! The generated queries, one module per .sql file.\n\n");
315 for m in modules {
316 let module = crate::names::ident(m);
317 out.push_str(&format!("pub mod {module};\n"));
318 }
319 out
320}
321
322fn render(tokens: proc_macro2::TokenStream) -> Result<String> {
323 let file: syn::File = syn::parse2(tokens)
324 .map_err(|e| GenError::Config(format!("internal: generated tokens do not parse: {e}")))?;
325 Ok(format!("{HEADER}\n{}", prettyplease::unparse(&file)))
326}
327
328/// The path a generated module's `include_str!` uses to reach its `.sql` file.
329///
330/// Computed from the configured strings rather than from canonicalised paths:
331/// an absolute path would pin the output to one machine, and determinism is a
332/// contract here.
333fn include_path(queries: &QueriesConfig, sql: &Path) -> Result<String> {
334 let name = sql
335 .file_name()
336 .and_then(|n| n.to_str())
337 .ok_or_else(|| GenError::Config(format!("{}: unusable file name", sql.display())))?;
338 if let Some(prefix) = &queries.include_prefix {
339 return Ok(format!("{}{name}", with_slash(prefix)));
340 }
341 let rel = relative(Path::new(&queries.out), Path::new(&queries.dir)).ok_or_else(|| {
342 GenError::Config(format!(
343 "cannot express `{}` relative to `{}`; set `[queries] include_prefix`",
344 queries.dir, queries.out
345 ))
346 })?;
347 Ok(format!("{}{name}", with_slash(&rel)))
348}
349
350fn with_slash(p: &str) -> String {
351 if p.is_empty() || p.ends_with('/') {
352 p.to_owned()
353 } else {
354 format!("{p}/")
355 }
356}
357
358/// A `../`-relative path from `from` to `to`, both read as written.
359fn relative(from: &Path, to: &Path) -> Option<String> {
360 use std::path::Component;
361 let parts = |p: &Path| -> Option<Vec<String>> {
362 let mut out = Vec::new();
363 for c in p.components() {
364 match c {
365 Component::Normal(s) => out.push(s.to_str()?.to_owned()),
366 Component::CurDir => {}
367 Component::RootDir => out.push("/".to_owned()),
368 Component::Prefix(_) | Component::ParentDir => return None,
369 }
370 }
371 Some(out)
372 };
373 let (from, to) = (parts(from)?, parts(to)?);
374 if from.first().map(String::as_str) == Some("/") || to.first().map(String::as_str) == Some("/")
375 {
376 // Mixing an absolute and a relative side cannot produce a portable
377 // include path.
378 if from.first() != to.first() {
379 return None;
380 }
381 }
382 let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
383 let mut out: Vec<&str> = vec![".."; from.len() - common];
384 out.extend(to[common..].iter().map(String::as_str));
385 Some(out.join("/"))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn the_include_path_walks_up_from_the_output_directory() {
394 assert_eq!(
395 relative(Path::new("src/queries"), Path::new("queries")).as_deref(),
396 Some("../../queries")
397 );
398 assert_eq!(
399 relative(Path::new("src/gen"), Path::new("src/sql")).as_deref(),
400 Some("../sql")
401 );
402 assert_eq!(
403 relative(Path::new("a"), Path::new("a/b")).as_deref(),
404 Some("b")
405 );
406 }
407
408 #[test]
409 fn an_unrelatable_pair_is_a_config_error_naming_the_escape_hatch() {
410 let q = QueriesConfig {
411 dir: "/abs/queries".to_owned(),
412 out: "src/queries".to_owned(),
413 include_prefix: None,
414 };
415 let err = include_path(&q, Path::new("/abs/queries/users.sql")).unwrap_err();
416 assert!(err.to_string().contains("include_prefix"), "{err}");
417 }
418
419 #[test]
420 fn include_prefix_overrides_the_computation() {
421 let q = QueriesConfig {
422 dir: "/abs/queries".to_owned(),
423 out: "src/queries".to_owned(),
424 include_prefix: Some("../../sql".to_owned()),
425 };
426 assert_eq!(
427 include_path(&q, Path::new("/abs/queries/users.sql")).unwrap(),
428 "../../sql/users.sql"
429 );
430 }
431}