diff --git a/CHANGELOG.md b/CHANGELOG.md index b529f94..a214a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `urlencode` template filter for percent-encoding strings in URLs. Useful for embedding passwords or other values containing URL-reserved characters (`@`, `%`, `:`, `/`, etc.) in connection strings. - `dprint` formatter for Markdown, JSON, TOML, YAML, and Dockerfile with CI check (`dprint/check@v2.2`) and definition-of-done gate. +- Structured database connection config as an alternative to URL (`host`, `port`, `user`, `password`, `name`, `options` fields). Passwords with URL-reserved characters (`@`, `%`, `:`, etc.) work without encoding. Connections are built using driver-native APIs (PostgreSQL key-value DSN, MySQL `OptsBuilder`), bypassing URL parsing entirely. The `url`/`url_env` fields remain supported for backward compatibility. See [#39](https://github.com/KitStream/initium/issues/39). ### Changed diff --git a/docs/seeding.md b/docs/seeding.md index 3a9e2f1..32ac3bc 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -29,11 +29,54 @@ initium seed --spec /seeds/seed.yaml --json The seed spec file defines the complete seeding plan. Both YAML and JSON formats are supported (file extension determines parser). The spec file is a MiniJinja template rendered with environment variables before parsing. +### Database connection + +Two connection styles are supported. Choose **one** — they cannot be combined. + +**URL-based** (existing behavior): + +```yaml +database: + driver: postgres + url: "postgres://user:pass@host:5432/dbname" + # or: url_env: DATABASE_URL +``` + +**Structured fields** (no URL encoding needed): + +```yaml +database: + driver: postgres + host: pg.example.com + port: 5432 # Optional. Default: 5432 (postgres), 3306 (mysql) + user: netbird + password: "{{ env.DB_PASSWORD }}" # Special chars just work — no URL encoding + name: mydb + options: # Optional. Driver-specific connection parameters + sslmode: disable +``` + +Structured config builds the connection using driver-native APIs, bypassing URL parsing entirely. Passwords with `@`, `%`, `:`, or other URL-reserved characters work without encoding. + +> **Note:** Structured config is not supported for SQLite — use `url` instead. + +### Full schema + ```yaml database: driver: postgres # Required. One of: postgres, mysql, sqlite + # --- URL-based connection (pick one style) --- url: "postgres://..." # Direct database URL url_env: DATABASE_URL # Or: name of env var containing the URL + # --- Structured connection (alternative to url/url_env) --- + host: pg.example.com # Database host + port: 5432 # Optional. Default per driver + user: myuser # Database user + password: "secret" # Database password (special chars OK) + name: mydb # Database name + options: # Optional. Driver-specific parameters + sslmode: disable + # --- Common --- tracking_table: initium_seed # Default: "initium_seed" phases: @@ -68,30 +111,36 @@ phases: ### Field reference -| Field | Type | Required | Description | -| ----------------------------------------------- | -------- | -------- | ---------------------------------------------------------------- | -| `database.driver` | string | Yes | Database driver: `postgres`, `mysql`, or `sqlite` | -| `database.url` | string | No | Direct database connection URL | -| `database.url_env` | string | No | Environment variable containing the database URL | -| `database.tracking_table` | string | No | Name of the seed tracking table (default: `initium_seed`) | -| `phases[].name` | string | Yes | Unique phase name | -| `phases[].order` | integer | No | Execution order (lower first, default: 0) | -| `phases[].database` | string | No | Target database name (for create/switch) | -| `phases[].schema` | string | No | Target schema name (for create/switch) | -| `phases[].create_if_missing` | boolean | No | Create the database/schema if it does not exist (default: false) | -| `phases[].timeout` | string | No | Default wait timeout (e.g. `30s`, `1m`, `1m30s`; default: `30s`) | -| `phases[].wait_for[].type` | string | Yes | Object type: `table`, `view`, `schema`, or `database` | -| `phases[].wait_for[].name` | string | Yes | Object name to wait for | -| `phases[].wait_for[].timeout` | string | No | Per-object timeout override (e.g. `60s`, `2m`, `1m30s`) | -| `phases[].seed_sets[].name` | string | Yes | Unique name for the seed set (used in tracking) | -| `phases[].seed_sets[].order` | integer | No | Execution order (lower values first, default: 0) | -| `phases[].seed_sets[].mode` | string | No | Seed mode: `once` (default) or `reconcile` | -| `phases[].seed_sets[].tables[].table` | string | Yes | Target database table name | -| `phases[].seed_sets[].tables[].order` | integer | No | Execution order within the seed set (default: 0) | -| `phases[].seed_sets[].tables[].unique_key` | string[] | No | Columns for duplicate detection | -| `phases[].seed_sets[].tables[].auto_id.column` | string | No | Auto-generated ID column name | -| `phases[].seed_sets[].tables[].auto_id.id_type` | string | No | ID type (default: `integer`) | -| `phases[].seed_sets[].tables[].rows[]._ref` | string | No | Internal reference name for cross-table references | +| Field | Type | Required | Description | +| ----------------------------------------------- | ----------------- | -------- | ---------------------------------------------------------------------- | +| `database.driver` | string | Yes | Database driver: `postgres`, `mysql`, or `sqlite` | +| `database.url` | string | No | Direct database connection URL (cannot combine with structured fields) | +| `database.url_env` | string | No | Environment variable containing the database URL | +| `database.host` | string | No | Database host (structured config; cannot combine with url/url_env) | +| `database.port` | integer | No | Database port (default: 5432 for postgres, 3306 for mysql) | +| `database.user` | string | No | Database user (structured config) | +| `database.password` | string | No | Database password — special characters work without encoding | +| `database.name` | string | No | Database name (structured config) | +| `database.options` | map[string]string | No | Driver-specific connection parameters (e.g. `sslmode: disable`) | +| `database.tracking_table` | string | No | Name of the seed tracking table (default: `initium_seed`) | +| `phases[].name` | string | Yes | Unique phase name | +| `phases[].order` | integer | No | Execution order (lower first, default: 0) | +| `phases[].database` | string | No | Target database name (for create/switch) | +| `phases[].schema` | string | No | Target schema name (for create/switch) | +| `phases[].create_if_missing` | boolean | No | Create the database/schema if it does not exist (default: false) | +| `phases[].timeout` | string | No | Default wait timeout (e.g. `30s`, `1m`, `1m30s`; default: `30s`) | +| `phases[].wait_for[].type` | string | Yes | Object type: `table`, `view`, `schema`, or `database` | +| `phases[].wait_for[].name` | string | Yes | Object name to wait for | +| `phases[].wait_for[].timeout` | string | No | Per-object timeout override (e.g. `60s`, `2m`, `1m30s`) | +| `phases[].seed_sets[].name` | string | Yes | Unique name for the seed set (used in tracking) | +| `phases[].seed_sets[].order` | integer | No | Execution order (lower values first, default: 0) | +| `phases[].seed_sets[].mode` | string | No | Seed mode: `once` (default) or `reconcile` | +| `phases[].seed_sets[].tables[].table` | string | Yes | Target database table name | +| `phases[].seed_sets[].tables[].order` | integer | No | Execution order within the seed set (default: 0) | +| `phases[].seed_sets[].tables[].unique_key` | string[] | No | Columns for duplicate detection | +| `phases[].seed_sets[].tables[].auto_id.column` | string | No | Auto-generated ID column name | +| `phases[].seed_sets[].tables[].auto_id.id_type` | string | No | ID type (default: `integer`) | +| `phases[].seed_sets[].tables[].rows[]._ref` | string | No | Internal reference name for cross-table references | ### Wait-for object support by driver @@ -115,14 +164,18 @@ phases: SQLite does not support separate databases or schemas — each file is a database. -### Database URL resolution +### Database connection resolution -The database URL is resolved in this order: +If structured fields (`host`, `port`, `user`, `password`, `name`) are provided, the connection is built using driver-native APIs — no URL is needed. + +Otherwise, the database URL is resolved in this order: 1. `database.url_env` — environment variable name containing the URL 2. `database.url` — direct URL in the spec file 3. `DATABASE_URL` — fallback environment variable +Structured fields and URL-based fields (`url`/`url_env`) are mutually exclusive — specifying both is a validation error. + ## Features ### MiniJinja Templating @@ -385,6 +438,7 @@ spec: See the [`examples/seed/`](../examples/seed/) directory: - [`basic-seed.yaml`](../examples/seed/basic-seed.yaml) — PostgreSQL with departments and employees, cross-table references +- [`structured-config-seed.yaml`](../examples/seed/structured-config-seed.yaml) — PostgreSQL with structured connection config (no URL encoding) - [`sqlite-seed.yaml`](../examples/seed/sqlite-seed.yaml) — SQLite configuration table seeding - [`env-credentials-seed.yaml`](../examples/seed/env-credentials-seed.yaml) — MySQL with credentials from Kubernetes secrets - [`phased-seed.yaml`](../examples/seed/phased-seed.yaml) — Multi-phase PostgreSQL seeding with wait-for, create-if-missing, and MiniJinja templating diff --git a/examples/seed/structured-config-seed.yaml b/examples/seed/structured-config-seed.yaml new file mode 100644 index 0000000..c69883e --- /dev/null +++ b/examples/seed/structured-config-seed.yaml @@ -0,0 +1,31 @@ +# Structured database connection config — no URL encoding needed. +# +# Passwords with special characters (@, %, :, etc.) just work. +# Initium builds the connection using driver-native APIs. +# +# Usage: +# export DB_PASSWORD='p@ss:word/with%special&chars' +# initium seed --spec examples/seed/structured-config-seed.yaml + +database: + driver: postgres + host: pg.example.com + port: 5432 + user: app_user + password: "{{ env.DB_PASSWORD }}" + name: myapp + options: + sslmode: disable + +phases: + - name: setup + seed_sets: + - name: config + tables: + - table: config + unique_key: [key] + rows: + - key: app_name + value: MyApp + - key: version + value: "1.0.0" diff --git a/src/seed/db.rs b/src/seed/db.rs index 9059c7d..33cbb36 100644 --- a/src/seed/db.rs +++ b/src/seed/db.rs @@ -1554,31 +1554,133 @@ impl Database for MysqlDb { } } -pub fn connect(driver: &str, url: &str) -> Result, String> { +pub fn connect(config: &crate::seed::schema::DatabaseConfig) -> Result, String> { + let driver = config.driver.as_str(); + + if config.has_structured_config() { + return connect_structured(config); + } + + let url = if !config.url_env.is_empty() { + std::env::var(&config.url_env).map_err(|_| { + format!( + "environment variable '{}' not set for database URL", + config.url_env + ) + })? + } else if !config.url.is_empty() { + config.url.clone() + } else { + std::env::var("DATABASE_URL").map_err(|_| { + "no database URL configured: set database.url, database.url_env, or DATABASE_URL env var, or use structured fields (host, port, user, password, name)".to_string() + })? + }; + match driver { #[cfg(feature = "sqlite")] - "sqlite" => Ok(Box::new(SqliteDb::connect(url)?)), + "sqlite" => Ok(Box::new(SqliteDb::connect(&url)?)), #[cfg(feature = "postgres")] - "postgres" | "postgresql" => Ok(Box::new(PostgresDb::connect(url)?)), + "postgres" | "postgresql" => Ok(Box::new(PostgresDb::connect(&url)?)), #[cfg(feature = "mysql")] - "mysql" => Ok(Box::new(MysqlDb::connect(url)?)), - _ => { - let mut supported = Vec::new(); - #[cfg(feature = "sqlite")] - supported.push("sqlite"); - #[cfg(feature = "postgres")] - supported.push("postgres"); - #[cfg(feature = "mysql")] - supported.push("mysql"); - Err(format!( - "unsupported database driver: '{}' (supported: {})", - driver, - supported.join(", ") - )) + "mysql" => Ok(Box::new(MysqlDb::connect(&url)?)), + _ => Err(unsupported_driver_error(driver)), + } +} + +fn connect_structured( + config: &crate::seed::schema::DatabaseConfig, +) -> Result, String> { + let driver = config.driver.as_str(); + match driver { + #[cfg(feature = "sqlite")] + "sqlite" => { + Err("structured database config is not supported for sqlite; use url instead".into()) + } + #[cfg(feature = "postgres")] + "postgres" | "postgresql" => { + let dsn = build_postgres_dsn(config); + Ok(Box::new(PostgresDb::connect(&dsn)?)) + } + #[cfg(feature = "mysql")] + "mysql" => { + if !config.options.is_empty() { + return Err(format!( + "structured database config does not support 'options' for mysql (unsupported keys: {})", + config.options.keys().cloned().collect::>().join(", ") + )); + } + let port = config.port.unwrap_or(3306); + let mut opts = mysql::OptsBuilder::default() + .ip_or_hostname(Some(&config.host)) + .tcp_port(port); + if !config.user.is_empty() { + opts = opts.user(Some(&config.user)); + } + if !config.password.is_empty() { + opts = opts.pass(Some(&config.password)); + } + if !config.name.is_empty() { + opts = opts.db_name(Some(&config.name)); + } + let pool = mysql::Pool::new(opts).map_err(|e| format!("connecting to mysql: {}", e))?; + let conn = pool + .get_conn() + .map_err(|e| format!("getting mysql connection: {}", e))?; + Ok(Box::new(MysqlDb { + conn, + in_transaction: false, + })) } + _ => Err(unsupported_driver_error(driver)), } } +#[cfg(feature = "postgres")] +fn build_postgres_dsn(config: &crate::seed::schema::DatabaseConfig) -> String { + let mut parts = Vec::new(); + parts.push(format!("host='{}'", escape_dsn_value(&config.host))); + parts.push(format!("port='{}'", config.port.unwrap_or(5432))); + if !config.user.is_empty() { + parts.push(format!("user='{}'", escape_dsn_value(&config.user))); + } + if !config.password.is_empty() { + parts.push(format!("password='{}'", escape_dsn_value(&config.password))); + } + if !config.name.is_empty() { + parts.push(format!("dbname='{}'", escape_dsn_value(&config.name))); + } + let mut keys: Vec<&String> = config.options.keys().collect(); + keys.sort(); + for key in keys { + let value = &config.options[key]; + parts.push(format!( + "{}='{}'", + escape_dsn_value(key), + escape_dsn_value(value) + )); + } + parts.join(" ") +} + +fn escape_dsn_value(val: &str) -> String { + val.replace('\\', "\\\\").replace('\'', "\\'") +} + +fn unsupported_driver_error(driver: &str) -> String { + let mut supported = Vec::new(); + #[cfg(feature = "sqlite")] + supported.push("sqlite"); + #[cfg(feature = "postgres")] + supported.push("postgres"); + #[cfg(feature = "mysql")] + supported.push("mysql"); + format!( + "unsupported database driver: '{}' (supported: {})", + driver, + supported.join(", ") + ) +} + fn sanitize_identifier(name: &str) -> String { name.chars() .filter(|c| c.is_alphanumeric() || *c == '_') @@ -1685,16 +1787,148 @@ mod tests { #[test] fn test_connect_unsupported_driver() { - let result = connect("oracle", "localhost"); + let config = crate::seed::schema::DatabaseConfig { + driver: "oracle".into(), + url: "localhost".into(), + ..Default::default() + }; + let result = connect(&config); assert!(result.is_err()); } #[test] fn test_connect_sqlite() { - let db = connect("sqlite", ":memory:"); + let config = crate::seed::schema::DatabaseConfig { + driver: "sqlite".into(), + url: ":memory:".into(), + ..Default::default() + }; + let db = connect(&config); assert!(db.is_ok()); } + #[test] + fn test_connect_structured_sqlite_rejected() { + let config = crate::seed::schema::DatabaseConfig { + driver: "sqlite".into(), + host: "localhost".into(), + ..Default::default() + }; + let result = connect(&config); + let err = result.err().expect("expected error"); + assert!(err.contains("not supported for sqlite")); + } + + #[cfg(feature = "mysql")] + #[test] + fn test_connect_structured_mysql_rejects_options() { + use std::collections::HashMap; + let config = crate::seed::schema::DatabaseConfig { + driver: "mysql".into(), + host: "localhost".into(), + user: "root".into(), + name: "test".into(), + options: { + let mut m = HashMap::new(); + m.insert("charset".into(), "utf8mb4".into()); + m + }, + ..Default::default() + }; + let err = connect(&config).err().expect("expected error"); + assert!(err.contains("does not support 'options' for mysql")); + assert!(err.contains("charset")); + } + + #[test] + fn test_escape_dsn_value() { + assert_eq!(escape_dsn_value("simple"), "simple"); + assert_eq!(escape_dsn_value("it's"), "it\\'s"); + assert_eq!(escape_dsn_value("back\\slash"), "back\\\\slash"); + assert_eq!(escape_dsn_value("p@ss:word"), "p@ss:word"); + } + + #[cfg(feature = "postgres")] + #[test] + fn test_build_postgres_dsn() { + use std::collections::HashMap; + let config = crate::seed::schema::DatabaseConfig { + driver: "postgres".into(), + host: "pg.example.com".into(), + port: Some(5432), + user: "admin".into(), + password: "s3cr't".into(), + name: "mydb".into(), + options: { + let mut m = HashMap::new(); + m.insert("sslmode".into(), "disable".into()); + m + }, + ..Default::default() + }; + let dsn = build_postgres_dsn(&config); + assert!(dsn.contains("host='pg.example.com'")); + assert!(dsn.contains("port='5432'")); + assert!(dsn.contains("user='admin'")); + assert!(dsn.contains("password='s3cr\\'t'")); + assert!(dsn.contains("dbname='mydb'")); + assert!(dsn.contains("sslmode='disable'")); + } + + #[cfg(feature = "postgres")] + #[test] + fn test_build_postgres_dsn_default_port() { + let config = crate::seed::schema::DatabaseConfig { + driver: "postgres".into(), + host: "localhost".into(), + user: "app".into(), + name: "db".into(), + ..Default::default() + }; + let dsn = build_postgres_dsn(&config); + assert!(dsn.contains("port='5432'")); + assert!(!dsn.contains("password=")); + } + + #[test] + fn test_connect_url_from_env() { + std::env::set_var("TEST_CONNECT_DB_URL_39", "sqlite::memory:"); + let config = crate::seed::schema::DatabaseConfig { + driver: "sqlite".into(), + url_env: "TEST_CONNECT_DB_URL_39".into(), + ..Default::default() + }; + // Should resolve from env var - but :memory: with sqlite: prefix won't work, + // so we just check the env resolution part by testing with a valid sqlite URL + std::env::set_var("TEST_CONNECT_DB_URL_39", ":memory:"); + let result = connect(&config); + assert!(result.is_ok()); + std::env::remove_var("TEST_CONNECT_DB_URL_39"); + } + + #[test] + fn test_connect_missing_url_env() { + std::env::remove_var("TEST_MISSING_DB_URL_39"); + let config = crate::seed::schema::DatabaseConfig { + driver: "sqlite".into(), + url_env: "TEST_MISSING_DB_URL_39".into(), + ..Default::default() + }; + let err = connect(&config).err().expect("expected error"); + assert!(err.contains("TEST_MISSING_DB_URL_39")); + } + + #[test] + fn test_connect_no_url_no_env_no_structured() { + std::env::remove_var("DATABASE_URL"); + let config = crate::seed::schema::DatabaseConfig { + driver: "sqlite".into(), + ..Default::default() + }; + let err = connect(&config).err().expect("expected error"); + assert!(err.contains("no database URL configured")); + } + #[test] fn test_mark_seed_idempotent() { let mut db = SqliteDb::connect(":memory:").unwrap(); diff --git a/src/seed/mod.rs b/src/seed/mod.rs index b8643d4..99b4d5f 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -38,13 +38,12 @@ pub fn run( schema::SeedPlan::from_yaml(&rendered)? }; - let db_url = plan.resolve_db_url()?; let tracking_table = plan.database.tracking_table.clone(); let driver = plan.database.driver.clone(); log.info("connecting to database", &[("driver", driver.as_str())]); - let db = db::connect(&driver, &db_url)?; + let db = db::connect(&plan.database)?; let mut exec = executor::SeedExecutor::new(log, db, tracking_table, reset) .with_dry_run(dry_run) .with_reconcile_all(reconcile_all); diff --git a/src/seed/schema.rs b/src/seed/schema.rs index d56031a..c0bcd4c 100644 --- a/src/seed/schema.rs +++ b/src/seed/schema.rs @@ -81,10 +81,41 @@ pub struct DatabaseConfig { pub url_env: String, #[serde(default)] pub url: String, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub user: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub options: HashMap, #[serde(default = "default_tracking_table")] pub tracking_table: String, } +impl DatabaseConfig { + pub fn has_structured_config(&self) -> bool { + !self.host.is_empty() + } + + fn has_url_config(&self) -> bool { + !self.url.is_empty() || !self.url_env.is_empty() + } + + pub fn validate(&self) -> Result<(), String> { + if self.has_structured_config() && self.has_url_config() { + return Err( + "database config must use either structured fields (host, port, user, password, name) or url/url_env, not both".into(), + ); + } + Ok(()) + } +} + fn default_driver() -> String { "postgres".into() } @@ -191,6 +222,7 @@ impl SeedPlan { } pub fn validate(&self) -> Result<(), String> { + self.database.validate()?; if self.phases.is_empty() { return Err("seed plan must contain at least one phase".into()); } @@ -304,22 +336,6 @@ impl SeedPlan { } Ok(()) } - - pub fn resolve_db_url(&self) -> Result { - if !self.database.url_env.is_empty() { - std::env::var(&self.database.url_env).map_err(|_| { - format!( - "environment variable '{}' not set for database URL", - self.database.url_env - ) - }) - } else if !self.database.url.is_empty() { - Ok(self.database.url.clone()) - } else { - std::env::var("DATABASE_URL") - .map_err(|_| "no database URL configured: set database.url, database.url_env, or DATABASE_URL env var".into()) - } - } } #[cfg(test)] @@ -425,7 +441,7 @@ phases: } #[test] - fn test_resolve_url_from_config() { + fn test_url_config() { let yaml = r#" database: driver: sqlite @@ -439,12 +455,12 @@ phases: rows: [] "#; let plan = SeedPlan::from_yaml(yaml).unwrap(); - assert_eq!(plan.resolve_db_url().unwrap(), "test.db"); + assert_eq!(plan.database.url, "test.db"); + assert!(!plan.database.has_structured_config()); } #[test] - fn test_resolve_url_from_env() { - std::env::set_var("TEST_SEED_DB_URL", "postgres://localhost/test"); + fn test_url_env_config() { let yaml = r#" database: driver: postgres @@ -458,8 +474,97 @@ phases: rows: [] "#; let plan = SeedPlan::from_yaml(yaml).unwrap(); - assert_eq!(plan.resolve_db_url().unwrap(), "postgres://localhost/test"); - std::env::remove_var("TEST_SEED_DB_URL"); + assert_eq!(plan.database.url_env, "TEST_SEED_DB_URL"); + assert!(!plan.database.has_structured_config()); + } + + #[test] + fn test_structured_config() { + let yaml = r#" +database: + driver: postgres + host: pg.example.com + port: 5432 + user: netbird + password: "s3cret!" + name: mydb + options: + sslmode: disable +phases: + - name: phase1 + seed_sets: + - name: x + tables: + - table: t + rows: [] +"#; + let plan = SeedPlan::from_yaml(yaml).unwrap(); + assert!(plan.database.has_structured_config()); + assert_eq!(plan.database.host, "pg.example.com"); + assert_eq!(plan.database.port, Some(5432)); + assert_eq!(plan.database.user, "netbird"); + assert_eq!(plan.database.password, "s3cret!"); + assert_eq!(plan.database.name, "mydb"); + assert_eq!(plan.database.options.get("sslmode").unwrap(), "disable"); + } + + #[test] + fn test_structured_config_default_port() { + let yaml = r#" +database: + driver: postgres + host: localhost + user: app + name: mydb +phases: + - name: phase1 + seed_sets: + - name: x + tables: + - table: t + rows: [] +"#; + let plan = SeedPlan::from_yaml(yaml).unwrap(); + assert!(plan.database.has_structured_config()); + assert_eq!(plan.database.port, None); + } + + #[test] + fn test_rejects_url_and_structured_config() { + let yaml = r#" +database: + driver: postgres + url: "postgres://localhost/db" + host: localhost +phases: + - name: phase1 + seed_sets: + - name: x + tables: + - table: t + rows: [] +"#; + let err = SeedPlan::from_yaml(yaml).unwrap_err(); + assert!(err.contains("not both")); + } + + #[test] + fn test_rejects_url_env_and_structured_config() { + let yaml = r#" +database: + driver: postgres + url_env: DATABASE_URL + host: localhost +phases: + - name: phase1 + seed_sets: + - name: x + tables: + - table: t + rows: [] +"#; + let err = SeedPlan::from_yaml(yaml).unwrap_err(); + assert!(err.contains("not both")); } #[test] diff --git a/tests/input/seed-mysql-structured.yaml b/tests/input/seed-mysql-structured.yaml new file mode 100644 index 0000000..bd889d9 --- /dev/null +++ b/tests/input/seed-mysql-structured.yaml @@ -0,0 +1,41 @@ +database: + driver: mysql + host: localhost + port: 13306 + user: initium + password: initium + name: initium_test + tracking_table: initium_seed + +phases: + - name: setup + order: 1 + seed_sets: + - name: products_structured + order: 1 + tables: + - table: products + unique_key: [sku] + auto_id: + column: id + rows: + - _ref: prod_widget + sku: WIDGET-001 + name: Widget + price: "9.99" + - _ref: prod_gadget + sku: GADGET-001 + name: Gadget + price: "19.99" + + - name: orders_structured + order: 2 + tables: + - table: orders + auto_id: + column: id + rows: + - product_id: "@ref:prod_widget.id" + quantity: "2" + - product_id: "@ref:prod_gadget.id" + quantity: "1" diff --git a/tests/input/seed-postgres-structured.yaml b/tests/input/seed-postgres-structured.yaml new file mode 100644 index 0000000..ee13c55 --- /dev/null +++ b/tests/input/seed-postgres-structured.yaml @@ -0,0 +1,40 @@ +database: + driver: postgres + host: localhost + port: 15432 + user: initium + password: initium + name: initium_test + tracking_table: initium_seed + +phases: + - name: setup + order: 1 + seed_sets: + - name: departments_structured + order: 1 + tables: + - table: departments + unique_key: [name] + auto_id: + column: id + rows: + - _ref: dept_eng + name: Engineering + - _ref: dept_sales + name: Sales + + - name: employees_structured + order: 2 + tables: + - table: employees + unique_key: [email] + auto_id: + column: id + rows: + - name: Alice + email: alice@example.com + department_id: "@ref:dept_eng.id" + - name: Bob + email: bob@example.com + department_id: "@ref:dept_sales.id" diff --git a/tests/integration_test.rs b/tests/integration_test.rs index dc5334d..005630c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -545,6 +545,134 @@ fn test_seed_mysql() { ); } +// --------------------------------------------------------------------------- +// seed: PostgreSQL — structured config (no URL, discrete fields) +// --------------------------------------------------------------------------- +#[cfg(feature = "postgres")] +#[test] +fn test_seed_postgres_structured_config() { + if !integration_enabled() { + return; + } + + let mut client = pg_client(); + client + .batch_execute( + "DROP TABLE IF EXISTS employees; + DROP TABLE IF EXISTS departments; + DROP TABLE IF EXISTS initium_seed; + CREATE TABLE departments (id SERIAL PRIMARY KEY, name TEXT UNIQUE); + CREATE TABLE employees (id SERIAL PRIMARY KEY, name TEXT, email TEXT UNIQUE, department_id INTEGER REFERENCES departments(id));", + ) + .expect("failed to create postgres tables"); + + let spec = format!("{}/seed-postgres-structured.yaml", input_dir()); + let out = Command::new(initium_bin()) + .args(["seed", "--spec", &spec]) + .output() + .expect("failed to run seed"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "seed postgres structured config should succeed: {}", + stderr + ); + assert!( + stderr.contains("seed execution completed"), + "expected completion log: {}", + stderr + ); + + let dept_count: i64 = client + .query_one("SELECT COUNT(*) FROM departments", &[]) + .unwrap() + .get(0); + assert_eq!(dept_count, 2, "expected 2 departments"); + + let emp_count: i64 = client + .query_one("SELECT COUNT(*) FROM employees", &[]) + .unwrap() + .get(0); + assert_eq!(emp_count, 2, "expected 2 employees"); + + // Verify cross-table references work with structured config + let rows = client + .query( + "SELECT e.name, d.name FROM employees e JOIN departments d ON e.department_id = d.id ORDER BY e.name", + &[], + ) + .unwrap(); + assert_eq!(rows.len(), 2); + let alice_dept: &str = rows[0].get(1); + let bob_dept: &str = rows[1].get(1); + assert_eq!(alice_dept, "Engineering"); + assert_eq!(bob_dept, "Sales"); +} + +// --------------------------------------------------------------------------- +// seed: MySQL — structured config (no URL, discrete fields) +// --------------------------------------------------------------------------- +#[cfg(feature = "mysql")] +#[test] +fn test_seed_mysql_structured_config() { + if !integration_enabled() { + return; + } + use mysql::prelude::Queryable; + + let mut conn = mysql_conn(); + conn.query_drop("DROP TABLE IF EXISTS orders").unwrap(); + conn.query_drop("DROP TABLE IF EXISTS products").unwrap(); + conn.query_drop("DROP TABLE IF EXISTS initium_seed") + .unwrap(); + conn.query_drop( + "CREATE TABLE products (id INT AUTO_INCREMENT PRIMARY KEY, sku VARCHAR(255) UNIQUE, name VARCHAR(255), price VARCHAR(50))", + ) + .unwrap(); + conn.query_drop( + "CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, product_id INT, quantity VARCHAR(50), FOREIGN KEY (product_id) REFERENCES products(id))", + ) + .unwrap(); + + let spec = format!("{}/seed-mysql-structured.yaml", input_dir()); + let out = Command::new(initium_bin()) + .args(["seed", "--spec", &spec]) + .output() + .expect("failed to run seed"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "seed mysql structured config should succeed: {}", + stderr + ); + assert!( + stderr.contains("seed execution completed"), + "expected completion log: {}", + stderr + ); + + let prod_count: Option = conn + .exec_first("SELECT COUNT(*) FROM products", ()) + .unwrap(); + assert_eq!(prod_count, Some(2), "expected 2 products"); + + let order_count: Option = conn.exec_first("SELECT COUNT(*) FROM orders", ()).unwrap(); + assert_eq!(order_count, Some(2), "expected 2 orders"); + + // Verify cross-table references work with structured config + let rows: Vec<(String, String)> = conn + .exec( + "SELECT p.name, o.quantity FROM orders o JOIN products p ON o.product_id = p.id ORDER BY p.name", + (), + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].0, "Gadget"); + assert_eq!(rows[0].1, "1"); + assert_eq!(rows[1].0, "Widget"); + assert_eq!(rows[1].1, "2"); +} + // --------------------------------------------------------------------------- // seed: PostgreSQL — create database via seed phase // ---------------------------------------------------------------------------