pub mod emit;
pub mod ir;
pub mod lex;
pub mod nest;
pub mod psql;
pub mod spec;
pub mod sqlite;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::{Config, Dialect};
use crate::error::{GenError, Result};
use crate::schema::Schema;
pub use ir::{Analysis, Clauses, Nesting, OutputColumn, Param, Span};
pub use spec::{Cardinality, QueryFile, QuerySpec};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QueriesConfig {
pub dir: String,
pub out: String,
#[serde(default)]
pub include_prefix: Option<String>,
}
pub(crate) fn assemble_params(
spec: &QuerySpec,
placeholders: &[ir::Placeholder],
found: &std::collections::BTreeMap<usize, (String, String, &'static str)>,
spelling: char,
) -> Result<Vec<Param>> {
let query = &spec.name;
let mut numbers: Vec<usize> = placeholders.iter().map(|p| p.number).collect();
numbers.sort_unstable();
numbers.dedup();
let mut used: Vec<String> = Vec::new();
let mut params = Vec::with_capacity(numbers.len());
for n in numbers {
let annotated = spec.param_types.get(&n);
let inferred = found.get(&n);
let (rust_type, rule) = match (annotated, inferred) {
(Some(t), _) => (t.clone(), "A2"),
(None, Some((_, t, r))) => (t.clone(), *r),
(None, None) => {
return Err(GenError::Config(format!(
"query `{query}`: the type of `{spelling}{n}` cannot be inferred from its \
context; add `-- param: {spelling}{n} <RustType>`"
)));
}
};
let base = spec
.param_names
.get(&n)
.cloned()
.or_else(|| inferred.map(|(name, _, _)| name.clone()))
.unwrap_or_else(|| format!("arg{n}"));
let mut name = sanitise(&base);
while used.contains(&name) {
name.push('_');
}
used.push(name.clone());
params.push(Param {
number: n,
name,
rust_type,
rule,
});
}
Ok(params)
}
fn sanitise(name: &str) -> String {
let mut out: String = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, '_');
}
if out.is_empty() {
out.push_str("arg");
}
out.to_lowercase()
}
pub fn analyse(schema: &Schema, config: &Config, file: &QueryFile) -> Result<Vec<Analysis>> {
file.queries
.iter()
.map(|spec| match config.dialect {
Dialect::Psql => psql::analyse(schema, config, spec, &file.source),
Dialect::Sqlite => sqlite::analyse(schema, config, spec, &file.source),
Dialect::Mysql => Err(emit::mysql_refusal()),
})
.collect()
}
pub fn query_files(queries: &QueriesConfig) -> Result<Vec<QueryFile>> {
let dir = Path::new(&queries.dir);
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| GenError::Config(format!("{}: {e}", dir.display())))?
.map(|e| e.map(|e| e.path()))
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.filter(|p| p.extension().is_some_and(|e| e == "sql"))
.collect();
paths.sort();
paths.iter().map(|p| spec::load(p)).collect()
}
pub fn generate_from_schema(schema: &Schema, config: &Config) -> Result<Vec<(String, String)>> {
let queries = config.queries.as_ref().ok_or_else(|| {
GenError::Config(
"no `[queries]` section in the config, so there is nothing to generate from".to_owned(),
)
})?;
let dial = emit::Dial::new(config.dialect)?;
let files = query_files(queries)?;
let mut out = Vec::with_capacity(files.len() + 1);
let mut modules: Vec<String> = files.iter().map(|f| f.module.clone()).collect();
modules.sort();
modules.dedup();
out.push(("mod.rs".to_owned(), mod_rs(&modules)));
for file in &files {
let analyses = analyse(schema, config, file)?;
let include = include_path(queries, &file.path)?;
let tokens = emit::module(file, &analyses, &include, &dial)?;
out.push((format!("{}.rs", file.module), render(tokens)?));
}
Ok(out)
}
pub fn generate(config: &Config) -> Result<Vec<(String, String)>> {
let mut schema = crate::introspect::introspect(config)?;
crate::introspect::canonicalise(&mut schema);
generate_from_schema(&schema, config)
}
pub fn run(config: &Config) -> Result<Vec<PathBuf>> {
let queries = config
.queries
.as_ref()
.ok_or_else(|| GenError::Config("no `[queries]` section in the config".to_owned()))?;
let files = generate(config)?;
crate::write_files(Path::new(&queries.out), &files)
}
const HEADER: &str = "// @generated by keelson-gen. DO NOT EDIT.\n\
// Regenerate from the .sql files instead; the SQL is the source of truth\n\
// and lives outside this directory.\n";
fn mod_rs(modules: &[String]) -> String {
let mut out = String::from(HEADER);
out.push_str("\n//! The generated queries, one module per .sql file.\n\n");
for m in modules {
let module = crate::names::ident(m);
out.push_str(&format!("pub mod {module};\n"));
}
out
}
fn render(tokens: proc_macro2::TokenStream) -> Result<String> {
let file: syn::File = syn::parse2(tokens)
.map_err(|e| GenError::Config(format!("internal: generated tokens do not parse: {e}")))?;
Ok(format!("{HEADER}\n{}", prettyplease::unparse(&file)))
}
fn include_path(queries: &QueriesConfig, sql: &Path) -> Result<String> {
let name = sql
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| GenError::Config(format!("{}: unusable file name", sql.display())))?;
if let Some(prefix) = &queries.include_prefix {
return Ok(format!("{}{name}", with_slash(prefix)));
}
let rel = relative(Path::new(&queries.out), Path::new(&queries.dir)).ok_or_else(|| {
GenError::Config(format!(
"cannot express `{}` relative to `{}`; set `[queries] include_prefix`",
queries.dir, queries.out
))
})?;
Ok(format!("{}{name}", with_slash(&rel)))
}
fn with_slash(p: &str) -> String {
if p.is_empty() || p.ends_with('/') {
p.to_owned()
} else {
format!("{p}/")
}
}
fn relative(from: &Path, to: &Path) -> Option<String> {
use std::path::Component;
let parts = |p: &Path| -> Option<Vec<String>> {
let mut out = Vec::new();
for c in p.components() {
match c {
Component::Normal(s) => out.push(s.to_str()?.to_owned()),
Component::CurDir => {}
Component::RootDir => out.push("/".to_owned()),
Component::Prefix(_) | Component::ParentDir => return None,
}
}
Some(out)
};
let (from, to) = (parts(from)?, parts(to)?);
if from.first().map(String::as_str) == Some("/") || to.first().map(String::as_str) == Some("/")
{
if from.first() != to.first() {
return None;
}
}
let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
let mut out: Vec<&str> = vec![".."; from.len() - common];
out.extend(to[common..].iter().map(String::as_str));
Some(out.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_include_path_walks_up_from_the_output_directory() {
assert_eq!(
relative(Path::new("src/queries"), Path::new("queries")).as_deref(),
Some("../../queries")
);
assert_eq!(
relative(Path::new("src/gen"), Path::new("src/sql")).as_deref(),
Some("../sql")
);
assert_eq!(
relative(Path::new("a"), Path::new("a/b")).as_deref(),
Some("b")
);
}
#[test]
fn an_unrelatable_pair_is_a_config_error_naming_the_escape_hatch() {
let q = QueriesConfig {
dir: "/abs/queries".to_owned(),
out: "src/queries".to_owned(),
include_prefix: None,
};
let err = include_path(&q, Path::new("/abs/queries/users.sql")).unwrap_err();
assert!(err.to_string().contains("include_prefix"), "{err}");
}
#[test]
fn include_prefix_overrides_the_computation() {
let q = QueriesConfig {
dir: "/abs/queries".to_owned(),
out: "src/queries".to_owned(),
include_prefix: Some("../../sql".to_owned()),
};
assert_eq!(
include_path(&q, Path::new("/abs/queries/users.sql")).unwrap(),
"../../sql/users.sql"
);
}
}