From 35e91fe7189dc6e7597b1227711421b1c860c2a9 Mon Sep 17 00:00:00 2001 From: yukkop Date: Tue, 4 Feb 2025 02:11:00 +0000 Subject: [PATCH] feat(pg-from): constrains --- package/postgres/pg-from/--inherit=created_at | 0 package/postgres/pg-from/src/main.rs | 119 +++++++++++++++++- .../pg-from/test/fixture/expected.sql | 76 +++++++++++ package/postgres/pg-from/test/fixture/test.db | Bin 0 -> 40960 bytes .../postgres/pg-from/test/fixture/test.sql | 79 ++++++++++++ 5 files changed, 268 insertions(+), 6 deletions(-) delete mode 100644 package/postgres/pg-from/--inherit=created_at create mode 100644 package/postgres/pg-from/test/fixture/expected.sql create mode 100644 package/postgres/pg-from/test/fixture/test.db create mode 100644 package/postgres/pg-from/test/fixture/test.sql diff --git a/package/postgres/pg-from/--inherit=created_at b/package/postgres/pg-from/--inherit=created_at deleted file mode 100644 index e69de29..0000000 diff --git a/package/postgres/pg-from/src/main.rs b/package/postgres/pg-from/src/main.rs index 38ab13a..df77362 100644 --- a/package/postgres/pg-from/src/main.rs +++ b/package/postgres/pg-from/src/main.rs @@ -1,5 +1,6 @@ use rusqlite::{Connection, Result}; use rusqlite::types::ValueRef; +use std::collections::HashMap; use std::env; use std::error::Error; use std::fs; @@ -7,6 +8,7 @@ use std::fs::File; use std::io::Write; use tempfile::NamedTempFile; +/// Print help/usage information. fn print_help(program: &str) { println!( "Usage: {} [--inherit= ...]\n\n\ @@ -16,9 +18,11 @@ fn print_help(program: &str) { ); } +/// Structure representing one column from PRAGMA table_info. #[derive(Debug)] struct ColumnInfo { - _cid: i32, + #[allow(dead_code)] + cid: i32, name: String, data_type: String, notnull: bool, @@ -26,6 +30,7 @@ struct ColumnInfo { pk: i32, } +/// Converts an SQLite type to a PostgreSQL type (very simple logic). fn convert_sqlite_type_to_postgres(sqlite_type: &str) -> String { let upper = sqlite_type.to_uppercase(); if upper.contains("INT") { @@ -41,6 +46,8 @@ fn convert_sqlite_type_to_postgres(sqlite_type: &str) -> String { } } +/// Generates the CREATE TABLE statement for a given table, based on PRAGMA table_info. +/// If an inheritance clause is provided, appends INHERITS (). fn generate_create_table_sql( table: &str, conn: &Connection, @@ -51,7 +58,7 @@ fn generate_create_table_sql( let columns: Vec = stmt .query_map([], |row| { Ok(ColumnInfo { - _cid: row.get(0)?, + cid: row.get(0)?, name: row.get(1)?, data_type: row.get(2)?, notnull: row.get::<_, i32>(3)? != 0, @@ -64,6 +71,7 @@ fn generate_create_table_sql( let mut column_defs = Vec::new(); let pk_columns: Vec<&ColumnInfo> = columns.iter().filter(|col| col.pk > 0).collect(); + // If exactly one PK and its type starts with "INTEGER", generate SERIAL. let single_autoinc = if pk_columns.len() == 1 { let col = pk_columns[0]; col.data_type.to_uppercase().starts_with("INTEGER") @@ -92,6 +100,7 @@ fn generate_create_table_sql( column_defs.push(col_def); } + // If composite primary key exists, add it as a separate constraint. if pk_columns.len() > 1 { let pk_names: Vec = pk_columns .iter() @@ -114,6 +123,7 @@ fn generate_create_table_sql( Ok(table_sql) } +/// Generates DDL for indexes of a given table. fn generate_indexes_sql(table: &str, conn: &Connection, schema: &str) -> Result, Box> { let mut indexes = Vec::new(); let mut stmt = conn.prepare(&format!("PRAGMA index_list(\"{}\")", table))?; @@ -153,10 +163,87 @@ fn generate_indexes_sql(table: &str, conn: &Connection, schema: &str) -> Result< Ok(indexes) } +/// Represents one foreign key entry from PRAGMA foreign_key_list. +struct ForeignKeyInfo { + id: i32, + seq: i32, + table: String, + from: String, + to: String, + on_update: String, + on_delete: String, + #[allow(dead_code)] + r#match: String, +} + +/// Generates foreign key constraints for the given table. It groups rows by foreign key ID +/// (to support multi‑column foreign keys) and produces ALTER TABLE … ADD CONSTRAINT statements. +fn generate_foreign_keys_sql( + table: &str, + conn: &Connection, + schema: &str, +) -> Result, Box> { + let mut stmt = conn.prepare(&format!("PRAGMA foreign_key_list(\"{}\")", table))?; + let fk_rows: Vec = stmt + .query_map([], |row| { + Ok(ForeignKeyInfo { + id: row.get(0)?, + seq: row.get(1)?, + table: row.get(2)?, + from: row.get(3)?, + to: row.get(4)?, + on_update: row.get(5)?, + on_delete: row.get(6)?, + r#match: row.get(7)?, + }) + })? + .collect::, _>>()?; + + // Group rows by foreign key ID. + let mut fk_map: HashMap> = HashMap::new(); + for fk in fk_rows { + fk_map.entry(fk.id).or_default().push(fk); + } + let mut constraints = Vec::new(); + for (fk_id, mut fks) in fk_map { + // Sort by sequence number. + fks.sort_by_key(|fk| fk.seq); + // All entries in this group refer to the same target table. + let ref_table = &fks[0].table; + let on_update = &fks[0].on_update; + let on_delete = &fks[0].on_delete; + let from_columns: Vec = fks.iter().map(|fk| format!("\"{}\"", fk.from)).collect(); + let to_columns: Vec = fks.iter().map(|fk| format!("\"{}\"", fk.to)).collect(); + // Generate a constraint name, e.g. fk_table_1. + let constraint_name = format!("fk_{}_{}", table, fk_id); + let mut constraint = format!( + "ALTER TABLE {}.\"{}\" ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}.\"{}\" ({})", + schema, + table, + constraint_name, + from_columns.join(", "), + schema, + ref_table, + to_columns.join(", ") + ); + if !on_update.is_empty() && on_update.to_uppercase() != "NO ACTION" { + constraint.push_str(&format!(" ON UPDATE {}", on_update)); + } + if !on_delete.is_empty() && on_delete.to_uppercase() != "NO ACTION" { + constraint.push_str(&format!(" ON DELETE {}", on_delete)); + } + constraint.push(';'); + constraints.push(constraint); + } + Ok(constraints) +} + +/// Escapes a text value for the PostgreSQL COPY format (e.g. escapes backslashes). fn escape_copy_text(s: &str) -> String { s.replace("\\", "\\\\") } +/// Formats a single column value for the PostgreSQL COPY command. NULL values become "\N". fn format_copy_field(value: ValueRef) -> String { match value { ValueRef::Null => "\\N".to_string(), @@ -167,44 +254,52 @@ fn format_copy_field(value: ValueRef) -> String { escape_copy_text(s) }, ValueRef::Blob(b) => { + // Convert blob to a hex string prefixed with \x. let hex: String = b.iter().map(|byte| format!("{:02X}", byte)).collect(); format!("\\x{}", hex) }, } } +/// Dumps data for a table in PostgreSQL COPY format. fn dump_table_data(table: &str, conn: &Connection, schema: &str, out: &mut File) -> Result<(), Box> { + // Get column names using PRAGMA table_info. let mut stmt = conn.prepare(&format!("PRAGMA table_info(\"{}\")", table))?; - let column_names: Result, _> = - stmt.query_map([], |row| row.get(1))?.collect(); + let column_names: Result, _> = stmt.query_map([], |row| row.get(1))?.collect(); let column_names = column_names?; + // Write COPY header. writeln!(out, "\n-- Data for table {}", table)?; writeln!(out, "COPY {}.\"{}\" ({}) FROM stdin;", schema, table, column_names.join(", "))?; + // Query all rows from the table. let mut stmt = conn.prepare(&format!("SELECT * FROM \"{}\"", table))?; let mut rows = stmt.query([])?; + // Use the known number of columns. + let col_count = column_names.len(); while let Some(row) = rows.next()? { - let col_count = column_names.len(); let mut fields = Vec::new(); for i in 0..col_count { let value = row.get_ref(i)?; fields.push(format_copy_field(value)); } + // Write tab-separated fields. writeln!(out, "{}", fields.join("\t"))?; } + // End COPY command. writeln!(out, "\\.")?; Ok(()) } fn main() -> Result<(), Box> { + // Process command-line arguments. let args: Vec = env::args().collect(); if args.iter().any(|arg| arg == "--help" || arg == "-h") { print_help(&args[0]); std::process::exit(0); } if args.len() < 4 { - eprintln!("Error: Недостаточно аргументов.\n"); + eprintln!("Error: Not enough arguments.\n"); print_help(&args[0]); std::process::exit(1); } @@ -212,6 +307,7 @@ fn main() -> Result<(), Box> { let output_file = &args[2]; let schema = &args[3]; + // Gather multiple --inherit options. let mut inherit_clauses: Vec = Vec::new(); for arg in &args[4..] { if arg.starts_with("--inherit=") { @@ -224,16 +320,20 @@ fn main() -> Result<(), Box> { Some(inherit_clauses.join(", ")) }; + // Copy the original SQLite file into a temporary file. let temp_file = NamedTempFile::new()?; fs::copy(sqlite_file, temp_file.path())?; let conn = Connection::open(temp_file.path())?; + // Open (or create) the output file. let mut out = File::create(output_file)?; + // Write header. writeln!(out, "-- PostgreSQL database dump generated from SQLite")?; writeln!(out, "CREATE SCHEMA IF NOT EXISTS {};\n", schema)?; writeln!(out, "SET client_encoding = 'UTF8';\n")?; + // Get table names (excluding internal SQLite tables). let mut table_names = Vec::new(); { let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?; @@ -244,6 +344,7 @@ fn main() -> Result<(), Box> { } } + // Generate DDL for each table, its indexes, and foreign key constraints. for table in &table_names { let table_sql = generate_create_table_sql(table, &conn, schema, inherit_clause.as_deref())?; writeln!(out, "{}\n", table_sql)?; @@ -253,8 +354,13 @@ fn main() -> Result<(), Box> { for idx in indexes { writeln!(out, "{}\n", idx)?; } + let fkeys = generate_foreign_keys_sql(table, &conn, schema)?; + for fk in fkeys { + writeln!(out, "{}\n", fk)?; + } } + // Process sqlite_sequence (for autoincrement values). let sqlite_sequence_exists: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='sqlite_sequence')", [], @@ -279,6 +385,7 @@ fn main() -> Result<(), Box> { } } + // Dump data for each table. for table in &table_names { dump_table_data(table, &conn, schema, &mut out)?; } diff --git a/package/postgres/pg-from/test/fixture/expected.sql b/package/postgres/pg-from/test/fixture/expected.sql new file mode 100644 index 0000000..66acb5b --- /dev/null +++ b/package/postgres/pg-from/test/fixture/expected.sql @@ -0,0 +1,76 @@ +-- PostgreSQL database dump generated from SQLite +CREATE SCHEMA IF NOT EXISTS legacy; + +SET client_encoding = 'UTF8'; + +CREATE TABLE legacy."authors" ( + "id" SERIAL PRIMARY KEY, + "name" text NOT NULL, + "email" text +) INHERITS (created_at, updated_at); + +ALTER TABLE legacy."authors" OWNER TO postgres; + +CREATE TABLE legacy."books" ( + "id" SERIAL PRIMARY KEY, + "title" text NOT NULL, + "author_id" bigint NOT NULL, + "published_date" text, + "price" double precision +) INHERITS (created_at, updated_at); + +ALTER TABLE legacy."books" OWNER TO postgres; + +CREATE INDEX idx_books_price ON legacy."books" ("price"); + +CREATE INDEX idx_books_title ON legacy."books" ("title"); + +ALTER TABLE legacy."books" ADD CONSTRAINT fk_books_0 FOREIGN KEY ("author_id") REFERENCES legacy."authors" ("id"); + +CREATE TABLE legacy."reviews" ( + "book_id" bigint, + "review_id" bigint, + "reviewer" text, + "rating" bigint, + "comment" text, + PRIMARY KEY ("book_id", "review_id") +) INHERITS (created_at, updated_at); + +ALTER TABLE legacy."reviews" OWNER TO postgres; + +ALTER TABLE legacy."reviews" ADD CONSTRAINT fk_reviews_0 FOREIGN KEY ("book_id") REFERENCES legacy."books" ("id"); + +CREATE TABLE legacy."book_log" ( + "log_id" SERIAL PRIMARY KEY, + "book_id" bigint, + "created_at" text DEFAULT CURRENT_TIMESTAMP +) INHERITS (created_at, updated_at); + +ALTER TABLE legacy."book_log" OWNER TO postgres; + +CREATE SEQUENCE legacy_books_seq START WITH 3 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; + +-- Data for table authors +COPY legacy."authors" (id, name, email) FROM stdin; +1 Author One author1@example.com +2 Author Two author2@example.com +\. + +-- Data for table books +COPY legacy."books" (id, title, author_id, published_date, price) FROM stdin; +1 Book One 1 2020-01-01 9.99 +2 Book Two 2 2021-05-15 19.99 +\. + +-- Data for table reviews +COPY legacy."reviews" (book_id, review_id, reviewer, rating, comment) FROM stdin; +1 1 Reviewer A 4 Good book +1 2 Reviewer B 5 Excellent! +2 1 Reviewer C 3 Average +\. + +-- Data for table book_log +COPY legacy."book_log" (log_id, book_id, created_at) FROM stdin; +1 1 2025-02-04 00:21:48 +2 2 2025-02-04 00:21:48 +\. diff --git a/package/postgres/pg-from/test/fixture/test.db b/package/postgres/pg-from/test/fixture/test.db new file mode 100644 index 0000000000000000000000000000000000000000..0174a3306f1b739a42fddf792a2bb51e20560544 GIT binary patch literal 40960 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU|?imVBlgv01gHQ1{MStERV#+%4B5F zE9>Uv|G~h`@qvNw5dTTu1YSPwMLhSpL%6dfW_<&X4sNiS_jE2By2#kinXb6mk zz-S0iEd(09xYHKbS}k;}{9(@z2HE`=loe?Nspz04E^TZJS&h&}O{DUBjr?9$re9F2t~ zMVaa8sYNA4>0qD4XXX{B7L|ZCLwykvV@cWjWc! zWn~$gcuNwKa#F!+bMn(+G#AW8j!r(VNM?eq%gIlV&rDJ9^b2tXxi%ok)7LR5Qo-9b z5)zJJLl6oeg2_dxi6yBi@rfl0E{-9tA)daj3NEg0j-fsw3eKTHL9Tuw@gTwA5J%qt zF3mU&c5!WS#zywcyp+_6%#;dnm=(ttfMNk;K`|%PiJpEgt`Q1oD&V1{0oI_Y5bog` zID5D{dn;%ZK}4Ng zL&9BM{S*up9Q|ArOf_{B6m)eJoHJ6BvlWu_^NLG~5;OBk6iV_Hic*V9iZYW+6reiG z5_3vZi@^>|&d<$F%_~8&6DhjkX-r1}?q*Gh%iR2fTs__WK#2m85QALZT!UQwoLz&# zNvc>QGewh2Qa6wk~qE-ud4DhoCf6eq=Cf(1oCSO^+#h}5ls z93GCLA^x6z&Oxreu6`kq@OCUM$=A)yOD;+Ug(E1o3W_pw6N@SpvQsO;K@3hp-~d+e z^AAz*3-$4Vgdw~Pfk!Z^JZez|&Q>~*Vhx4V)m3mwEJ;-;F3B%SO;JcJhS;T%U!>p} z>~CRaUU0sC$M3^d+7K1_=OX&pm9w^o!0RRt7l+Xfm zbafSiQVUBnLE)8_nVORV)|#4|n3)678|vp780rdEi$>_`Dum``7M7+eK$IhL8keRn z6TXxV^?AG@l)=ovzyRvcGw?DnFfj4&V&K2azl&;>?5IJbAut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8Uj=b0YxSj21R2>@bI-lNO?Y_XK&h8o=H#U2l_*Lv zvT<@MGRnjCIkLFt=cj=T|t}p-q literal 0 HcmV?d00001 diff --git a/package/postgres/pg-from/test/fixture/test.sql b/package/postgres/pg-from/test/fixture/test.sql new file mode 100644 index 0000000..723039e --- /dev/null +++ b/package/postgres/pg-from/test/fixture/test.sql @@ -0,0 +1,79 @@ +-- Enable foreign key constraints +PRAGMA foreign_keys = ON; + +BEGIN TRANSACTION; + +-- Table: authors +CREATE TABLE authors ( + id INTEGER PRIMARY KEY, -- Primary key using INTEGER PRIMARY KEY + name TEXT NOT NULL, -- Required field + email TEXT UNIQUE -- Unique email constraint +); + +-- Table: books +CREATE TABLE books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- Auto-incrementing primary key + title TEXT NOT NULL, + author_id INTEGER NOT NULL, + published_date DATE, -- Date stored as TEXT (or ISO8601 format) + price REAL, + CONSTRAINT fk_author FOREIGN KEY(author_id) REFERENCES authors(id) +); + +-- Table: reviews with composite primary key and a CHECK constraint +CREATE TABLE reviews ( + book_id INTEGER, + review_id INTEGER, + reviewer TEXT, + rating INTEGER CHECK (rating BETWEEN 1 AND 5), -- Check constraint to restrict rating values + comment TEXT, + PRIMARY KEY (book_id, review_id), + FOREIGN KEY(book_id) REFERENCES books(id) +); + +-- Create a standard index on books (non-unique) +CREATE INDEX idx_books_title ON books(title); + +-- Create a partial index (only rows where price > 10) +CREATE INDEX idx_books_price ON books(price) WHERE price > 10; + +-- Table: book_log for logging inserted books +CREATE TABLE book_log ( + log_id INTEGER PRIMARY KEY, + book_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Trigger: After inserting a book, log its id into book_log +CREATE TRIGGER trg_book_insert +AFTER INSERT ON books +BEGIN + INSERT INTO book_log (book_id) VALUES (new.id); +END; + +-- Create a view joining authors and books +CREATE VIEW vw_author_books AS +SELECT a.name AS author, + b.title, + b.published_date, + b.price +FROM authors a +JOIN books b ON a.id = b.author_id; + +-- Insert sample data into authors +INSERT INTO authors (id, name, email) VALUES (1, 'Author One', 'author1@example.com'); +INSERT INTO authors (name, email) VALUES ('Author Two', 'author2@example.com'); + +-- Insert sample data into books +INSERT INTO books (title, author_id, published_date, price) VALUES ('Book One', 1, '2020-01-01', 9.99); +INSERT INTO books (title, author_id, published_date, price) VALUES ('Book Two', 2, '2021-05-15', 19.99); + +-- Insert sample data into reviews +INSERT INTO reviews (book_id, review_id, reviewer, rating, comment) +VALUES (1, 1, 'Reviewer A', 4, 'Good book'); +INSERT INTO reviews (book_id, review_id, reviewer, rating, comment) +VALUES (1, 2, 'Reviewer B', 5, 'Excellent!'); +INSERT INTO reviews (book_id, review_id, reviewer, rating, comment) +VALUES (2, 1, 'Reviewer C', 3, 'Average'); + +COMMIT;