diff --git a/Cargo.lock b/Cargo.lock index 6913f607a..77993285f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -559,6 +568,7 @@ dependencies = [ "serde_json", "similar", "sqlformat", + "sqlparser", "tattle", "textplots", "thiserror 1.0.69", @@ -2843,6 +2853,15 @@ dependencies = [ "libm", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "ode_solvers" version = "0.6.1" @@ -3144,6 +3163,16 @@ dependencies = [ "proptest", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "qubit" version = "1.0.0-beta.0" @@ -3384,6 +3413,26 @@ dependencies = [ "winnow", ] +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.101", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -4186,6 +4235,16 @@ dependencies = [ "winnow", ] +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", + "recursive", +] + [[package]] name = "sqlx" version = "0.8.5" @@ -4405,6 +4464,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "stringprep" version = "0.1.5" diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index f01b6c7a1..4945819b4 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -95,12 +95,17 @@ impl ThSchema { analyses::sql::SQLBackend::try_from(backend) .and_then(|backend| { analyses::sql::SQLAnalysis::new(backend) - .render( + .execute( model.discrete()?, |id| model.ob_namespace.label_string(id), |id| model.mor_namespace.label_string(id), ) - .map_err(|e| format!("{}", e)) + .map_err(|errs| { + errs.into_iter() + .map(|e| format!("- {e}")) + .collect::>() + .join("\n\n") + }) }) .into() } diff --git a/packages/catlog/Cargo.toml b/packages/catlog/Cargo.toml index dc0f9219c..535d024f4 100644 --- a/packages/catlog/Cargo.toml +++ b/packages/catlog/Cargo.toml @@ -9,7 +9,7 @@ edition = "2024" ode = ["dep:ode_solvers", "dep:nalgebra"] serde = ["dep:serde", "nonempty/serialize", "ustr/serde", "uuid/serde"] serde-wasm = ["serde", "dep:wasm-bindgen", "dep:tsify"] -sql = ["dep:sea-query", "dep:sqlformat" ] +sql = ["dep:sea-query", "dep:sqlformat", "dep:sqlparser"] stochastic = ["dep:rebop"] [dependencies] @@ -41,6 +41,7 @@ wasm-bindgen = { version = "0.2.100", optional = true } catcolab-document-types = { version = "0.1.0", path = "../document-types" } sea-query = { version = "0.32.7", optional = true } sqlformat = { version = "0.5.0", optional = true } +sqlparser = { version = "0.62.0", optional = true } [dev-dependencies] clap = { version = "4.5.47", features = ["derive"] } diff --git a/packages/catlog/src/stdlib/analyses/sql.rs b/packages/catlog/src/stdlib/analyses/sql.rs index 587835670..d820acb2a 100644 --- a/packages/catlog/src/stdlib/analyses/sql.rs +++ b/packages/catlog/src/stdlib/analyses/sql.rs @@ -2,10 +2,11 @@ use crate::{ dbl::model::*, one::{ - Path, + FgCategory, Path, graph::FinGraph, graph_algorithms::{ToposortData, toposort_lenient}, }, + validate::Validate, zero::{QualifiedLabel, QualifiedName, name}, }; use derive_more::Constructor; @@ -18,8 +19,11 @@ use sea_query::{ PostgresQueryBuilder, SqliteQueryBuilder, Table, TableCreateStatement, prepare::Write, }; use sqlformat::{Dialect, format}; +use sqlparser::{dialect::GenericDialect, parser}; use std::fmt; +const PRIMARY_KEY_NAME: &str = "id"; + impl Iden for QualifiedName { fn unquoted(&self, s: &mut dyn Write) { Iden::unquoted(&format!("{self}").as_str(), s) @@ -177,6 +181,15 @@ pub enum SQLAnalysisError { /// The tables which have failing foreign key constraints. cycles: Vec<(QualifiedName, ColumnType)>, }, + /// There is a duplicate column on a table. + DuplicateColumnError { + /// The table with the duplicate column. + table: QualifiedName, + /// The duplicate column. + column: QualifiedName, + }, + /// Miscellaneous SQL parsing errors. + SQLParsingError(String), } impl std::fmt::Display for SQLAnalysisError { @@ -187,6 +200,10 @@ impl std::fmt::Display for SQLAnalysisError { "Cycle detected at tables {:#?}. {backend} cannot support cyclic foreign keys.", cycles ), + SQLAnalysisError::DuplicateColumnError { table, column } => { + write!(f, "Duplicate column {column} found on {table}") + } + SQLAnalysisError::SQLParsingError(err) => write!(f, "{err}"), } } } @@ -197,6 +214,55 @@ pub struct SQLAnalysis { backend: SQLBackend, } +type SQLAnalysisResult = Result>; + +impl SQLAnalysis { + /// Consumes itself and a discrete double model to produce a SQL string. + pub fn execute( + &self, + model: &DiscreteDblModel, + ob_label: impl Fn(&QualifiedName) -> String, + mor_label: impl Fn(&QualifiedName) -> String, + // SQLAnalysisOutput has the output of the execution as well as warnings + ) -> SQLAnalysisResult { + let mut errors: Vec = self.pre_validate(model, &mor_label); + + let constraints = match self.toposort_morphisms(model) { + Ok(x) => x, + Err(e) => { + errors.push(e); + return Err(nonempty::NonEmpty::from_vec(errors).unwrap()); + } + }; + + let tables = self.make_tables(model, constraints.clone(), &ob_label, &mor_label); + let output: String = self.build(tables, constraints.clone(), ob_label, mor_label); + let formatted_output = self.format(&output); + // pragmas + let result = match self.backend { + SQLBackend::SQLite => ["PRAGMA foreign_keys = ON", &formatted_output].join(";\n\n"), + _ => formatted_output, + }; + + match SQLAnalysisResult::Ok(result.clone()).validate() { + Ok(_) => Ok(result), + Err(nonempty::NonEmpty { head: e, tail: _ }) => { + errors.push(e); + Err(nonempty::NonEmpty::from_vec(errors).unwrap()) + } + } + } + + /// Validates the model before execute the analysis. Useful for checking things such as a duplicate foreign keys. + fn pre_validate( + &self, + model: &DiscreteDblModel, + mor_label: impl Fn(&QualifiedName) -> String, + ) -> Vec { + self.validate_duplicate_foreign_keys(model, &mor_label) + } +} + impl SQLAnalysis { /// Returns formatted output. pub fn format(&self, output: &str) -> String { @@ -211,6 +277,26 @@ impl SQLAnalysis { ) } + /// Validates duplicate foreign keys + pub fn validate_duplicate_foreign_keys( + &self, + model: &DiscreteDblModel, + mor_label: impl Fn(&QualifiedName) -> String, + ) -> Vec { + model + .mor_generators() + .filter_map(|mor| { + (&mor_label(&mor) == PRIMARY_KEY_NAME).then_some({ + Some(SQLAnalysisError::DuplicateColumnError { + table: model.get_dom(&mor).unwrap().clone(), + column: mor, + }) + }) + }) + .collect::>>() + .unwrap() + } + /// Builds table statements into valid SQL DML. fn build( &self, @@ -273,29 +359,11 @@ impl SQLAnalysis { self.validate_toposort(constraints) } - /// Consumes itself and a discrete double model to produce a SQL string. - pub fn render( - &self, - model: &DiscreteDblModel, - ob_label: impl Fn(&QualifiedName) -> String, - mor_label: impl Fn(&QualifiedName) -> String, - ) -> Result { - let constraints = self.toposort_morphisms(model); - let tables = self.make_tables(model, constraints.clone()?, &ob_label, &mor_label); - let output: String = self.build(tables, constraints.clone()?, ob_label, mor_label); - let formatted_output = self.format(&output); - // pragmas - match self.backend { - SQLBackend::SQLite => Ok(["PRAGMA foreign_keys = ON", &formatted_output].join(";\n\n")), - _ => Ok(formatted_output), - } - } - fn fk(&self, src: &str, tgt: &str, mor: &str) -> ForeignKeyCreateStatement { ForeignKey::create() .name(format!("FK_{}_{}_{}", mor, src, tgt)) .from(Alias::new(src), Alias::new(mor)) - .to(Alias::new(tgt), "id") + .to(Alias::new(tgt), PRIMARY_KEY_NAME) .to_owned() } @@ -315,7 +383,11 @@ impl SQLAnalysis { // the targets for arrows let table_column_defs = mors.iter().fold( tbl.table(Alias::new(ob_label(&ob))).if_not_exists().col( - ColumnDef::new("id").integer().not_null().auto_increment().primary_key(), + ColumnDef::new(PRIMARY_KEY_NAME) + .integer() + .not_null() + .auto_increment() + .primary_key(), ), |acc, mor| { let mor_tgt = mor.tgt(); @@ -417,7 +489,7 @@ impl fmt::Display for SQLBackend { SQLBackend::SQLite => "SQLite", SQLBackend::PostgresSQL => "PostgresSQL", }; - write!(f, "{}", string) + write!(f, "{string}") } } @@ -434,6 +506,19 @@ fn add_column_type(col: &mut ColumnDef, label: &str) { }; } +impl Validate for SQLAnalysisResult { + type ValidationError = SQLAnalysisError; + + fn validate(&self) -> Result<(), nonempty::NonEmpty> { + self.clone().and_then(|result| { + match parser::Parser::parse_sql(&GenericDialect {}, &result) { + Ok(_) => Ok(()), + Err(e) => Err(nonempty![SQLAnalysisError::SQLParsingError(format!("{e}"))]), + } + }) + } +} + #[cfg(test)] mod tests { use expect_test::expect; @@ -468,7 +553,7 @@ CREATE TABLE IF NOT EXISTS `Person` ( );"# ]]; let ddl = SQLAnalysis::new(SQLBackend::MySQL) - .render( + .execute( &model, |id| format!("{id}").as_str().into(), |id| format!("{id}").as_str().into(), @@ -477,6 +562,62 @@ CREATE TABLE IF NOT EXISTS `Person` ( expected.assert_eq(&ddl); } + #[test] + fn sql_schema_duplicate_columns() { + let th = Rc::new(th_schema()); + let source = "[ + Person : Entity, + Dog : Entity, + walks : (Hom Entity)[Person, Dog], + hair : AttrType, + name: AttrType, + id : Attr[Person, hair], + has : Attr[Person, name], + ]"; + let model = tt::modelgen::Model::from_text(&th.clone().into(), source) + .ok() + .and_then(|m| m.as_discrete()) + .unwrap(); + + let expected = vec![SQLAnalysisError::DuplicateColumnError { + table: name("Person"), + column: name("id"), + }]; + let errors = SQLAnalysis::new(SQLBackend::MySQL) + .pre_validate(&model, |id| format!("{id}").as_str().into()); + assert_eq!(&expected, &errors); + } + + #[test] + fn sql_schema_bad_characters() { + let th = Rc::new(th_schema()); + let source = "[ + Person : Entity, + Dog : Entity, + walks : (Hom Entity)[Person, Dog], + | : AttrType, + has : Attr[Person, |], + ]"; + let model = tt::modelgen::Model::from_text(&th.clone().into(), source) + .ok() + .and_then(|m| m.as_discrete()) + .unwrap(); + + let result = SQLAnalysis::new(SQLBackend::MySQL).execute( + &model, + |id| format!("{id}").as_str().into(), + |id| format!("{id}").as_str().into(), + ); + let validation = result.validate(); + + let expected: Result<(), nonempty::NonEmpty> = + Err(nonempty![SQLAnalysisError::SQLParsingError( + "sql parser error: Expected: a data type name, found: | at Line: 6, Column: 9" + .into() + )]); + assert_eq!(&expected, &validation); + } + #[test] fn sql_postgres_cycles() { let th = Rc::new(th_schema()); @@ -516,7 +657,7 @@ ALTER TABLE ADD CONSTRAINT fk_head_Refs_Snapshots FOREIGN KEY (head) REFERENCES "Snapshots" (id) DEFERRABLE INITIALLY DEFERRED;"#]]; let ddl = SQLAnalysis::new(SQLBackend::PostgresSQL) - .render( + .execute( &model, |id| format!("{id}").as_str().into(), |id| format!("{id}").as_str().into(), @@ -542,7 +683,7 @@ ADD .and_then(|m| m.as_discrete()) .unwrap(); - let ddl = SQLAnalysis::new(SQLBackend::MySQL).render( + let ddl = SQLAnalysis::new(SQLBackend::MySQL).execute( &model, |id| format!("{id}").as_str().into(), |id| format!("{id}").as_str().into(), @@ -550,7 +691,7 @@ ADD let e = ddl.unwrap_err(); assert_eq!( e, - SQLAnalysisError::CyclicForeignKeyError { + nonempty![SQLAnalysisError::CyclicForeignKeyError { backend: SQLBackend::MySQL, cycles: vec![ ( @@ -565,7 +706,7 @@ ADD } ) ] - } + }] ); } } diff --git a/packages/frontend/src/stdlib/analyses/sql.tsx b/packages/frontend/src/stdlib/analyses/sql.tsx index c8c8e1c18..219c50e7f 100644 --- a/packages/frontend/src/stdlib/analyses/sql.tsx +++ b/packages/frontend/src/stdlib/analyses/sql.tsx @@ -111,7 +111,7 @@ export default function SQLSchemaAnalysis(

{"The model failed to compile into a SQL script."}

-

{"Check for cycles in foreign key constraints."}

+

{result().content}