diff --git a/Cargo.lock b/Cargo.lock index cc485b4..dc3ff96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,20 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.2.15", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -181,6 +195,26 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad1fa75f77bbd06f187540aa1d70ca50b80b27ce85e7f41c0ce7ff42b34ed3b" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1cef5dd4a4457dd11529e743d18ba4fabbd5f20b6895f4c865cb257337dcf9f" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "2.8.0" @@ -318,6 +352,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -398,6 +452,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + [[package]] name = "crypto-common" version = "0.1.6" @@ -1144,6 +1204,15 @@ dependencies = [ "syn", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1404,6 +1473,9 @@ name = "once_cell" version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +dependencies = [ + "portable-atomic", +] [[package]] name = "openssl" @@ -1574,6 +1646,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1784,6 +1862,34 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rhai" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4d759a4729a655ddfdbb3ff6e77fb9eadd902dae12319455557796e435d2a6" +dependencies = [ + "ahash", + "bitflags", + "instant", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", +] + +[[package]] +name = "rhai_codegen" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "rsa" version = "0.9.7" @@ -1975,11 +2081,13 @@ dependencies = [ name = "server" version = "0.1.0" dependencies = [ + "bincode", "chrono", "common", "dotenvy", "lazy_static", "prost", + "rhai", "rstest", "serde", "serde_json", @@ -2077,6 +2185,17 @@ dependencies = [ "serde", ] +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "socket2" version = "0.5.8" @@ -2401,6 +2520,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thin-vec" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38c90d48152c236a3ab59271da4f4ae63d678c5d7ad6b7714d7cb9760be5e4b" + [[package]] name = "thiserror" version = "2.0.11" @@ -2454,6 +2579,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2769,6 +2903,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unty" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a88342087869553c259588a3ec9ca73ce9b2d538b7051ba5789ff236b6c129" + [[package]] name = "url" version = "2.5.4" @@ -2804,6 +2944,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "want" version = "0.3.1" diff --git a/common/proto/table_script.proto b/common/proto/table_script.proto new file mode 100644 index 0000000..021b3fc --- /dev/null +++ b/common/proto/table_script.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; +package multieko2.table_script; + +message PostTableScriptRequest { + int64 table_definition_id = 1; + string target_column = 2; + string rhai_script = 3; +} + +message TableScriptResponse { + int64 id = 1; + string created_at = 2; + string warnings = 3; +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 6fc65c2..7dc5c21 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -18,6 +18,8 @@ tonic = "0.12.3" tonic-reflection = "0.12.3" tracing = "0.1.41" time = { version = "0.3.37", features = ["local-offset"] } +rhai = "1.21.0" +bincode = "2.0.0" [lib] name = "server" diff --git a/server/migrations/20250306132638_create_table_scripts.sql b/server/migrations/20250306132638_create_table_scripts.sql index 56334a5..822b5d4 100644 --- a/server/migrations/20250306132638_create_table_scripts.sql +++ b/server/migrations/20250306132638_create_table_scripts.sql @@ -4,7 +4,7 @@ CREATE TABLE table_scripts ( table_definitions_id BIGINT NOT NULL REFERENCES table_definitions(id), target_column TEXT NOT NULL, rhai_script TEXT NOT NULL, - compiled_script BYTEA NOT NULL; + compiled_script BYTEA NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(table_definitions_id, target_column) ); diff --git a/server/src/lib.rs b/server/src/lib.rs index cdbfe88..5547234 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,6 +7,7 @@ pub mod shared; pub mod table_structure; pub mod table_definition; pub mod tables_data; +pub mod table_script; // Re-export run_server from the inner server module: pub use server::run_server; diff --git a/server/src/table_script/handlers.rs b/server/src/table_script/handlers.rs new file mode 100644 index 0000000..da0a988 --- /dev/null +++ b/server/src/table_script/handlers.rs @@ -0,0 +1,4 @@ +// src/table_script/handlers.rs +pub mod post_table_script; + +pub use post_table_script::post_table_script; diff --git a/server/src/table_script/handlers/post_table_script.rs b/server/src/table_script/handlers/post_table_script.rs new file mode 100644 index 0000000..ec19631 --- /dev/null +++ b/server/src/table_script/handlers/post_table_script.rs @@ -0,0 +1,119 @@ +// src/table_script/handlers/post_table_script.rs +use tonic::Status; +use sqlx::{PgPool, Error as SqlxError}; +use rhai::Engine; +use time::OffsetDateTime; +use bincode::serialize; +use common::proto::multieko2::table_script::{PostTableScriptRequest, TableScriptResponse}; + +pub async fn post_table_script( + db_pool: &PgPool, + request: PostTableScriptRequest, +) -> Result { + // Fetch table definition + let table_def = sqlx::query!( + r#"SELECT table_name, columns, linked_table_id + FROM table_definitions WHERE id = $1"#, + request.table_definition_id + ) + .fetch_optional(db_pool) + .await + .map_err(|e| Status::internal(format!("Database error: {}", e)))? + .ok_or_else(|| Status::not_found("Table definition not found"))?; + + // Validate target column exists + let columns: Vec = serde_json::from_value(table_def.columns.clone()) + .map_err(|e| Status::invalid_argument(format!("Invalid columns format: {}", e)))?; + + let column_names: Vec<&str> = columns.iter() + .filter_map(|col| col.split_whitespace().next()) + .map(|name| name.trim_matches('"')) + .collect(); + + if !column_names.contains(&request.target_column.as_str()) { + return Err(Status::invalid_argument("Target column not found in table definition")); + } + + // Check for existing data + let row_count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {}", + sanitize_identifier(&table_def.table_name) + )) + .fetch_one(db_pool) + .await + .map_err(|e| Status::internal(format!("Data check failed: {}", e)))?; + + if row_count > 0 { + return Err(Status::failed_precondition( + "Cannot add scripts to tables with existing data" + )); + } + + // Compile Rhai script + let (ast, warnings) = compile_rhai_script(&request.rhai_script) + .map_err(|e| Status::invalid_argument(format!("Script error: {}", e)))?; + + // Store script in database + let script_record = sqlx::query!( + r#"INSERT INTO table_scripts + (table_definitions_id, target_column, rhai_script, compiled_script) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at"#, + request.table_definition_id, + request.target_column, + request.rhai_script, + bincode::serialize(&ast).map_err(|e| Status::internal(format!("Serialization failed: {}", e)))?, + ) + .fetch_one(db_pool) + .await + .map_err(|e| match e { + SqlxError::Database(db_err) if db_err.constraint() == Some("table_scripts_table_definitions_id_target_column_key") => { + Status::already_exists("Script already exists for this column") + } + _ => Status::internal(format!("Database error: {}", e)), + })?; + + Ok(TableScriptResponse { + id: script_record.id, + created_at: Some(script_record.created_at.into()), + warnings, + }) + Ok(TableScriptResponse { + id: script_record.id, + created_at: script_record.created_at.to_string(), + warnings, + }) +} + +fn sanitize_identifier(s: &str) -> String { + s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "") + .trim() + .to_lowercase() +} + +fn compile_rhai_script(script: &str) -> Result<(AST, String), String> { + let mut engine = Engine::new(); + let mut scope = Scope::new(); + + // Add custom API bindings + engine.register_fn("get_column", |_: &str, _: &str| Ok(())); + engine.register_fn("set_result", |_: &mut Scope, _: rhai::Dynamic| Ok(())); + + let ast = engine.compile(script).map_err(|e| e.to_string())?; + + // Validate script structure + if !script.contains("set_result") { + return Err("Script must call set_result()".into()); + } + + let warnings = engine.gen_ast_clear_comments(&ast) + .iter() + .filter_map(|(_, err)| match err { + rhai::ParseError(warn, _) => Some(warn.to_string()), + _ => None + }) + .collect::>() + .join(", "); + + Ok((ast, warnings)) +} diff --git a/server/src/table_script/mod.rs b/server/src/table_script/mod.rs new file mode 100644 index 0000000..95dc60e --- /dev/null +++ b/server/src/table_script/mod.rs @@ -0,0 +1,3 @@ +// src/tables_data/mod.rs + +pub mod handlers;