Skip to content

Commit de1e4a3

Browse files
feat(codegen): split sidecar DDL by dialect; reject json sidecar (#45) (#113)
generate_sidecar_schema now takes a SqlDialect and dispatches to new overlay::sqlite / overlay::postgres modules. The portable table bodies are shared via assemble(); the only dialect-divergent fragment — the metadata upsert — is per-module: SQLite INSERT OR IGNORE vs PostgreSQL INSERT … ON CONFLICT DO NOTHING. The SQLite-only datetime('now') is replaced by portable CURRENT_TIMESTAMP. SqlDialect::from_storage maps [sidecar].storage: sqlite→Sqlite, postgres/postgresql→Postgres, and rejects "json" (previously it silently emitted SQLite DDL for a JSON store) with a pointer to the split-out tracking issue #112. main.rs derives the dialect from the manifest; SidecarConfig docs updated. 6 existing overlay tests retained (now dialect-explicit) + 5 new: sqlite seed/timestamp, postgres ON CONFLICT, shared bodies, empty schema, storage→dialect mapping. Suite: 112 lib + 9 integration green. Closes #45. Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1a73d3a commit de1e4a3

4 files changed

Lines changed: 263 additions & 46 deletions

File tree

src/codegen/overlay.rs

Lines changed: 249 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,62 @@ fn must_validate_identifier(name: &str) -> &str {
6363
}
6464
}
6565

66+
// ---------------------------------------------------------------------------
67+
// SQL dialect (V-L2-F1, #45)
68+
// ---------------------------------------------------------------------------
69+
70+
/// The SQL dialect the sidecar DDL is emitted for. Selected from the
71+
/// manifest's `[sidecar].storage`. The table bodies are written in the
72+
/// portable subset both engines accept (`CREATE TABLE IF NOT EXISTS`,
73+
/// `CHECK`, partial unique indexes, `CURRENT_TIMESTAMP`); the only
74+
/// genuinely dialect-divergent fragment is the metadata upsert
75+
/// (`INSERT OR IGNORE` vs `INSERT … ON CONFLICT DO NOTHING`), which lives
76+
/// in the [`sqlite`] / [`postgres`] modules.
77+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
78+
pub enum SqlDialect {
79+
Sqlite,
80+
Postgres,
81+
}
82+
83+
impl SqlDialect {
84+
/// Map a `[sidecar].storage` value to a dialect. `sqlite` →
85+
/// [`SqlDialect::Sqlite`]; `postgres`/`postgresql` →
86+
/// [`SqlDialect::Postgres`]. `json` and unknown values are rejected
87+
/// (the previous behaviour silently emitted SQLite DDL regardless,
88+
/// V-L2-F1). The JSON store is tracked separately by #112.
89+
pub fn from_storage(storage: &str) -> anyhow::Result<Self> {
90+
match storage.to_lowercase().as_str() {
91+
"sqlite" => Ok(SqlDialect::Sqlite),
92+
"postgres" | "postgresql" => Ok(SqlDialect::Postgres),
93+
"json" => anyhow::bail!(
94+
"[sidecar].storage = \"json\" is not implemented (it previously \
95+
emitted SQLite DDL silently). Use \"sqlite\". The JSON sidecar \
96+
store is tracked by hyperpolymath/verisimiser#112."
97+
),
98+
other => anyhow::bail!(
99+
"unknown [sidecar].storage {other:?}; supported: \"sqlite\" \
100+
(\"postgres\" for a PostgreSQL sidecar; \"json\" is #112)."
101+
),
102+
}
103+
}
104+
}
105+
66106
// ---------------------------------------------------------------------------
67107
// Overlay generation
68108
// ---------------------------------------------------------------------------
69109

70-
/// Generate the complete sidecar schema DDL for all enabled octad dimensions.
110+
/// Generate the complete sidecar schema DDL for all enabled octad
111+
/// dimensions, in the requested SQL `dialect`.
71112
///
72-
/// The generated SQL is SQLite-compatible (the default sidecar backend).
73113
/// Each table is created with `IF NOT EXISTS` so the schema can be applied
74-
/// idempotently.
114+
/// idempotently. Dispatches to [`sqlite::generate`] / [`postgres::generate`].
75115
///
76116
/// # Arguments
77117
/// * `schema` - The parsed schema of the target database (used to reference
78118
/// table names in provenance and temporal tracking).
79119
/// * `octad` - The octad configuration from the manifest, controlling which
80120
/// dimension tables to generate.
121+
/// * `dialect` - The sidecar SQL dialect (see [`SqlDialect::from_storage`]).
81122
///
82123
/// # Returns
83124
/// `Ok(String)` with the complete DDL on success. `Err` if any table or
@@ -87,6 +128,21 @@ fn must_validate_identifier(name: &str) -> &str {
87128
pub fn generate_sidecar_schema(
88129
schema: &ParsedSchema,
89130
octad: &OctadConfig,
131+
dialect: SqlDialect,
132+
) -> anyhow::Result<String> {
133+
match dialect {
134+
SqlDialect::Sqlite => sqlite::generate(schema, octad),
135+
SqlDialect::Postgres => postgres::generate(schema, octad),
136+
}
137+
}
138+
139+
/// Shared schema body: validate identifiers, then assemble every enabled
140+
/// octad table. The metadata *seed* (the only dialect-divergent fragment)
141+
/// is supplied by the caller via `seed`.
142+
fn assemble(
143+
schema: &ParsedSchema,
144+
octad: &OctadConfig,
145+
seed: impl Fn(&ParsedSchema) -> String,
90146
) -> anyhow::Result<String> {
91147
use crate::codegen::ident::validate_identifier;
92148

@@ -105,7 +161,9 @@ pub fn generate_sidecar_schema(
105161
ddl.push_str("-- Do not edit manually; regenerate with `verisimiser init`.\n\n");
106162

107163
// Metadata table: tracks which target tables are being augmented.
108-
ddl.push_str(&generate_metadata_table(schema));
164+
// The CREATE is portable; the seed upsert is dialect-specific.
165+
ddl.push_str(&metadata_table_ddl());
166+
ddl.push_str(&seed(schema));
109167

110168
if octad.enable_provenance {
111169
ddl.push_str(&generate_provenance_table());
@@ -130,52 +188,103 @@ pub fn generate_sidecar_schema(
130188
Ok(ddl)
131189
}
132190

133-
/// Generate the metadata table that tracks which target tables are augmented.
191+
/// The metadata table CREATE — portable across SQLite and PostgreSQL.
134192
///
135193
/// This table is always created regardless of octad configuration, because
136-
/// the Data and Metadata dimensions are always active.
137-
fn generate_metadata_table(schema: &ParsedSchema) -> String {
138-
let mut ddl = String::new();
194+
/// the Data and Metadata dimensions are always active. The per-table seed
195+
/// rows are dialect-specific and emitted by [`sqlite`] / [`postgres`].
196+
fn metadata_table_ddl() -> String {
197+
"-- Metadata: tracks augmented target tables\n\
198+
CREATE TABLE IF NOT EXISTS verisimdb_metadata (\n\
199+
\x20 table_name TEXT PRIMARY KEY,\n\
200+
\x20 column_count INTEGER NOT NULL,\n\
201+
\x20 pk_columns TEXT NOT NULL, -- comma-separated list of PK column names\n\
202+
\x20 discovered_at TEXT NOT NULL -- ISO 8601 timestamp\n\
203+
);\n\n"
204+
.to_string()
205+
}
139206

140-
ddl.push_str("-- Metadata: tracks augmented target tables\n");
141-
ddl.push_str(
142-
"CREATE TABLE IF NOT EXISTS verisimdb_metadata (\n\
143-
\x20 table_name TEXT PRIMARY KEY,\n\
144-
\x20 column_count INTEGER NOT NULL,\n\
145-
\x20 pk_columns TEXT NOT NULL, -- comma-separated list of PK column names\n\
146-
\x20 discovered_at TEXT NOT NULL -- ISO 8601 timestamp\n\
147-
);\n\n",
148-
);
149-
150-
// Generate INSERT statements for each discovered table.
151-
//
152-
// V-L2-G1: every identifier flowing into the SQL string here is
153-
// validated. Anything that wouldn't match `^[A-Za-z_][A-Za-z0-9_]*$`
154-
// is rejected at codegen time rather than allowed to land in DDL
155-
// (where it would be an injection vector).
156-
if !schema.tables.is_empty() {
157-
ddl.push_str("-- Seed metadata from parsed schema\n");
158-
for table in &schema.tables {
159-
let table_name = must_validate_identifier(&table.name);
160-
let pk_cols: Vec<&str> = table
207+
/// Per-table seed values, shared by both dialects. Returns
208+
/// `(validated_table_name, column_count, validated_pk_csv)`.
209+
///
210+
/// V-L2-G1: every identifier flowing into the SQL string here is
211+
/// validated. Anything that wouldn't match `^[A-Za-z_][A-Za-z0-9_]*$`
212+
/// is rejected at codegen time rather than allowed to land in DDL
213+
/// (where it would be an injection vector).
214+
fn metadata_rows(schema: &ParsedSchema) -> Vec<(&str, usize, String)> {
215+
schema
216+
.tables
217+
.iter()
218+
.map(|table| {
219+
let name = must_validate_identifier(&table.name);
220+
let pk_csv = table
161221
.columns
162222
.iter()
163223
.filter(|c| c.is_primary_key)
164224
.map(|c| must_validate_identifier(c.name.as_str()))
165-
.collect();
166-
let pk_str = pk_cols.join(",");
225+
.collect::<Vec<_>>()
226+
.join(",");
227+
(name, table.columns.len(), pk_csv)
228+
})
229+
.collect()
230+
}
231+
232+
/// SQLite-specific sidecar DDL emission (V-L2-F1, #45).
233+
pub mod sqlite {
234+
use super::*;
235+
236+
/// SQLite metadata seed: `INSERT OR IGNORE` + portable
237+
/// `CURRENT_TIMESTAMP` (was the SQLite-only `datetime('now')`).
238+
pub(super) fn metadata_seed(schema: &ParsedSchema) -> String {
239+
if schema.tables.is_empty() {
240+
return String::new();
241+
}
242+
let mut ddl = String::from("-- Seed metadata from parsed schema (SQLite)\n");
243+
for (name, ncols, pk_csv) in metadata_rows(schema) {
167244
ddl.push_str(&format!(
168245
"INSERT OR IGNORE INTO verisimdb_metadata (table_name, column_count, pk_columns, discovered_at)\n\
169-
\x20 VALUES ('{}', {}, '{}', datetime('now'));\n",
170-
table_name,
171-
table.columns.len(),
172-
pk_str,
246+
\x20 VALUES ('{}', {}, '{}', CURRENT_TIMESTAMP);\n",
247+
name, ncols, pk_csv,
248+
));
249+
}
250+
ddl.push('\n');
251+
ddl
252+
}
253+
254+
/// Generate the full SQLite sidecar schema.
255+
pub fn generate(schema: &ParsedSchema, octad: &OctadConfig) -> anyhow::Result<String> {
256+
assemble(schema, octad, metadata_seed)
257+
}
258+
}
259+
260+
/// PostgreSQL-specific sidecar DDL emission (V-L2-F1, #45).
261+
pub mod postgres {
262+
use super::*;
263+
264+
/// PostgreSQL metadata seed: `INSERT … ON CONFLICT DO NOTHING`
265+
/// (SQLite's `INSERT OR IGNORE` is not valid PostgreSQL) + portable
266+
/// `CURRENT_TIMESTAMP`.
267+
pub(super) fn metadata_seed(schema: &ParsedSchema) -> String {
268+
if schema.tables.is_empty() {
269+
return String::new();
270+
}
271+
let mut ddl = String::from("-- Seed metadata from parsed schema (PostgreSQL)\n");
272+
for (name, ncols, pk_csv) in metadata_rows(schema) {
273+
ddl.push_str(&format!(
274+
"INSERT INTO verisimdb_metadata (table_name, column_count, pk_columns, discovered_at)\n\
275+
\x20 VALUES ('{}', {}, '{}', CURRENT_TIMESTAMP)\n\
276+
\x20 ON CONFLICT (table_name) DO NOTHING;\n",
277+
name, ncols, pk_csv,
173278
));
174279
}
175280
ddl.push('\n');
281+
ddl
176282
}
177283

178-
ddl
284+
/// Generate the full PostgreSQL sidecar schema.
285+
pub fn generate(schema: &ParsedSchema, octad: &OctadConfig) -> anyhow::Result<String> {
286+
assemble(schema, octad, metadata_seed)
287+
}
179288
}
180289

181290
/// Generate the provenance log table for the Provenance dimension.
@@ -393,7 +502,8 @@ mod tests {
393502
enable_constraints: true,
394503
enable_simulation: true,
395504
};
396-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
505+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
506+
.expect("test schema must validate");
397507

398508
assert!(ddl.contains("verisimdb_metadata"));
399509
assert!(ddl.contains("verisimdb_provenance_log"));
@@ -417,7 +527,8 @@ mod tests {
417527
enable_constraints: true,
418528
enable_simulation: true,
419529
};
420-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
530+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
531+
.expect("test schema must validate");
421532

422533
// Self-referencing FK on parent_branch.
423534
assert!(
@@ -460,7 +571,8 @@ mod tests {
460571
enable_constraints: false,
461572
enable_simulation: false,
462573
};
463-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
574+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
575+
.expect("test schema must validate");
464576
assert!(ddl.contains("verisimdb_temporal_versions"));
465577
assert!(
466578
ddl.contains("CREATE UNIQUE INDEX IF NOT EXISTS ux_temporal_current"),
@@ -491,7 +603,8 @@ mod tests {
491603
enable_constraints: false,
492604
enable_simulation: false,
493605
};
494-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
606+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
607+
.expect("test schema must validate");
495608
assert!(ddl.contains("verisimdb_lineage_graph"));
496609
// The exact CHECK clause must be present in the emitted DDL.
497610
assert!(
@@ -513,7 +626,8 @@ mod tests {
513626
enable_constraints: false,
514627
enable_simulation: false,
515628
};
516-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
629+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
630+
.expect("test schema must validate");
517631

518632
// Metadata is always generated.
519633
assert!(ddl.contains("verisimdb_metadata"));
@@ -529,7 +643,8 @@ mod tests {
529643
fn test_metadata_seeds_table_info() {
530644
let schema = test_schema();
531645
let octad = OctadConfig::default();
532-
let ddl = generate_sidecar_schema(&schema, &octad).expect("test schema must validate");
646+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
647+
.expect("test schema must validate");
533648

534649
assert!(ddl.contains("INSERT OR IGNORE INTO verisimdb_metadata"));
535650
assert!(ddl.contains("'posts'"));
@@ -680,4 +795,96 @@ mod tests {
680795
);
681796
}
682797
}
798+
799+
// --- #45 acceptance: per-dialect DDL + storage mapping ---
800+
801+
#[test]
802+
fn test_sqlite_dialect_seed_and_portable_timestamp() {
803+
let schema = test_schema();
804+
let octad = OctadConfig::default();
805+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite)
806+
.expect("sqlite ddl");
807+
assert!(ddl.contains("INSERT OR IGNORE INTO verisimdb_metadata"));
808+
assert!(
809+
ddl.contains("CURRENT_TIMESTAMP"),
810+
"portable timestamp must replace datetime(now)"
811+
);
812+
assert!(
813+
!ddl.contains("datetime('now')"),
814+
"the SQLite-only datetime('now') must be gone (V-L2-F1)"
815+
);
816+
assert!(ddl.contains("'posts'") && ddl.contains("verisimdb_provenance_log"));
817+
}
818+
819+
#[test]
820+
fn test_postgres_dialect_uses_on_conflict_not_or_ignore() {
821+
let schema = test_schema();
822+
let octad = OctadConfig::default();
823+
let ddl = generate_sidecar_schema(&schema, &octad, SqlDialect::Postgres)
824+
.expect("postgres ddl");
825+
assert!(
826+
ddl.contains("ON CONFLICT (table_name) DO NOTHING"),
827+
"postgres metadata upsert must use ON CONFLICT"
828+
);
829+
assert!(
830+
!ddl.contains("INSERT OR IGNORE"),
831+
"INSERT OR IGNORE is not valid PostgreSQL"
832+
);
833+
assert!(ddl.contains("CURRENT_TIMESTAMP") && !ddl.contains("datetime('now')"));
834+
assert!(ddl.contains("verisimdb_metadata") && ddl.contains("'posts'"));
835+
}
836+
837+
#[test]
838+
fn test_both_dialects_share_the_octad_table_bodies() {
839+
let schema = test_schema();
840+
let octad = OctadConfig::default();
841+
let s = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite).unwrap();
842+
let p = generate_sidecar_schema(&schema, &octad, SqlDialect::Postgres).unwrap();
843+
for table in [
844+
"verisimdb_provenance_log",
845+
"verisimdb_lineage_graph",
846+
"verisimdb_temporal_versions",
847+
"verisimdb_access_policies",
848+
] {
849+
assert!(s.contains(table), "sqlite missing {table}");
850+
assert!(p.contains(table), "postgres missing {table}");
851+
}
852+
}
853+
854+
#[test]
855+
fn test_empty_schema_emits_no_seed_in_either_dialect() {
856+
let schema = ParsedSchema {
857+
tables: vec![],
858+
source: None,
859+
};
860+
let octad = OctadConfig::default();
861+
let s = generate_sidecar_schema(&schema, &octad, SqlDialect::Sqlite).unwrap();
862+
let p = generate_sidecar_schema(&schema, &octad, SqlDialect::Postgres).unwrap();
863+
assert!(!s.contains("INSERT OR IGNORE") && s.contains("verisimdb_metadata"));
864+
assert!(!s.contains("Seed metadata from parsed schema"));
865+
assert!(!p.contains("ON CONFLICT") && p.contains("verisimdb_metadata"));
866+
assert!(!p.contains("Seed metadata from parsed schema"));
867+
}
868+
869+
#[test]
870+
fn test_storage_to_dialect_mapping() {
871+
assert_eq!(
872+
SqlDialect::from_storage("sqlite").unwrap(),
873+
SqlDialect::Sqlite
874+
);
875+
assert_eq!(
876+
SqlDialect::from_storage("postgres").unwrap(),
877+
SqlDialect::Postgres
878+
);
879+
assert_eq!(
880+
SqlDialect::from_storage("PostgreSQL").unwrap(),
881+
SqlDialect::Postgres
882+
);
883+
let json_err = SqlDialect::from_storage("json").unwrap_err().to_string();
884+
assert!(
885+
json_err.contains("not implemented") && json_err.contains("#112"),
886+
"json must be rejected with the #112 pointer, got: {json_err}"
887+
);
888+
assert!(SqlDialect::from_storage("mariadb").is_err());
889+
}
683890
}

0 commit comments

Comments
 (0)