diff --git a/src/migrations/create.rs b/src/migrations/create.rs index ae51b55c7..833288509 100644 --- a/src/migrations/create.rs +++ b/src/migrations/create.rs @@ -1,6 +1,9 @@ -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::{ + env, + borrow::Cow, + collections::{HashMap, BTreeMap}, + path::{Path, PathBuf}, sync::Arc +}; use anyhow::Context as _; use colorful::Colorful; @@ -60,6 +63,20 @@ enum Choice { Quit, } + +// Example: +// +// "required_user_input": [ +// { +// "prompt": "Please specify a conversion expression to alter the type of property 'strength'", +// "new_type": "std::str", +// "old_type": "std::int64", +// "placeholder": "cast_expr", +// "pointer_name": "strength", +// "new_type_is_object": false, +// "old_type_is_object": false +// } +// ] #[derive(Deserialize, Debug, Clone)] pub struct RequiredUserInput { placeholder: String, @@ -79,6 +96,17 @@ pub struct StatementProposal { pub text: String, } +// Example: +// +// "proposed": { +// "prompt": "did you alter the type of property 'strength' of link 'times'?", +// "data_safe": false, +// "prompt_id": "SetPropertyType PROPERTY default::__|strength@default|__||times&default||Time", +// "confidence": 1.0, +// "statements": [ +// { +// "text": "ALTER TYPE default::Time {\n ALTER LINK times {\n ALTER PROPERTY strength {\n SET TYPE std::str USING (\\(cast_expr));\n };\n };\n};" +// } #[derive(Deserialize, Debug)] pub struct Proposal { pub prompt_id: Option, @@ -90,6 +118,36 @@ pub struct Proposal { pub required_user_input: Vec, } +// Returned from each DESCRIBE CURRENT SCHEMA AS JSON during a migration. +// Example: +// +// { +// "parent": "m17emwiottbbfc4coo7ybcrkljr5bhdv46ouoyyjrsj4qwvg7w5ina", +// "complete": false, +// "proposed": { +// "prompt": "did you alter the type of property 'strength' of link 'times'?", +// "data_safe": false, +// "prompt_id": "SetPropertyType PROPERTY default::__|strength@default|__||times&default||Time", +// "confidence": 1.0, +// "statements": [ +// { +// "text": "ALTER TYPE default::Time {\n ALTER LINK times {\n ALTER PROPERTY strength {\n SET TYPE std::str USING (\\(cast_expr));\n };\n };\n};" +// } +// ], +// "required_user_input": [ +// { +// "prompt": "Please specify a conversion expression to alter the type of property 'strength'", +// "new_type": "std::str", +// "old_type": "std::int64", +// "placeholder": "cast_expr", +// "pointer_name": "strength", +// "new_type_is_object": false, +// "old_type_is_object": false +// } +// ] +// }, +// "confirmed": [] +// } #[derive(Deserialize, Queryable, Debug)] #[edgedb(json)] pub struct CurrentMigration { @@ -128,6 +186,122 @@ struct InteractiveMigration<'a> { confirmed: Vec, } +#[derive(Clone, Debug, Default)] +pub struct InteractiveMigrationInfo { + cast_info: HashMap>, + function_info: Vec, + properties: PropertyInfo +} + +#[derive(Queryable, Debug, Clone, Default)] +pub struct PropertyInfo { + regular_properties: Vec, + link_properties: Vec, +} + +enum PropertyKind { + RegularProperty, + LinkProperty, + BothProperties +} + +impl PropertyInfo { + fn property_check(&self, pointer_name: &str) -> PropertyKind { + if self + .link_properties + .iter() + .any(|l| *l == pointer_name) { + if self + .regular_properties + .iter() + .any(|l| *l == pointer_name) { + PropertyKind::BothProperties + } else { + PropertyKind::LinkProperty + } + } else { + PropertyKind::RegularProperty + } + } +} + +#[derive(Queryable, Debug, Clone)] +pub struct FunctionInfo { + name: String, + input: String, + returns: String +} + +// Returns all functions as long as the type returned input type does not equal output +// (Casts only required when changing to a new type) +async fn get_function_info(cli: &mut Connection) -> Result, Error> { + cli.query::( + r#"with fn := (select schema::Function filter count(.params.type.name) = 1), + select fn { + name, + input := array_join(array_agg((.params.type.name)), ''), + returns := .return_type.name + };"#, + &(), + ) + .await +} + +#[derive(Queryable, Debug, Clone)] +pub struct CastInfo { + from_type_name: String, + to_type_name: String +} + +async fn get_cast_info(cli: &mut Connection) -> Result>, Error> { + let cast_info: Vec = cli + .query( + "select schema::Cast { + from_type_name := .from_type.name, + to_type_name := .to_type.name + };", + &(), + ) + .await?; + let mut map = std::collections::HashMap::new(); + for cast in cast_info { + map.entry(cast.from_type_name) + .or_insert(Vec::new()) + .push(cast.to_type_name); + } + Ok(map) +} + +async fn get_all_property_info(cli: &mut Connection) -> Result { + cli.query_required_single("with + all_props := (select schema::Property filter .builtin = false), + props := (select all_props filter .source is schema::ObjectType and .name != 'id'), + links := (select all_props filter .source is schema::Link and .name not in {'target', 'source'}), + select { regular_properties := props.name, link_properties := links.name };", &()).await +} + +// Don't want to fail CLI if migration info can't be found, just log and return default +async fn get_migration_info(cli: &mut Connection) -> InteractiveMigrationInfo { + let function_info = get_function_info(cli).await.unwrap_or_else(|e| { + log::debug!("Failed to find function info: {e}"); + Default::default() + }); + let cast_info = get_cast_info(cli).await.unwrap_or_else(|e| { + log::debug!("Failed to find cast_info: {e}"); + Default::default() + }); + let properties = get_all_property_info(cli).await.unwrap_or_else(|e| { + log::debug!("Failed to find property info: {e}"); + Default::default() + }); + + InteractiveMigrationInfo { + cast_info, + function_info, + properties + } +} + #[derive(Debug, thiserror::Error)] #[error("refused to input data required for placeholder")] struct Refused; @@ -271,6 +445,7 @@ pub async fn execute_start_migration(ctx: &Context, cli: &mut Connection) -> anyhow::Result<()> { let (text, source_map) = gen_start_migration(&ctx).await?; + match execute(cli, text).await { Ok(_) => Ok(()), Err(e) if e.is::() => { @@ -282,7 +457,7 @@ pub async fn execute_start_migration(ctx: &Context, cli: &mut Connection) } pub async fn first_migration(cli: &mut Connection, ctx: &Context, - options: &CreateMigration) + options: &CreateMigration) -> anyhow::Result { execute_start_migration(&ctx, cli).await?; @@ -351,9 +526,103 @@ pub fn make_default_expression(input: &RequiredUserInput) Some(expr) } -pub async fn unsafe_populate(_ctx: &Context, cli: &mut Connection) - -> anyhow::Result -{ +pub fn make_default_expression_interactive( + input: &RequiredUserInput, + info: Arc, +) -> Option { + let name = &input.placeholder[..]; + let kind_end = name.find("_expr").unwrap_or(name.len()); + let expr = match &name[..kind_end] { + "fill" if input.type_name.is_some() => { + format!("<{}>{{}}", input.type_name.as_ref().unwrap()) + } + "conv" if input.pointer_name.is_some() => { + format!("(SELECT .{} LIMIT 1)", input.pointer_name.as_ref().unwrap()) + } + // Please specify a conversion expression to alter the type of property 'd': + // cast_expr> .d + "cast" if input.pointer_name.is_some() && input.new_type.is_some() => { + let pointer_name = input.pointer_name.as_deref().unwrap(); + // Sometimes types will show up eg. as array for some reason instead of array + let old_type = input + .old_type + .as_deref() + .unwrap_or_default() + .replace('|', "::"); + let new_type = input + .new_type + .as_deref() + .unwrap_or_default() + .replace('|', "::"); + match (input.old_type_is_object, input.new_type_is_object) { + (Some(true), Some(true)) => { + format!(".{pointer_name}[IS {new_type}]") + } + (Some(false), Some(false)) | (None, None) => { + // Check if old type has any casts + match info.cast_info.get(&old_type) { + // and new types match the cast + Some(vals) if vals.iter().any(|t| t == &new_type) => { + // If so, then check if any link and regular properties share a name + match info.properties.property_check(pointer_name) { + PropertyKind::BothProperties => { + println!(" Note: Change .{pointer_name} to @{pointer_name} if `{pointer_name}` refers to a link property."); + format!("<{new_type}>.{pointer_name}") + } + PropertyKind::LinkProperty => { + format!("<{new_type}>@{pointer_name}") + } + PropertyKind::RegularProperty => { + format!("<{new_type}>.{pointer_name}") + } + } + } + _ => { + // No matching casts between old and new type. Now try to print out any matching functions + let available_functions = info.function_info.iter().filter(|func| { + func.input == old_type && func.returns == new_type + }).collect::>(); + if !available_functions.is_empty() { + println!(" Note: The following function{plural} may help you convert from {old_type} to {new_type}:", plural = if available_functions.len() > 2 {"s"} else {""}); + for function in available_functions { + let FunctionInfo { + name, + input, + returns, + } = function; + println!(" {name}({input}) -> {returns}"); + } + } + // Then return the pointer (maybe with matching functions, maybe not) + match info.properties.property_check(pointer_name) { + PropertyKind::BothProperties => { + println!(" Note: Change .{pointer_name} to @{pointer_name} if `{pointer_name}` refers to a link property."); + format!(".{pointer_name}") + } + PropertyKind::LinkProperty => { + format!("@{pointer_name}") + } + PropertyKind::RegularProperty => { + format!(".{pointer_name}") + } + } + } + } + } + // TODO(tailhook) maybe create something for mixed case? + _ => return None, + } + } + _ => { + return None; + } + }; + Some(expr) +} + + +pub async fn unsafe_populate(_ctx: &Context, cli: &mut Connection) -> anyhow::Result { + loop { let data = query_row::(cli, "DESCRIBE CURRENT MIGRATION AS JSON" @@ -449,20 +718,20 @@ async fn non_interactive_populate(_ctx: &Context, cli: &mut Connection) eprintln!("EdgeDB intended to apply the following migration:"); for statement in proposal.statements { for line in statement.text.lines() { - eprintln!(" {}", line); + eprintln!(" {line}"); } } eprintln!("But confidence is {}, below minimum threshold of {}", proposal.confidence, SAFE_CONFIDENCE); anyhow::bail!("EdgeDB is unable to make a decision. Please run in \ interactive mode to confirm changes, \ - or use `--allow-unsafe`"); + or use `--allow-unsafe` to automatically force all suggested changes"); } } else { anyhow::bail!("EdgeDB could not resolve \ migration automatically. Please run in \ interactive mode to confirm changes, \ - or use `--allow-unsafe`"); + or use `--allow-unsafe` to automatically force all suggested changes"); } } } @@ -484,14 +753,15 @@ async fn run_non_interactive(ctx: &Context, cli: &mut Connection, Ok(FutureMigration::new(key, descr)) } -impl InteractiveMigration<'_> { - fn new(cli: &mut Connection) -> InteractiveMigration { - InteractiveMigration { +impl<'a> InteractiveMigration<'a> { + async fn new(cli: &'a mut Connection) -> Result { + + Ok(InteractiveMigration { cli, save_point: 0, operations: vec![Set::new()], confirmed: Vec::new(), - } + }) } async fn save_point(&mut self) -> Result<(), Error> { execute(self.cli, @@ -503,9 +773,9 @@ impl InteractiveMigration<'_> { "ROLLBACK TO SAVEPOINT migration_{}", self.save_point) ).await } - async fn run(mut self) -> anyhow::Result { + async fn run(mut self, info: Arc) -> anyhow::Result { self.save_point().await?; - loop { + loop { let descr = query_row::(self.cli, "DESCRIBE CURRENT MIGRATION AS JSON", ).await?; @@ -514,7 +784,7 @@ impl InteractiveMigration<'_> { return Ok(descr); } if let Some(proposal) = &descr.proposed { - match self.process_proposal(proposal).await { + match self.process_proposal(proposal, Arc::clone(&info)).await { Err(e) if e.is::() => return Ok(descr), Err(e) => return Err(e), Ok(()) => {} @@ -524,9 +794,7 @@ impl InteractiveMigration<'_> { } } } - async fn process_proposal(&mut self, proposal: &Proposal) - -> anyhow::Result<()> - { + async fn process_proposal(&mut self, proposal: &Proposal, info: Arc) -> anyhow::Result<()> { use Choice::*; let cur_oper = self.operations.last().unwrap(); @@ -544,7 +812,7 @@ impl InteractiveMigration<'_> { } println!("(approved as part of an earlier prompt)"); let input = self.cli.ping_while( - get_user_input(&proposal.required_user_input) + get_user_input(&proposal.required_user_input, Arc::clone(&info)) ).await; match input { Ok(data) => break data, @@ -567,7 +835,7 @@ impl InteractiveMigration<'_> { match self.cli.ping_while(choice(prompt)).await? { Yes => { let input_res = self.cli.ping_while( - get_user_input(&proposal.required_user_input) + get_user_input(&proposal.required_user_input, Arc::clone(&info)) ).await; match input_res { Ok(data) => input = data, @@ -577,9 +845,7 @@ impl InteractiveMigration<'_> { break; } No => { - execute(self.cli, - "ALTER CURRENT MIGRATION REJECT PROPOSED" - ).await?; + execute(self.cli, "ALTER CURRENT MIGRATION REJECT PROPOSED").await?; self.save_point += 1; self.save_point().await?; return Ok(()); @@ -674,10 +940,11 @@ impl InteractiveMigration<'_> { async fn run_interactive(_ctx: &Context, cli: &mut Connection, - key: MigrationKey, options: &CreateMigration) + key: MigrationKey, options: &CreateMigration, info: Arc) -> anyhow::Result { - let descr = InteractiveMigration::new(cli).run().await?; + + let descr = InteractiveMigration::new(cli).await?.run(info).await?; if descr.confirmed.is_empty() && !options.allow_empty { print::warn("No schema changes detected."); @@ -694,11 +961,12 @@ pub async fn write_migration(ctx: &Context, descr: &impl MigrationToText, let filename = match &descr.key() { MigrationKey::Index(idx) => { let dir = ctx.schema_dir.join("migrations"); - dir.join(format!("{:05}.edgeql", idx)) + dir.join(format!("{idx:05}.edgeql")) } MigrationKey::Fixup { target_revision } => { let dir = ctx.schema_dir.join("fixups"); - dir.join(format!("{}-{}.edgeql", descr.parent()?, target_revision)) + let parent = descr.parent()?; + dir.join(format!("{parent}-{target_revision}.edgeql")) } }; _write_migration(descr, filename.as_ref(), verbose).await @@ -804,6 +1072,7 @@ pub async fn normal_migration(cli: &mut Connection, ctx: &Context, create: &CreateMigration) -> anyhow::Result { +let info = Arc::new(get_migration_info(cli).await); execute_start_migration(&ctx, cli).await?; async_try! { async { @@ -828,7 +1097,7 @@ pub async fn normal_migration(cli: &mut Connection, ctx: &Context, log::warn!("The `--allow-unsafe` flag is unused \ in interactive mode"); } - run_interactive(&ctx, cli, key, &create).await + run_interactive(&ctx, cli, key, &create, info).await } }, finally async { @@ -854,9 +1123,9 @@ fn add_newline_after_comment(value: &mut String) -> Result<(), anyhow::Error> { Ok(()) } -fn get_input(req: &RequiredUserInput) -> Result { +fn get_input(req: &RequiredUserInput, info: Arc) -> Result { let prompt = format!("{}> ", req.placeholder); - let mut prev = make_default_expression(req).unwrap_or(String::new()); + let mut prev = make_default_expression_interactive(req, info).unwrap_or_default(); loop { println!("{}:", req.prompt); let mut value = match prompt::expression(&prompt, &req.placeholder, &prev) { @@ -880,13 +1149,14 @@ fn get_input(req: &RequiredUserInput) -> Result { } } -async fn get_user_input(req: &[RequiredUserInput]) +async fn get_user_input(req: &[RequiredUserInput], info: Arc) -> Result, anyhow::Error> { let mut result = BTreeMap::new(); for item in req { let copy = item.clone(); - let input = unblock(move || get_input(©)).await??; + let info = Arc::clone(&info); + let input = unblock(move || get_input(©, info)).await??; result.insert(item.placeholder.clone(), input); } Ok(result)