rok-sqlc 0.3.0

Generate SQLx Rust database modules from SQL schema files
Documentation
use std::collections::BTreeMap;
use std::path::PathBuf;

/// What to generate and how: the one configuration model behind the CLI and
/// [`generate`](crate::generate).
///
/// New fields may be added in minor releases, so build a `Config` from
/// [`Config::default`] and assign the fields you need:
///
/// ```no_run
/// let mut config = rok_sqlc::Config::default();
/// config.schema = vec!["migrations/0001_init.sql".into()];
/// config.output = "src/db".into();
/// rok_sqlc::generate(&config)?;
/// # Ok::<(), rok_sqlc::Error>(())
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Config {
    /// Schema inputs, combined in the order given: a file, or a directory
    /// whose `*.sql` migrations are read in filename order, skipping
    /// `*.down.sql`.
    pub schema: Vec<PathBuf>,
    /// Annotated query files or directories. A single file becomes the
    /// generated `queries.rs`. A directory (its `*.sql` files) or several
    /// inputs become one `queries/<stem>.rs` module per file, declared by a
    /// generated `queries/mod.rs`; each stem must be a snake_case Rust
    /// module name, and stems must be unique.
    ///
    /// Switching between the two layouts removes the old layout's generated
    /// files as stale. Left empty, a previously generated `queries.rs` or
    /// `queries/` is kept and still declared.
    pub queries: Vec<PathBuf>,
    /// The directory generated modules are written to. Defaults to `src/db`.
    pub output: PathBuf,
    /// Generate the table with this SQL or module name and regenerate the
    /// resources already generated in the output directory; tables not
    /// generated yet stay ungenerated.
    pub resource: Option<String>,
    /// Plan the run and report it without touching the filesystem.
    pub dry_run: bool,
    /// Write and remove nothing; report missing, changed, and stale files.
    /// Any such drift fails the run. Takes precedence over `dry_run`.
    pub check: bool,
    /// Overwrite existing files that were not generated by rok-sqlc.
    pub force: bool,
    /// Skip unsupported tables and queries (listed in the report) instead
    /// of failing.
    pub lenient: bool,
    /// Opt-in conventions about what certain columns mean across tables.
    /// All off by default; [`Conventions::rok`] gives the Rok preset.
    pub conventions: Conventions,
    /// Extra derives for the generated rows, typed ids, key structs, enums,
    /// inputs, and patches (ADR 0013). Empty by default.
    pub derive: Vec<Derive>,
    /// HTTP frameworks to generate validated-body extractors for, into
    /// `http/` of the output: `mod.rs` and a module per framework (ADR 0015).
    /// Needs [`Derive::Validate`], and reserves the module name `http`. Empty
    /// by default.
    pub extractors: Vec<Extractor>,
    /// Renames and opt-outs of inferred relationships (ADR 0014), by table
    /// (its SQL name, schema-qualified name, or module name) and then by the
    /// relationship's inferred name. Empty by default.
    ///
    /// ```
    /// use rok_sqlc::RelationshipChange;
    /// let mut config = rok_sqlc::Config::default();
    /// let invoices = config.relationships.entry("invoices".into()).or_default();
    /// invoices.insert("created_by_user".into(), RelationshipChange::Rename("author".into()));
    /// invoices.insert("approved_by_user".into(), RelationshipChange::OptOut);
    /// ```
    pub relationships: BTreeMap<String, BTreeMap<String, RelationshipChange>>,
    /// Module renames, by table (its schema-qualified name or its SQL name;
    /// `public.` may be left out): the table generates as this module, and
    /// its row struct, typed id, and key struct are named after it. This is
    /// how same-named tables of two schemas both generate. Each new name must
    /// be a snake_case Rust identifier, and every entry must name a table of
    /// the schema. Empty by default.
    ///
    /// ```
    /// let mut config = rok_sqlc::Config::default();
    /// config.modules.insert("audit.users".into(), "audit_users".into());
    /// ```
    pub modules: BTreeMap<String, String>,
}

/// What configuration does to one inferred relationship (the
/// `[relationships]` section of the config file).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RelationshipChange {
    /// Generate the relationship under this name instead: its loader becomes
    /// `load_<name>`. The name must be a snake_case Rust identifier and must
    /// not collide with the table's other relationships.
    Rename(String),
    /// Generate nothing for the relationship, nor for its other side (the
    /// inverse of a belongs-to, or the belongs-to of an inverse).
    OptOut,
}

/// An opt-in derive for generated types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Derive {
    /// `serde::Serialize` and `serde::Deserialize`. Typed ids serialize as
    /// their inner value and enum variants as their database label. The
    /// consuming crate needs `serde` with its `derive` feature, and the
    /// `serde` feature of `uuid`, `chrono`, and `rust_decimal` where the
    /// generated code uses them; the generated `mod.rs` lists them.
    Serde,
    /// `validator::Validate` for create inputs and update patches, with
    /// rules derived from the schema (ADR 0015). Needs [`Derive::Serde`].
    /// The consuming crate needs `validator` with its `derive` feature.
    Validate,
}

impl Derive {
    /// The name the config file, the JSON payload, and `--derive` use.
    pub fn name(self) -> &'static str {
        match self {
            Derive::Serde => "serde",
            Derive::Validate => "validate",
        }
    }
}

/// An HTTP framework to generate a validated-body extractor for
/// ([`Config::extractors`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Extractor {
    /// axum: `http::axum::ValidatedJson<T>`. The consuming crate needs
    /// `axum` and `serde_path_to_error`.
    Axum,
    /// actix-web: `http::actix_web::ValidatedJson<T>`. The consuming crate
    /// needs `actix-web`.
    ActixWeb,
}

impl Extractor {
    /// The name the config file, the JSON payload, and `--extractor` use.
    pub fn name(self) -> &'static str {
        match self {
            Extractor::Axum => "axum",
            Extractor::ActixWeb => "actix_web",
        }
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            schema: Vec::new(),
            queries: Vec::new(),
            output: PathBuf::from("src/db"),
            resource: None,
            dry_run: false,
            check: false,
            force: false,
            lenient: false,
            conventions: Conventions::default(),
            derive: Vec::new(),
            extractors: Vec::new(),
            relationships: BTreeMap::new(),
            modules: BTreeMap::new(),
        }
    }
}

/// Opt-in conventions: columns that mean the same thing in every table that
/// has them (ADR 0010). Each convention applies only to the tables that have
/// the named column, and all are off by default.
///
/// ```
/// let mut conventions = rok_sqlc::Conventions::rok();
/// conventions.soft_delete_column = Some("removed_at".into());
/// let mut config = rok_sqlc::Config::default();
/// config.conventions = conventions;
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Conventions {
    /// The tenant column, which scopes every generated operation on a table
    /// to one tenant given by the caller.
    pub tenant_column: Option<String>,
    /// The PostgreSQL setting the generated session helper sets to the
    /// current tenant, for row-level security policies.
    pub tenant_setting: Option<String>,
    /// The PostgreSQL setting the generated session helper sets to the
    /// acting user, for audit triggers.
    pub user_setting: Option<String>,
    /// The soft-delete column: a nullable timestamp recording when a row was
    /// deleted.
    pub soft_delete_column: Option<String>,
    /// Managed columns, maintained by the database (defaults and triggers):
    /// left out of create inputs and update patches, kept in rows.
    pub managed_columns: Vec<String>,
    /// The version column, for optimistic concurrency on update.
    pub version_column: Option<String>,
    /// How generated code makes new primary key values for tables keyed by
    /// one `UUID` column (or, with [`IdGeneration::Ulid`], one text column).
    /// Such a table's typed id gets a `generate()` constructor, and its
    /// `create` input takes the key as an `Option`: `None` inserts a new id
    /// made in Rust (over any database default). Off by default.
    pub primary_key_ids: Option<IdGeneration>,
}

/// How generated code makes a new primary key value
/// ([`Conventions::primary_key_ids`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IdGeneration {
    /// A random UUID (version 4), for `UUID` keys. Needs the `uuid` crate's
    /// `v4` feature.
    UuidV4,
    /// A time-ordered UUID (version 7), for `UUID` keys. Needs the `uuid`
    /// crate's `v7` feature.
    UuidV7,
    /// A ULID: time-ordered, stored as a UUID in a `UUID` key, or as its
    /// 26-character text in a text key. Needs the `ulid` crate (with its
    /// `uuid` feature for `UUID` keys).
    Ulid,
}

impl IdGeneration {
    /// The name the config file and the JSON payload use.
    pub fn name(self) -> &'static str {
        match self {
            IdGeneration::UuidV4 => "uuid_v4",
            IdGeneration::UuidV7 => "uuid_v7",
            IdGeneration::Ulid => "ulid",
        }
    }
}

impl Conventions {
    /// The preset names [`Conventions::preset`] knows.
    pub const PRESETS: &[&str] = &["rok"];

    /// The Rok ERP's conventions: tenant column `organization_id` (with the
    /// settings `app.organization_id` and `app.user_id`), soft-delete column
    /// `deleted_at`, managed columns `updated_at` and `row_version`, and
    /// version column `row_version`.
    pub fn rok() -> Self {
        Self {
            tenant_column: Some("organization_id".to_owned()),
            tenant_setting: Some("app.organization_id".to_owned()),
            user_setting: Some("app.user_id".to_owned()),
            soft_delete_column: Some("deleted_at".to_owned()),
            managed_columns: vec!["updated_at".to_owned(), "row_version".to_owned()],
            version_column: Some("row_version".to_owned()),
            primary_key_ids: None,
        }
    }

    /// The preset with this name (one of [`Conventions::PRESETS`]), or
    /// `None` for an unknown name.
    pub fn preset(name: &str) -> Option<Self> {
        match name {
            "rok" => Some(Self::rok()),
            _ => None,
        }
    }
}