Skip to content

Commit 5676cdf

Browse files
fix: structured config with create_if_missing for non-existent database (#50)
When using structured database config, the `name` field was included in the initial connection string. If the target database did not exist yet (and a phase had `create_if_missing: true`), the connection failed before the create phase could run. Now initium detects this scenario and first connects to a bootstrap database (`postgres` by default for PostgreSQL, no database for MySQL), creates the missing database, then reconnects to the target for full execution. Adds `database.default_database` config field to override the bootstrap database for environments where the default is not accessible. Also adds integration tests for structured config edge cases: special-character passwords, PostgreSQL options field, custom default_database, and the create_if_missing regression. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 06794c4 commit 5676cdf

5 files changed

Lines changed: 793 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `database.default_database` field for structured config: specifies which database to connect to during `create_if_missing` bootstrap. Defaults to `postgres` for PostgreSQL; MySQL connects without selecting a database. Useful when the user does not have access to the default `postgres` database.
13+
14+
### Fixed
15+
16+
- Structured database config now works with `create_if_missing: true` when the target database does not yet exist. Previously, the initial connection included the non-existent database name, causing an immediate connection error. Now initium connects to a bootstrap database first, creates the target, then reconnects. Fixes [#50](https://github.com/KitStream/initium/issues/50).
17+
18+
### Chores
19+
20+
- Added integration tests for structured database connectivity: special-character passwords (URL-reserved chars like `@`, `:`, `/`, `?`, `#`, `%`), PostgreSQL `options` field (`connect_timeout`), and `create_if_missing` with non-existent database ([#50](https://github.com/KitStream/initium/issues/50)).
21+
1022
## [2.0.1] - 2026-03-14
1123

1224
### Fixed

docs/seeding.md

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ database:
7676
name: mydb # Database name
7777
options: # Optional. Driver-specific parameters
7878
sslmode: disable
79+
default_database: postgres # Optional. Bootstrap database for create_if_missing
7980
# --- Common ---
8081
tracking_table: initium_seed # Default: "initium_seed"
8182
@@ -111,36 +112,37 @@ phases:
111112

112113
### Field reference
113114

114-
| Field | Type | Required | Description |
115-
| ----------------------------------------------- | ----------------- | -------- | ---------------------------------------------------------------------- |
116-
| `database.driver` | string | Yes | Database driver: `postgres`, `mysql`, or `sqlite` |
117-
| `database.url` | string | No | Direct database connection URL (cannot combine with structured fields) |
118-
| `database.url_env` | string | No | Environment variable containing the database URL |
119-
| `database.host` | string | No | Database host (structured config; cannot combine with url/url_env) |
120-
| `database.port` | integer | No | Database port (default: 5432 for postgres, 3306 for mysql) |
121-
| `database.user` | string | No | Database user (structured config) |
122-
| `database.password` | string | No | Database password — special characters work without encoding |
123-
| `database.name` | string | No | Database name (structured config) |
124-
| `database.options` | map[string]string | No | Driver-specific connection parameters (e.g. `sslmode: disable`) |
125-
| `database.tracking_table` | string | No | Name of the seed tracking table (default: `initium_seed`) |
126-
| `phases[].name` | string | Yes | Unique phase name |
127-
| `phases[].order` | integer | No | Execution order (lower first, default: 0) |
128-
| `phases[].database` | string | No | Target database name (for create/switch) |
129-
| `phases[].schema` | string | No | Target schema name (for create/switch) |
130-
| `phases[].create_if_missing` | boolean | No | Create the database/schema if it does not exist (default: false) |
131-
| `phases[].timeout` | string | No | Default wait timeout (e.g. `30s`, `1m`, `1m30s`; default: `30s`) |
132-
| `phases[].wait_for[].type` | string | Yes | Object type: `table`, `view`, `schema`, or `database` |
133-
| `phases[].wait_for[].name` | string | Yes | Object name to wait for |
134-
| `phases[].wait_for[].timeout` | string | No | Per-object timeout override (e.g. `60s`, `2m`, `1m30s`) |
135-
| `phases[].seed_sets[].name` | string | Yes | Unique name for the seed set (used in tracking) |
136-
| `phases[].seed_sets[].order` | integer | No | Execution order (lower values first, default: 0) |
137-
| `phases[].seed_sets[].mode` | string | No | Seed mode: `once` (default) or `reconcile` |
138-
| `phases[].seed_sets[].tables[].table` | string | Yes | Target database table name |
139-
| `phases[].seed_sets[].tables[].order` | integer | No | Execution order within the seed set (default: 0) |
140-
| `phases[].seed_sets[].tables[].unique_key` | string[] | No | Columns for duplicate detection |
141-
| `phases[].seed_sets[].tables[].auto_id.column` | string | No | Auto-generated ID column name |
142-
| `phases[].seed_sets[].tables[].auto_id.id_type` | string | No | ID type (default: `integer`) |
143-
| `phases[].seed_sets[].tables[].rows[]._ref` | string | No | Internal reference name for cross-table references |
115+
| Field | Type | Required | Description |
116+
| ----------------------------------------------- | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
117+
| `database.driver` | string | Yes | Database driver: `postgres`, `mysql`, or `sqlite` |
118+
| `database.url` | string | No | Direct database connection URL (cannot combine with structured fields) |
119+
| `database.url_env` | string | No | Environment variable containing the database URL |
120+
| `database.host` | string | No | Database host (structured config; cannot combine with url/url_env) |
121+
| `database.port` | integer | No | Database port (default: 5432 for postgres, 3306 for mysql) |
122+
| `database.user` | string | No | Database user (structured config) |
123+
| `database.password` | string | No | Database password — special characters work without encoding |
124+
| `database.name` | string | No | Database name (structured config) |
125+
| `database.options` | map[string]string | No | Driver-specific connection parameters (e.g. `sslmode: disable`) |
126+
| `database.default_database` | string | No | Database to connect to during `create_if_missing` bootstrap. Default: `postgres` for PostgreSQL, none for MySQL. |
127+
| `database.tracking_table` | string | No | Name of the seed tracking table (default: `initium_seed`) |
128+
| `phases[].name` | string | Yes | Unique phase name |
129+
| `phases[].order` | integer | No | Execution order (lower first, default: 0) |
130+
| `phases[].database` | string | No | Target database name (for create/switch) |
131+
| `phases[].schema` | string | No | Target schema name (for create/switch) |
132+
| `phases[].create_if_missing` | boolean | No | Create the database/schema if it does not exist (default: false) |
133+
| `phases[].timeout` | string | No | Default wait timeout (e.g. `30s`, `1m`, `1m30s`; default: `30s`) |
134+
| `phases[].wait_for[].type` | string | Yes | Object type: `table`, `view`, `schema`, or `database` |
135+
| `phases[].wait_for[].name` | string | Yes | Object name to wait for |
136+
| `phases[].wait_for[].timeout` | string | No | Per-object timeout override (e.g. `60s`, `2m`, `1m30s`) |
137+
| `phases[].seed_sets[].name` | string | Yes | Unique name for the seed set (used in tracking) |
138+
| `phases[].seed_sets[].order` | integer | No | Execution order (lower values first, default: 0) |
139+
| `phases[].seed_sets[].mode` | string | No | Seed mode: `once` (default) or `reconcile` |
140+
| `phases[].seed_sets[].tables[].table` | string | Yes | Target database table name |
141+
| `phases[].seed_sets[].tables[].order` | integer | No | Execution order within the seed set (default: 0) |
142+
| `phases[].seed_sets[].tables[].unique_key` | string[] | No | Columns for duplicate detection |
143+
| `phases[].seed_sets[].tables[].auto_id.column` | string | No | Auto-generated ID column name |
144+
| `phases[].seed_sets[].tables[].auto_id.id_type` | string | No | ID type (default: `integer`) |
145+
| `phases[].seed_sets[].tables[].rows[]._ref` | string | No | Internal reference name for cross-table references |
144146

145147
### Wait-for object support by driver
146148

src/seed/mod.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ pub mod schema;
55

66
use crate::logging::Logger;
77

8+
fn bootstrap_database(config: &schema::DatabaseConfig) -> String {
9+
if !config.default_database.is_empty() {
10+
return config.default_database.clone();
11+
}
12+
match config.driver.as_str() {
13+
// PostgreSQL requires connecting to an existing database; `postgres` is
14+
// guaranteed to exist on every cluster.
15+
"postgres" | "postgresql" => "postgres".into(),
16+
// MySQL can connect without selecting a database, which avoids needing
17+
// access to the `mysql` system schema.
18+
_ => String::new(),
19+
}
20+
}
21+
822
fn render_template(content: &str) -> Result<String, String> {
923
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
1024
let mut jinja_env = minijinja::Environment::new();
@@ -41,9 +55,48 @@ pub fn run(
4155
let tracking_table = plan.database.tracking_table.clone();
4256
let driver = plan.database.driver.clone();
4357

58+
// When using structured config and a phase needs to create a database that
59+
// matches the configured name, we try the normal connection first. If it
60+
// fails, we fall back to connecting to a bootstrap database, create the
61+
// target, then reconnect. See https://github.com/KitStream/initium/issues/50
62+
let may_need_bootstrap = plan.database.has_structured_config()
63+
&& plan.phases.iter().any(|p| {
64+
p.create_if_missing && !p.database.is_empty() && p.database == plan.database.name
65+
});
66+
4467
log.info("connecting to database", &[("driver", driver.as_str())]);
4568

46-
let db = db::connect(&plan.database)?;
69+
let db = match db::connect(&plan.database) {
70+
Ok(db) => db,
71+
Err(err) if may_need_bootstrap => {
72+
log.info(
73+
"target database not reachable, bootstrapping via default database",
74+
&[("driver", driver.as_str())],
75+
);
76+
77+
let mut admin_config = plan.database.clone();
78+
admin_config.name = bootstrap_database(&plan.database);
79+
80+
let mut admin_db = db::connect(&admin_config)?;
81+
82+
for phase in &plan.phases {
83+
if phase.create_if_missing && !phase.database.is_empty() {
84+
log.info(
85+
"creating database if missing",
86+
&[("database", phase.database.as_str())],
87+
);
88+
admin_db.create_database(&phase.database)?;
89+
}
90+
// Schemas are database-scoped, so they must be created after
91+
// reconnecting to the target database. The executor handles
92+
// schema creation in execute_phase().
93+
}
94+
drop(admin_db);
95+
96+
db::connect(&plan.database).map_err(|_| err)?
97+
}
98+
Err(err) => return Err(err),
99+
};
47100
let mut exec = executor::SeedExecutor::new(log, db, tracking_table, reset)
48101
.with_dry_run(dry_run)
49102
.with_reconcile_all(reconcile_all);

src/seed/schema.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ pub struct DatabaseConfig {
9292
#[serde(default)]
9393
pub name: String,
9494
#[serde(default)]
95+
pub default_database: String,
96+
#[serde(default)]
9597
pub options: HashMap<String, String>,
9698
#[serde(default = "default_tracking_table")]
9799
pub tracking_table: String,
@@ -529,6 +531,47 @@ phases:
529531
assert_eq!(plan.database.port, None);
530532
}
531533

534+
#[test]
535+
fn test_structured_config_default_database() {
536+
let yaml = r#"
537+
database:
538+
driver: postgres
539+
host: pg.example.com
540+
user: app
541+
name: mydb
542+
default_database: maintenance_db
543+
phases:
544+
- name: phase1
545+
seed_sets:
546+
- name: x
547+
tables:
548+
- table: t
549+
rows: []
550+
"#;
551+
let plan = SeedPlan::from_yaml(yaml).unwrap();
552+
assert_eq!(plan.database.default_database, "maintenance_db");
553+
}
554+
555+
#[test]
556+
fn test_structured_config_default_database_empty_by_default() {
557+
let yaml = r#"
558+
database:
559+
driver: postgres
560+
host: localhost
561+
user: app
562+
name: mydb
563+
phases:
564+
- name: phase1
565+
seed_sets:
566+
- name: x
567+
tables:
568+
- table: t
569+
rows: []
570+
"#;
571+
let plan = SeedPlan::from_yaml(yaml).unwrap();
572+
assert!(plan.database.default_database.is_empty());
573+
}
574+
532575
#[test]
533576
fn test_rejects_url_and_structured_config() {
534577
let yaml = r#"

0 commit comments

Comments
 (0)