@@ -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 {
87128pub 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