diff --git a/src/main.rs b/src/main.rs index 9a1ef57..5853104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ static DATATYPE_HELP: &str = "A datatype name"; static BUILD_ERROR: &str = "Error building Valve"; -static RECONFIGURE_ERROR: &str = "Could not reconfigure Valve"; +static REBUILD_ERROR: &str = "Could not rebuild Valve"; #[derive(Parser)] #[command(version, @@ -177,7 +177,7 @@ enum Commands { context: usize, }, - /// TODO: Add docstring + /// Rename table, rows, and datatypes Rename { #[command(subcommand)] subcommand: RenameSubcommands, @@ -374,16 +374,16 @@ enum AddSubcommands { seed: Option, }, - /// TODO: Add docstring + /// Add a column to a database table Column { #[arg(value_name = "TABLE", action = ArgAction::Set, help = TABLE_HELP)] - table: String, + table: Option, #[arg(value_name = "COLUMN", action = ArgAction::Set, help = COLUMN_HELP)] - column: String, + column: Option, }, - /// TODO: Add docstring + /// Add a datatype to the datatype table Datatype { #[arg(value_name = "DATATYPE", action = ArgAction::Set, help = DATATYPE_HELP)] datatype: String, @@ -445,7 +445,7 @@ enum UpdateSubcommands { #[derive(Subcommand)] enum MoveSubcommands { - /// TODO: Add docstring. + /// Move a row within a given database table Row { #[arg(value_name = "TABLE", action = ArgAction::Set, help = TABLE_HELP)] table: String, @@ -496,13 +496,13 @@ enum DeleteSubcommands { rule: Option, }, - /// TODO: Add docstring + /// Delete a table Table { #[arg(value_name = "TABLE", action = ArgAction::Set, help = TABLE_HELP)] table: String, }, - /// TODO: Add docstring + /// Delete a column from a given table Column { #[arg(value_name = "TABLE", action = ArgAction::Set, help = TABLE_HELP)] table: String, @@ -511,7 +511,7 @@ enum DeleteSubcommands { column: String, }, - /// TODO: Add docstring + /// Remove a datatype Datatype { #[arg(value_name = "DATATYPE", action = ArgAction::Set, help = DATATYPE_HELP)] datatype: String, @@ -629,22 +629,26 @@ async fn main() -> Result<()> { .expect("Can't convert to simple JSON") } - // Reads an intput row from STDIN. This should be formatted as a simple JSON, optionally with + fn read_json_input() -> JsonRow { + let mut json_row = String::new(); + io::stdin() + .read_line(&mut json_row) + .expect("Error reading from STDIN"); + let json_row = serde_json::from_str::(&json_row) + .expect(&format!("Invalid JSON: {json_row}")) + .as_object() + .expect(&format!("{json_row} is not a JSON object")) + .clone(); + json_row + } + + // Reads a row from STDIN. This should be formatted as a simple JSON, optionally with // a "row_number" field included. Returns the row number (if any) that was included in the - // JSON, as well as a JSON representation of the row without the row_number field included. - async fn input_row() -> (Option, JsonRow) { - let mut json_row = { - let mut json_row = String::new(); - io::stdin() - .read_line(&mut json_row) - .expect("Error reading from STDIN"); - let json_row = serde_json::from_str::(&json_row) - .expect(&format!("Invalid JSON: {json_row}")) - .as_object() - .expect(&format!("{json_row} is not a JSON object")) - .clone(); - json_row - }; + // JSON, as well as a JSON representation of the row without the row_number field included. + fn read_row_input() -> (Option, JsonRow) { + let mut json_row = read_json_input(); + + // TODO: Handle unrecognized columns. // If the input row contains a row number as one of its cells, remove it from the JSON // and return it separately as the first position of the returned tuple. Otherwise the @@ -662,47 +666,144 @@ async fn main() -> Result<()> { (row_number, json_row) } + /// TODO: Add docstring + fn read_column_input(table_param: &Option, column_param: &Option) -> JsonRow { + // TODO: Handle unrecognized fields in the JSON. + + let json_row = read_json_input(); + // Mandatory fields that may optionally be provided as arguments to this function: + let table = match json_row.get("table") { + Some(input_table) => match table_param { + Some(table_param) if table_param != input_table => { + panic!("Mismatch between input table and positional parameter, TABLE") + } + None | Some(_) => input_table.clone(), + }, + None => match table_param { + Some(table_param) => json!(table_param), + None => panic!("No table given"), + }, + }; + let column = match json_row.get("column") { + Some(input_column) => match column_param { + Some(column_param) if column_param != input_column => { + panic!("Mismatch between input column and positional parameter, COLUMN") + } + None | Some(_) => input_column.clone(), + }, + None => match column_param { + Some(column_param) => json!(column_param), + None => panic!("No column given"), + }, + }; + + // Mandatory field: + let datatype = match json_row.get("datatype") { + None => panic!("No datatype given"), + Some(datatype) => datatype.clone(), + }; + + // Optional fields: + let label = match json_row.get("label") { + None => SerdeValue::Null, + Some(label) => match label { + SerdeValue::String(_) => label.clone(), + _ => panic!("Field 'label' is not a string"), + }, + }; + let nulltype = match json_row.get("nulltype") { + None => SerdeValue::Null, + Some(nulltype) => match nulltype { + SerdeValue::String(_) => nulltype.clone(), + _ => panic!("Field 'nulltype' is not a string"), + }, + }; + let structure = match json_row.get("structure") { + None => SerdeValue::Null, + Some(structure) => match structure { + SerdeValue::String(_) => structure.clone(), + _ => panic!("Field 'structure' is not a string"), + }, + }; + let description = match json_row.get("description") { + None => SerdeValue::Null, + Some(description) => match description { + SerdeValue::String(_) => description.clone(), + _ => panic!("Field 'description' is not a string"), + }, + }; + + let mut column_config = JsonRow::new(); + column_config.insert("table".to_string(), table); + column_config.insert("column".to_string(), column); + column_config.insert("datatype".to_string(), datatype); + if label != SerdeValue::Null { + column_config.insert("label".to_string(), label); + } + if nulltype != SerdeValue::Null { + column_config.insert("nulltype".to_string(), nulltype); + } + if structure != SerdeValue::Null { + column_config.insert("structure".to_string(), structure); + } + if description != SerdeValue::Null { + column_config.insert("description".to_string(), description); + } + + column_config + } + // Reads and parses a JSON-formatted string representing a validation message (for the expected // format see, e.g., AddSubcommands::Message above), and returns the tuple: // (table, row, column, value, level, rule, message) - fn read_input_message( - table: &Option, - row: &Option, - column: &Option, + fn read_message_input( + table_param: &Option, + row_param: &Option, + column_param: &Option, ) -> (String, u32, String, String, String, String, String) { - let mut json_row = String::new(); - io::stdin() - .read_line(&mut json_row) - .expect("Error reading from STDIN"); - let json_row = serde_json::from_str::(&json_row) - .expect(&format!("Invalid JSON: {json_row}")) - .as_object() - .expect(&format!("{json_row} is not a JSON object")) - .clone(); + // TODO: Handle unrecognized fields in the JSON. + + let json_row = read_json_input(); let table = match json_row.get("table") { - Some(table) => table.as_str().expect("Not a string").to_string(), - None => match table { - Some(table) => table.to_string(), + Some(input_table) => match table_param { + Some(table_param) if table_param != input_table => { + panic!("Mismatch between input table and positional parameter, TABLE") + } + None | Some(_) => input_table.as_str().expect("Not a string").to_string(), + }, + None => match table_param { + Some(table_param) => table_param.to_string(), None => panic!("No table given"), }, }; let row = match json_row.get("row") { - Some(rn) => { - let rn = rn.as_i64().expect("Not a number"); - rn as u32 - } - None => match row { - Some(rn) => rn.clone(), + Some(input_row) => match row_param { + Some(row_param) if row_param != input_row => { + panic!("Mismatch between input row and positional parameter, ROW") + } + None | Some(_) => { + let input_row = input_row.as_i64().expect("Not a number"); + input_row as u32 + } + }, + None => match row_param { + Some(row_param) => *row_param, None => panic!("No row given"), }, }; let column = match json_row.get("column") { - Some(column) => column.as_str().expect("Not a string").to_string(), - None => match column { - Some(column) => column.to_string(), + Some(input_column) => match column_param { + Some(column_param) if column_param != input_column => { + panic!("Mismatch between input column and positional parameter, COLUMN") + } + None | Some(_) => input_column.as_str().expect("Not a string").to_string(), + }, + None => match column_param { + Some(column_param) => column_param.to_string(), None => panic!("No column given"), }, }; + let value = match json_row.get("value") { Some(value) => value.as_str().expect("Not a string").to_string(), None => panic!("No value given"), @@ -726,11 +827,17 @@ async fn main() -> Result<()> { match &cli.command { Commands::Add { subcommand } => { match subcommand { - AddSubcommands::Column { .. } => todo!(), + AddSubcommands::Column { table, column } => { + let column_json = read_column_input(table, column); + let mut valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); + valve + .add_column(table, column, &column_json, !cli.assume_yes) + .await?; + } AddSubcommands::Datatype { .. } => todo!(), AddSubcommands::Message { table, row, column } => { let (table, row, column, value, level, rule, message) = - read_input_message(table, row, column); + read_message_input(table, row, column); let valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); let message_id = valve .insert_message(&table, row, &column, &value, &level, &rule, &message) @@ -738,18 +845,10 @@ async fn main() -> Result<()> { println!("{message_id}"); } AddSubcommands::Row { table } => { - let mut row = String::new(); - io::stdin() - .read_line(&mut row) - .expect("Error reading from STDIN"); - let row: SerdeValue = - serde_json::from_str(&row).expect(&format!("Invalid JSON: {row}")); - let row = row - .as_object() - .expect(&format!("{row} is not a JSON object")); + let (_, row) = read_row_input(); let valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); let (_, row) = valve - .insert_row(table, row) + .insert_row(table, &row) .await .expect("Error inserting row"); if cli.verbose { @@ -783,7 +882,7 @@ async fn main() -> Result<()> { ); if table_added { valve.save_tables(&vec!["table", "column"], &None).await?; - valve.reconfigure().expect(RECONFIGURE_ERROR); + valve.rebuild().expect(REBUILD_ERROR); valve.load_tables(&vec!["table", "column"], true).await?; valve.ensure_all_tables_created().await?; // TODO: Ask the user if they want to load the table now. If assume_yes @@ -1239,22 +1338,6 @@ async fn main() -> Result<()> { Commands::SaveAs { table, path } => { let valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); valve.save_table(table, path).await?; - // TODO: DON'T NEED TO DO THE STUFF BELOW. REMOVE IT: - /* - let sql = local_sql_syntax( - &valve.db_kind, - &format!(r#"UPDATE "table" SET "path" = {SQL_PARAM} WHERE "table" = {SQL_PARAM}"#), - ); - let mut query = sqlx_query(&sql); - query = query.bind(path); - query = query.bind(table); - query.execute(&valve.pool).await?; - valve.save_tables(&vec!["table"], &None).await?; - valve.save_tables(&vec![table], &None).await?; - valve.reconfigure().expect(RECONFIGURE_ERROR); - valve.load_tables(&vec!["table"], true).await?; - valve.ensure_all_tables_created().await?; - */ } Commands::TestApi {} => { let valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); @@ -1276,7 +1359,7 @@ async fn main() -> Result<()> { } = subcommand { let (table, row, column, value, level, rule, message) = - read_input_message(table, row, column); + read_message_input(table, row, column); let valve = build_valve(&cli.source, &cli.database).expect(BUILD_ERROR); valve .update_message( @@ -1293,12 +1376,20 @@ async fn main() -> Result<()> { } else { let (table, row_number, input_row) = match subcommand { UpdateSubcommands::Row { table, row } => { - let (json_row_number, json_row) = input_row().await; - let row = match json_row_number { - None => row.clone(), - Some(value) => Some(value), + let (input_rn, json_row) = read_row_input(); + let row = match input_rn { + Some(input_rn) => match row { + Some(row) if *row != input_rn => panic!( + "Mismatch between input row and positional parameter, ROW" + ), + None | Some(_) => input_rn, + }, + None => match row { + Some(row) => *row, + None => panic!("No row given"), + }, }; - (table, row, json_row) + (table, Some(row), json_row) } UpdateSubcommands::Value { table, @@ -1349,10 +1440,19 @@ async fn main() -> Result<()> { (Some(rn), input_row) } None => { - let (rn, input_row) = input_row().await; + let (rn, input_row) = read_row_input(); // If now row was input, default to `row` (which could still be None) let rn = match rn { - Some(rn) => Some(rn), + Some(rn) => { + if let Some(row) = row { + if *row != rn { + panic!( + "Mismatch between input row and positional parameter, ROW" + ) + } + } + Some(rn) + } None => *row, }; (rn, input_row) diff --git a/src/toolkit.rs b/src/toolkit.rs index d29ce0d..84f5ad4 100644 --- a/src/toolkit.rs +++ b/src/toolkit.rs @@ -918,8 +918,7 @@ pub fn read_config_files( for foreign in table_foreigns { let ftable = &foreign.ftable; let funiques = constraints_config.unique.get_mut(ftable).expect(&format!( - "No unique constraints found for table '{}'", - ftable + "No unique constraints found for table '{ftable}' (or '{ftable}' does not exist)", )); let fprimaries = constraints_config.primary.get(ftable).expect(&format!( "No primary constraints found for table '{}'", @@ -2977,6 +2976,44 @@ pub async fn get_previous_row_tx( } } +/// TODO: Add docstring +pub async fn get_next_new_row_tx( + db_kind: &DbKind, + tx: &mut Transaction<'_, sqlx::Any>, + table: &str, +) -> Result<(u32, u32)> { + let sql = local_sql_syntax( + db_kind, + &format!( + r#"SELECT MAX("row_number") AS "row_number" FROM ( + SELECT MAX("row_number") AS "row_number" + FROM "{table}_view" + UNION ALL + SELECT MAX("row") AS "row_number" + FROM "history" + WHERE "table" = {SQL_PARAM} + ) t"#, + ), + ); + let query = sqlx_query(&sql).bind(table); + let result_rows = query.fetch_all(tx.acquire().await?).await?; + let new_row_number: i64; + if result_rows.len() == 0 { + new_row_number = 1; + } else { + let result_row = &result_rows[0]; + let result = result_row.try_get_raw("row_number")?; + if result.is_null() { + new_row_number = 1; + } else { + new_row_number = result_row.get("row_number"); + } + } + let new_row_number = new_row_number as u32 + 1; + let new_row_order = new_row_number * MOVE_INTERVAL; + Ok((new_row_number, new_row_order)) +} + /// Given a table name, a row number, the new row order to assign to the row, and a database /// transaction, update the row order for the row in the database. Note that the row_order is /// represented using the signed i64 type but it can never actually be negative. This function @@ -3141,37 +3178,10 @@ pub async fn insert_new_row_tx( // Now prepare the row and messages for insertion to the database. let new_row_number = match row.row_number { - Some(n) => n, + Some(rn) => rn, None => { - let sql = local_sql_syntax( - &DbKind::from_pool(pool)?, - &format!( - r#"SELECT MAX("row_number") AS "row_number" FROM ( - SELECT MAX("row_number") AS "row_number" - FROM "{table}_view" - UNION ALL - SELECT MAX("row") AS "row_number" - FROM "history" - WHERE "table" = {SQL_PARAM} - ) t"#, - ), - ); - let query = sqlx_query(&sql).bind(table); - let result_rows = query.fetch_all(tx.acquire().await?).await?; - let new_row_number: i64; - if result_rows.len() == 0 { - new_row_number = 1; - } else { - let result_row = &result_rows[0]; - let result = result_row.try_get_raw("row_number")?; - if result.is_null() { - new_row_number = 1; - } else { - new_row_number = result_row.get("row_number"); - } - } - let new_row_number = new_row_number as u32 + 1; - new_row_number + let (rn, _) = get_next_new_row_tx(&DbKind::from_pool(pool)?, tx, table).await?; + rn } }; diff --git a/src/valve.rs b/src/valve.rs index 9f7287a..6dff50d 100644 --- a/src/valve.rs +++ b/src/valve.rs @@ -8,9 +8,9 @@ use crate::{ add_message_counts, cast_column_sql_to_text, correct_row_datatypes, delete_row_tx, generate_datatype_conditions, generate_rule_conditions, get_column_for_label, get_column_value_as_string, get_db_records_to_redo, get_db_records_to_undo, - get_json_array_from_column, get_json_object_from_column, get_next_undo_id, - get_parsed_structure_conditions, get_pool_from_connection_string, get_previous_row_tx, - get_sql_for_standard_view, get_sql_for_text_view, get_sql_type, + get_json_array_from_column, get_json_object_from_column, get_next_new_row_tx, + get_next_undo_id, get_parsed_structure_conditions, get_pool_from_connection_string, + get_previous_row_tx, get_sql_for_standard_view, get_sql_for_text_view, get_sql_type, get_sql_type_from_global_config, get_text_row_from_db_tx, insert_chunks, insert_new_row_tx, local_sql_syntax, move_row_tx, normalize_options, read_config_files, record_row_change_tx, record_row_move_tx, switch_undone_state_tx, undo_or_redo_move_tx, update_row_tx, @@ -899,7 +899,7 @@ impl Valve { } /// TODO: Add docstring here - pub fn reconfigure(&mut self) -> Result<()> { + pub fn rebuild(&mut self) -> Result<()> { let table_path = self.get_path()?; let parser = StartParser::new(); let ( @@ -1017,6 +1017,99 @@ impl Valve { self } + /// TODO: Add docstring + pub async fn add_column( + &mut self, + table: &Option, + column: &Option, + column_details: &JsonRow, + _interactive: bool, + ) -> Result<()> { + let make_err = + |err_str: &str| -> ValveError { ValveError::InputError(err_str.to_string()) }; + + // Extract the required and optional fields from the column_details JSON: + let table = match column_details.get("table") { + Some(input_table) => match table { + Some(table) if table != input_table => { + return Err(make_err( + "Mismatch between input table and positional parameter, TABLE", + ) + .into()) + } + None | Some(_) => input_table.as_str().ok_or(make_err("Not a string"))?, + }, + None => match table { + Some(table) => table, + None => return Err(make_err("No table given").into()), + }, + }; + let column = match column_details.get("column") { + Some(input_column) => match column { + Some(column) if column != input_column => { + return Err(make_err( + "Mismatch between input column and positional parameter, COLUMN", + ) + .into()) + } + None | Some(_) => input_column.as_str().ok_or(make_err("Not a string"))?, + }, + None => match column { + Some(column) => column, + None => return Err(make_err("No column given").into()), + }, + }; + let datatype = match column_details.get("datatype") { + Some(datatype) => datatype.as_str().ok_or(make_err("Not a string"))?, + None => return Err(make_err("No datatype given").into()), + }; + let label = column_details.get("label").and_then(|l| l.as_str()); + let nulltype = column_details.get("nulltype").and_then(|l| l.as_str()); + let structure = column_details.get("structure").and_then(|l| l.as_str()); + let description = column_details.get("description").and_then(|l| l.as_str()); + + // Generate an insert statement and execute it: + let mut fields = vec![r#""table""#, r#""column""#, r#""datatype""#]; + let mut placeholders = vec![SQL_PARAM, SQL_PARAM, SQL_PARAM]; + let mut field_params = vec![table, column, datatype]; + for (field, field_param) in [ + (r#""label""#, label), + (r#""nulltype""#, nulltype), + (r#""structure""#, structure), + (r#""description""#, description), + ] { + if let Some(param) = field_param { + fields.push(field); + placeholders.push(SQL_PARAM); + field_params.push(param); + } + } + let (rn, ro) = + get_next_new_row_tx(&self.db_kind, &mut self.pool.begin().await?, "column").await?; + let sql = local_sql_syntax( + &self.db_kind, + &format!( + r#"INSERT INTO "column" ("row_number", "row_order", {fields}) + VALUES ({rn}, {ro}, {placeholders})"#, + fields = fields.join(", "), + placeholders = placeholders.join(", ") + ), + ); + let mut query = sqlx_query(&sql); + for param in &field_params { + query = query.bind(param); + } + query.execute(&self.pool).await?; + + // Save the column table and then rebuild valve: + self.save_tables(&vec!["column", table], &None).await?; + self.rebuild()?; + + // Load the newly modified table: + self.load_tables(&vec![table], true).await?; + Ok(()) + } + /// (Private function.) Given a SQL string, execute it using the connection pool associated /// with the Valve instance. async fn execute_sql(&self, sql: &str) -> Result<()> { @@ -1845,47 +1938,6 @@ impl Valve { Ok(output) } - /// Drop and recreate the tables in the given list - pub async fn recreate_tables(&self, tables: &Vec<&str>) -> Result<&Self> { - println!("Entering recreate_tables()"); - let setup_statements = self.get_setup_statements().await?; - for table in tables { - let table_config = self.get_table_config(table)?; - if table_config.options.contains("db_view") { - // See the comment at a similar point in ensure_all_tables_created() for an - // explanation of this logic. - if table_config.path.to_lowercase().ends_with(".sql") { - self.execute_sql_file(&table_config.path).await?; - } else if table_config.path != "" { - self.execute_script(&table_config.path, &vec![&self.db_path, table])?; - } else { - log::warn!("View '{table}' has an empty path. Doing nothing."); - } - // Check to make sure that the view now exists: - if !self.view_exists(table).await? { - return Err(ValveError::DataError(format!( - "No view named '{}' exists in the database", - table - )) - .into()); - } - } else { - self.drop_tables(&vec![table]).await?; - let table_statements = - setup_statements - .get(*table) - .ok_or(ValveError::ConfigError(format!( - "Could not find setup statements for {}", - table - )))?; - for stmt in table_statements { - self.execute_sql(stmt).await?; - } - } - } - Ok(self) - } - /// Create all configured database tables and views if they do not already exist as configured. pub async fn ensure_all_tables_created(&self) -> Result<&Self> { let setup_statements = self.get_setup_statements().await?; @@ -2613,18 +2665,6 @@ impl Valve { } } - // Construct the query to use to retrieve the data: - let query_table = format!("\"{}_text_view\"", table); - let sql = format!( - r#"SELECT "row_number", {} FROM {} ORDER BY "row_order""#, - columns - .keys() - .map(|c| format!(r#""{}""#, c)) - .collect::>() - .join(", "), - query_table - ); - // Query the database and use the results to construct the records that will be written // to the TSV file: let format_regex = Regex::new(PRINTF_RE)?; @@ -2637,11 +2677,40 @@ impl Valve { .map(|(_, (label, _))| label) .collect::>(); writer.write_record(tsv_header_row)?; + + let existing_columns = { + let mut existing_columns = vec![]; + for column in columns.keys() { + if self.column_enabled_in_db(table, column).await? { + existing_columns.push(column); + } + } + existing_columns + }; + + // Construct the query to use to retrieve the data: + let query_table = format!("\"{}_text_view\"", table); + let sql = format!( + r#"SELECT "row_number", {} FROM {} ORDER BY "row_order""#, + existing_columns + .iter() + .map(|c| format!(r#""{}""#, c)) + .collect::>() + .join(", "), + query_table + ); + let mut stream = sqlx_query(&sql).fetch(&self.pool); while let Some(row) = stream.try_next().await? { let mut record: Vec = vec![]; for (column, (_, colformat)) in &columns { - let cell = row.try_get::<&str, &str>(column).ok().unwrap_or_default(); + let cell = { + if existing_columns.contains(&column) { + row.try_get::<&str, &str>(column).ok().unwrap_or_default() + } else { + "" + } + }; if *colformat != "" { let formatted_cell = format_cell(&colformat, &format_regex, &cell); record.push(formatted_cell.to_string()); @@ -2744,6 +2813,12 @@ impl Valve { } } + /// TODO: Add doctring + pub async fn get_next_new_row(&self, table: &str) -> Result<(u32, u32)> { + let mut tx = self.pool.begin().await?; + get_next_new_row_tx(&self.db_kind, &mut tx, table).await + } + /// Given a table name and a row number, return a [ValveRow] representing that row. pub async fn get_row_from_db(&self, table: &str, row_number: &u32) -> Result { let mut tx = self.pool.begin().await?;