From 239fbb3f2e709e237133e2ad05564937354f1cee Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Tue, 15 Sep 2026 10:33:41 +0100 Subject: [PATCH 1/3] =?UTF-8?q?Run=20`spacetime=20describe=20mydb`=20today?= =?UTF-8?q?=20and=20it=20refuses:=20`--json`=20is=20a=20required=20flag,?= =?UTF-8?q?=20and=20its=20help=20text=20promises=20that=20"in=20the=20futu?= =?UTF-8?q?re,=20omitting=20this=20will=20give=20human-readable=20output".?= =?UTF-8?q?=20This=20patch=20brings=20that=20glorious=20future.=20?= =?UTF-8?q?=F0=9F=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spacetime describe [db]` now prints the module as readable text, in the sections Tables, Views, Reducers, Procedures, and Types: ``` page_previews (public) Columns: title String primary key page_id U64 rev_id U64 display_title String description Option extract String thumbnail Option fetched_at Timestamp Indexes: page_previews_title_idx_btree btree (title) ``` You can narrow it to every entity of one kind, or to one entity by name. The entity types are `tables`, `reducers`, `procedures` and `types`, matching the section headings: ``` $ spacetime describe wikiwatch procedures fetch_edits(timer: FetchEditsSchedule) [private] fetch_previews(timer: FetchPreviewsSchedule) [private] $ spacetime describe wikiwatch types Thumbnail Thumbnail = { url: String, width: U32, height: U32 } ``` The singular forms (`table person`) still work, since that's what the JSON-only command accepted. `--format json`, or the existing `--json` shorthand, prints the raw definitions instead: the whole module, an array for a listing, or a single def. Text is styled like the migration plan that `spacetime publish` prints, and is coloured only when stdout is a terminal and `NO_COLOR` is unset. The renderer is a new `describe` module in the schema crate, next to the migration printer whose look it shares. - `StyledWriter`: the colour scheme, colour/no-colour buffer and indent helpers move out of `TermColorFormatter` into a `pub(crate)` writer that both printers use. Migration output is unchanged, and its snapshots pass untouched. - `type_name` spells types language-neutrally: `U64`, `Array`, `Option`, `Timestamp`, and named types by their scoped name joined with `.`. It reads the "for generate" typespace, which keeps special types and refs intact. - Field and variant names come from the case-converted typespace, because `typespace_for_generate` keeps source names (`imageUrl`, not `image_url`). Column defaults are formatted through `WithTypespace`, so a sum prints as `(red = ())` rather than `( = ())`. - Reducer names arrive already qualified with their submodule (`lib.end_session`), while table, view, procedure and type names are local and have the prefix added. Prefixing reducers too would print `lib.lib.end_session`, and a test pins that. - Types lists only named types reachable from a column or a signature, and leaves out table row types, which their table's block already shows. `types ` searches every named type, so a row type seen in a signature (`FetchEditsSchedule`) can still be looked up. - Each listing (`describe_tables` and friends) shares its sorted source with the whole-module renderer, and tests pin each one to its module section. - CLI: `--format text|json` defaults to `text`, using a `Format` enum now shared with `sql` and `logs` in `common_args`. `--json` conflicts with an explicit `--format`. Whole-module JSON is still the raw, unvalidated def, returned before validation, so a module that fails validation can still be dumped for debugging. Existing JSON output is byte-for-byte unchanged. - Tests: insta snapshots of a fixture covering every section, submodules, indexes, constraints, defaults and schedules. The `describe` smoketest now checks the text output, exact single-entity output, `--json` against `--format json`, and the flag conflict. - Docs: the regenerated CLI reference, the cheat sheet, and the CLI agent skill, plus its codex-plugin copy, which must match byte for byte. - Known gap: inside a submodule table, a column whose type is defined in that submodule prints the bare type name, while Types prefixes it with `lib.`. # API and ABI breaking changes None. `spacetime describe ` without `--json` used to be an error, so no existing invocation changes meaning, and JSON output is byte-for-byte unchanged. `--json` combined with an explicit `--format` is now rejected as a conflict, but `--format` is new to this command. # Rollback safety impact n/a # Expected complexity level and risk 2. The diff is large, but most of it is the new, self-contained renderer and its snapshots. The parts that touch existing code are the `StyledWriter` extraction from the migration printer (its snapshots pass untouched) and the shared `Format` enum now used by `sql` and `logs`. # Testing - [x] Insta snapshots for the renderer: whole module (colour and no colour), a single table, a single reducer. - [x] Unit tests pinning each listing to its module section, and submodule reducer names not being double-prefixed. - [x] `describe` smoketest covers text output, exact single-entity output, `--json` vs `--format json`, and the flag conflict. - [ ] Reviewer: run `spacetime describe` against a real module of your own and check the output reads well. --- .../plugins/spacetimedb/skills/cli/SKILL.md | 6 +- crates/cli/src/common_args.rs | 28 + crates/cli/src/subcommands/describe.rs | 364 +++- crates/cli/src/subcommands/logs.rs | 28 +- crates/cli/src/subcommands/repl.rs | 3 +- crates/cli/src/subcommands/sql.rs | 29 +- crates/schema/src/auto_migrate.rs | 18 +- .../src/auto_migrate/termcolor_formatter.rs | 414 ++--- crates/schema/src/describe.rs | 1538 +++++++++++++++++ crates/schema/src/lib.rs | 2 + ...describe__tests__describe_module_ansi.snap | 82 + ...ribe__tests__describe_module_no_color.snap | 82 + ...ibe__tests__describe_reducer_no_color.snap | 5 + ...cribe__tests__describe_table_no_color.snap | 18 + crates/schema/src/styled_writer.rs | 126 ++ crates/smoketests/src/lib.rs | 2 +- crates/smoketests/tests/cluster/describe.rs | 79 +- .../00100-databases/00500-cheat-sheet.md | 3 +- .../00100-cli-reference.md | 12 +- skills/cli/SKILL.md | 6 +- 20 files changed, 2442 insertions(+), 403 deletions(-) create mode 100644 crates/schema/src/describe.rs create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap create mode 100644 crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap create mode 100644 crates/schema/src/styled_writer.rs diff --git a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md index d66ee69e330..229e1028656 100644 --- a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md +++ b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md @@ -81,9 +81,11 @@ spacetime logs my-database -f # follow logs spacetime logs my-database -n 100 # up to 100 log lines # Describe schema +# (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database table users --json -spacetime describe my-database reducer my_reducer --json +spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables users --json +spacetime describe my-database reducers my_reducer --json ``` ### Database Management diff --git a/crates/cli/src/common_args.rs b/crates/cli/src/common_args.rs index 381565d8040..0f1f7aebcb0 100644 --- a/crates/cli/src/common_args.rs +++ b/crates/cli/src/common_args.rs @@ -8,6 +8,25 @@ pub enum ClearMode { Never, // parses as "never" } +#[derive(Clone, Copy, PartialEq)] +pub enum Format { + Text, + Json, +} + +impl clap::ValueEnum for Format { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Text, Self::Json] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), + Self::Json => Some(clap::builder::PossibleValue::new("json")), + } + } +} + pub fn server() -> Arg { Arg::new("server") .long("server") @@ -30,6 +49,15 @@ pub fn yes() -> Arg { .help("Run non-interactively wherever possible. This will answer \"yes\" to almost all prompts, but will sometimes answer \"no\" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com).") } +/// The `--format` arg, parsed as a [`Format`]. Callers supply their own `.help(...)`. +pub fn format() -> Arg { + Arg::new("format") + .long("format") + .default_value("text") + .required(false) + .value_parser(value_parser!(Format)) +} + pub fn confirmed() -> Arg { Arg::new("confirmed") .required(false) diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 1f841187428..9373716ae97 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -1,14 +1,25 @@ use crate::api::ClientApi; -use crate::common_args; +use crate::common_args::{self, Format}; use crate::config::Config; use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_with_optional_parts}; +use crate::subcommands::publish::pretty_print_style_from_env; use crate::util::UNSTABLE_WARNING; use crate::util::{database_identity, get_auth_header}; use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; -use spacetimedb_lib::db::raw_def::v10::{RawReducerDefV10, RawTableDefV10}; +use spacetimedb_client_api_messages::name::PrettyPrintStyle as EnvStyle; +use spacetimedb_lib::db::raw_def::v10::{RawProcedureDefV10, RawReducerDefV10, RawTableDefV10, RawTypeDefV10}; use spacetimedb_lib::sats; +use spacetimedb_schema::auto_migrate::PrettyPrintStyle; use spacetimedb_schema::def::ModuleDef; +use spacetimedb_schema::describe::{ + all_named_types, describe_module, describe_procedure, describe_procedures, describe_reducer, describe_reducers, + describe_table, describe_tables, describe_type, describe_types, sorted_procedures, sorted_reducers, sorted_tables, + sorted_types, +}; +use std::io::IsTerminal; + +const USAGE: &str = "spacetime describe [database] [entity_type [entity_name]] [--format text|json] [--no-config]"; pub fn cli() -> clap::Command { clap::Command::new("describe") @@ -18,18 +29,15 @@ pub fn cli() -> clap::Command { .arg( Arg::new("describe_parts") .num_args(0..) - .help("Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME]"), + .help("Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]]"), ) + .arg(common_args::format().help("Output format for the schema")) .arg( Arg::new("json") .long("json") .action(ArgAction::SetTrue) - // make not required() once we have a human readable output - .required(true) - .help( - "Output the schema in JSON format. Currently required; in the future, omitting this will \ - give human-readable output.", - ), + .conflicts_with("format") + .help("Output the schema in JSON format. Shorthand for `--format json`."), ) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) @@ -43,48 +51,77 @@ pub fn cli() -> clap::Command { .after_help("Run `spacetime help describe` for more detailed information.\n") } -#[derive(clap::ValueEnum, Clone, Copy)] +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)] enum EntityType { + Procedure, Reducer, Table, + Type, +} + +/// What to describe: the whole module, every entity of one type, or a single named entity. +#[derive(Debug, PartialEq)] +enum Selection<'a> { + Module, + All(EntityType), + One(EntityType, &'a str), +} + +/// Parses an entity type. The plural is canonical, matching the section headings of the module +/// output, but the singular is accepted too. +fn parse_entity_type(entity_type: &str) -> anyhow::Result { + match entity_type { + "procedures" | "procedure" => Ok(EntityType::Procedure), + "reducers" | "reducer" => Ok(EntityType::Reducer), + "tables" | "table" => Ok(EntityType::Table), + "types" | "type" => Ok(EntityType::Type), + _ => { + anyhow::bail!("Invalid entity_type '{entity_type}'. Expected one of: procedures, reducers, tables, types.") + } + } +} + +/// Parses the describe arguments left over once the database has been resolved. +fn parse_selection(parts: &[String]) -> anyhow::Result> { + match parts { + [] => Ok(Selection::Module), + [entity_type] => Ok(Selection::All(parse_entity_type(entity_type)?)), + [entity_type, entity_name] => Ok(Selection::One(parse_entity_type(entity_type)?, entity_name)), + _ => anyhow::bail!("Invalid describe arguments.\nUsage: {USAGE}"), + } +} + +fn output_format(args: &ArgMatches) -> Format { + if args.get_flag("json") { + Format::Json + } else { + *args.get_one::("format").unwrap() + } +} + +/// Colour only when stdout is a terminal and `NO_COLOR` is unset. +fn text_style() -> PrettyPrintStyle { + if !std::io::stdout().is_terminal() { + return PrettyPrintStyle::NoColor; + } + match pretty_print_style_from_env() { + EnvStyle::AnsiColor => PrettyPrintStyle::AnsiColor, + EnvStyle::NoColor => PrettyPrintStyle::NoColor, + } } pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let json = args.get_flag("json"); + let format = output_format(args); let no_config = args.get_flag("no_config"); let raw_parts: Vec = args .get_many::("describe_parts") .map(|vals| vals.cloned().collect()) .unwrap_or_default(); let config_targets = load_config_db_targets(no_config)?; - let resolved = resolve_database_with_optional_parts( - &raw_parts, - config_targets.as_deref(), - "spacetime describe [database] [entity_type entity_name] --json [--no-config]", - )?; - let entity = match resolved.remaining_args.as_slice() { - [] => None, - [entity_type, entity_name] => { - let entity_type = match entity_type.as_str() { - "reducer" => EntityType::Reducer, - "table" => EntityType::Table, - _ => { - anyhow::bail!( - "Invalid entity_type '{}'. Expected one of: reducer, table.", - entity_type - ) - } - }; - Some((entity_type, entity_name.as_str())) - } - _ => { - anyhow::bail!( - "Invalid describe arguments.\nUsage: spacetime describe [database] [entity_type entity_name] --json [--no-config]" - ); - } - }; + let resolved = resolve_database_with_optional_parts(&raw_parts, config_targets.as_deref(), USAGE)?; + let selection = parse_selection(&resolved.remaining_args)?; let mut config = config; let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); @@ -101,36 +138,237 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let raw = api.module_def().await?; - if json { - fn sats_to_json(v: &T) -> serde_json::Result { - serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(v)) - } - let json = match entity { - // Entity lookups go through the validated `ModuleDef`, which resolves - // canonical names and dot-qualified names from submodules - // (e.g. `lib.my_reducer`). - Some((EntityType::Reducer, reducer_name)) => { - let module_def: ModuleDef = raw.try_into()?; - let (_, reducer) = module_def.reducer_by_name(reducer_name).context("no such reducer")?; - sats_to_json(&RawReducerDefV10::from(reducer.clone()))? + fn sats_to_json(v: &T) -> serde_json::Result { + serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(v)) + } + + // The whole-module JSON is the raw, unvalidated def, exactly as before. + if format == Format::Json && matches!(selection, Selection::Module) { + // TODO: validate the JSON output + println!("{}", sats_to_json(&raw)?); + return Ok(()); + } + + // Entity lookups and text rendering go through the validated `ModuleDef`, which resolves + // canonical names and dot-qualified names from submodules (e.g. `lib.my_reducer`). + let module_def: ModuleDef = raw.try_into()?; + match selection { + Selection::Module => print!("{}", describe_module(&module_def, text_style())), + Selection::All(EntityType::Procedure) => match format { + Format::Json => { + let procedures: Vec = sorted_procedures(&module_def) + .into_iter() + .map(|(_, _, procedure)| procedure.clone().into()) + .collect(); + println!("{}", sats_to_json(&procedures)?) } - Some((EntityType::Table, table_name)) => { - let module_def: ModuleDef = raw.try_into()?; - let (_, _, table) = module_def - .all_tables_with_prefix() + Format::Text => print!("{}", describe_procedures(&module_def, text_style())), + }, + Selection::All(EntityType::Reducer) => match format { + Format::Json => { + let reducers: Vec = sorted_reducers(&module_def) .into_iter() - .find(|(prefix, _, t)| format!("{}{}", prefix, &*t.name) == *table_name) - .context("no such table")?; - sats_to_json(&RawTableDefV10::from(table.clone()))? + .map(|(_, _, reducer)| reducer.clone().into()) + .collect(); + println!("{}", sats_to_json(&reducers)?) } - None => sats_to_json(&raw)?, - }; - - // TODO: validate the JSON output - println!("{json}"); - } else { - // TODO: human-readable API + Format::Text => print!("{}", describe_reducers(&module_def, text_style())), + }, + Selection::All(EntityType::Table) => match format { + Format::Json => { + let tables: Vec = sorted_tables(&module_def) + .into_iter() + .map(|(_, _, table)| table.clone().into()) + .collect(); + println!("{}", sats_to_json(&tables)?) + } + Format::Text => print!("{}", describe_tables(&module_def, text_style())), + }, + Selection::All(EntityType::Type) => match format { + Format::Json => { + let types: Vec = sorted_types(&module_def) + .into_iter() + .map(|named| named.def.clone().into()) + .collect(); + println!("{}", sats_to_json(&types)?) + } + Format::Text => print!("{}", describe_types(&module_def, text_style())), + }, + Selection::One(EntityType::Procedure, procedure_name) => { + let (prefix, owning, procedure) = module_def + .all_procedures_with_prefix() + .into_iter() + .find(|(prefix, _, p)| format!("{prefix}{}", p.name) == *procedure_name) + .context("no such procedure")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawProcedureDefV10::from(procedure.clone()))?), + Format::Text => print!("{}", describe_procedure(&prefix, owning, procedure, text_style())), + } + } + Selection::One(EntityType::Reducer, reducer_name) => { + let (_, reducer, owning) = module_def + .reducer_by_name_with_module(reducer_name) + .context("no such reducer")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawReducerDefV10::from(reducer.clone()))?), + Format::Text => print!("{}", describe_reducer(owning, reducer, text_style())), + } + } + Selection::One(EntityType::Table, table_name) => { + let (prefix, owning, table) = module_def + .all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, t)| format!("{}{}", prefix, &*t.name) == *table_name) + .context("no such table")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawTableDefV10::from(table.clone()))?), + Format::Text => print!("{}", describe_table(&prefix, owning, table, text_style())), + } + } + Selection::One(EntityType::Type, type_name) => { + // Any named type can be looked up, including the row types the listing leaves out. + let named = all_named_types(&module_def) + .into_iter() + .find(|named| named.qualified == *type_name) + .context("no such type")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawTypeDefV10::from(named.def.clone()))?), + Format::Text => print!("{}", describe_type(&named, text_style())), + } + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + fn parse_format(args: &[&str]) -> Format { + output_format(&cli().try_get_matches_from(args).unwrap()) + } + + fn parts(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() + } + + #[test] + fn cli_is_well_formed() { + cli().debug_assert(); + } + + #[test] + fn format_defaults_to_text() { + assert!(parse_format(&["describe", "db"]) == Format::Text); + } + + #[test] + fn json_flag_selects_json() { + assert!(parse_format(&["describe", "db", "--json"]) == Format::Json); + } + + #[test] + fn format_json_selects_json() { + assert!(parse_format(&["describe", "db", "--format", "json"]) == Format::Json); + } + + #[test] + fn format_text_aliases_select_text() { + assert!(parse_format(&["describe", "db", "--format", "txt"]) == Format::Text); + assert!(parse_format(&["describe", "db", "--format", "default"]) == Format::Text); + } + + #[test] + fn json_flag_conflicts_with_explicit_format() { + for format in ["text", "json"] { + let err = cli() + .try_get_matches_from(["describe", "db", "--json", "--format", format]) + .err() + .unwrap_or_else(|| panic!("`--json --format {format}` should be rejected")); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + } + + #[test] + fn json_flag_keeps_entity_parts() { + let matches = cli() + .try_get_matches_from(["describe", "db", "tables", "t", "--json"]) + .unwrap(); + let parts: Vec<&String> = matches.get_many::("describe_parts").unwrap().collect(); + assert_eq!(parts, ["db", "tables", "t"]); + assert!(output_format(&matches) == Format::Json); + } + + #[test] + fn no_entity_selects_the_module() { + assert_eq!(parse_selection(&parts(&[])).unwrap(), Selection::Module); + } + + #[test] + fn entity_type_alone_selects_all_of_that_type() { + for (entity_type, expected) in [ + ("procedures", EntityType::Procedure), + ("reducers", EntityType::Reducer), + ("tables", EntityType::Table), + ("types", EntityType::Type), + ] { + assert_eq!( + parse_selection(&parts(&[entity_type])).unwrap(), + Selection::All(expected) + ); + } + } + + #[test] + fn entity_type_and_name_select_one_entity() { + for (entity_type, name, expected) in [ + ("procedures", "lib.count", EntityType::Procedure), + ("reducers", "lib.add", EntityType::Reducer), + ("tables", "person", EntityType::Table), + ("types", "geo.Point", EntityType::Type), + ] { + assert_eq!( + parse_selection(&parts(&[entity_type, name])).unwrap(), + Selection::One(expected, name) + ); + } + } + + #[test] + fn singular_entity_types_are_accepted() { + for (singular, plural) in [ + ("procedure", "procedures"), + ("reducer", "reducers"), + ("table", "tables"), + ("type", "types"), + ] { + assert_eq!( + parse_selection(&parts(&[singular])).unwrap(), + parse_selection(&parts(&[plural])).unwrap() + ); + assert_eq!( + parse_selection(&parts(&[singular, "x"])).unwrap(), + parse_selection(&parts(&[plural, "x"])).unwrap() + ); + } + } + + #[test] + fn unknown_entity_type_is_rejected() { + for bad in [parts(&["Tables"]), parts(&["columns", "name"])] { + let err = parse_selection(&bad).unwrap_err().to_string(); + assert!(err.contains("Invalid entity_type"), "{err}"); + assert!(err.contains("procedures, reducers, tables, types"), "{err}"); + } + } + + #[test] + fn too_many_parts_are_rejected() { + let err = parse_selection(&parts(&["table", "person", "extra"])) + .unwrap_err() + .to_string(); + assert!(err.contains("Invalid describe arguments"), "{err}"); + } +} diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 57d388fea9e..ca2e15afb71 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use std::io::{self, Write}; use crate::common_args; +use crate::common_args::Format; use crate::config::Config; use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg}; use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; @@ -40,14 +41,7 @@ pub fn cli() -> clap::Command { .help("A flag indicating whether or not to follow the logs") .long_help("A flag that causes logs to not stop when end of the log file is reached, but rather to wait for additional data to be appended to the input."), ) - .arg( - Arg::new("format") - .long("format") - .default_value("text") - .required(false) - .value_parser(clap::value_parser!(Format)) - .help("Output format for the logs") - ) + .arg(common_args::format().help("Output format for the logs")) .arg( Arg::new("level") .long("level") @@ -166,24 +160,6 @@ struct LogsParams { follow: bool, } -#[derive(Clone, Copy, PartialEq)] -pub enum Format { - Text, - Json, -} - -impl clap::ValueEnum for Format { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Text, Self::Json] - } - fn to_possible_value(&self) -> Option { - match self { - Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), - Self::Json => Some(clap::builder::PossibleValue::new("json")), - } - } -} - pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); let no_config = args.get_flag("no_config"); diff --git a/crates/cli/src/subcommands/repl.rs b/crates/cli/src/subcommands/repl.rs index 6561475ca92..9487f95c1d2 100644 --- a/crates/cli/src/subcommands/repl.rs +++ b/crates/cli/src/subcommands/repl.rs @@ -1,5 +1,6 @@ use crate::api::{ClientApi, Connection}; -use crate::sql::{run_sql, Format}; +use crate::common_args::Format; +use crate::sql::run_sql; use colored::*; use dirs::home_dir; use std::env::temp_dir; diff --git a/crates/cli/src/subcommands/sql.rs b/crates/cli/src/subcommands/sql.rs index 5b782d7106b..3267b15b3b6 100644 --- a/crates/cli/src/subcommands/sql.rs +++ b/crates/cli/src/subcommands/sql.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use crate::api::{from_json_seed, ClientApi, Connection, SqlStmtResult, StmtStats}; use crate::common_args; +use crate::common_args::Format; use crate::config::Config; use crate::subcommands::db_arg_resolution::{ load_config_db_targets, resolve_database_arg, resolve_optional_database_parts, ResolvedDbArgs, @@ -33,14 +34,7 @@ pub fn cli() -> clap::Command { .arg(common_args::confirmed()) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) - .arg( - Arg::new("format") - .long("format") - .default_value("text") - .required(false) - .value_parser(clap::value_parser!(Format)) - .help("Output format for the SQL results"), - ) + .arg(common_args::format().help("Output format for the SQL results")) .arg(common_args::yes()) .arg( Arg::new("no_config") @@ -50,25 +44,6 @@ pub fn cli() -> clap::Command { ) } -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum Format { - Text, - Json, -} - -impl clap::ValueEnum for Format { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Text, Self::Json] - } - - fn to_possible_value(&self) -> Option { - match self { - Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])), - Self::Json => Some(clap::builder::PossibleValue::new("json")), - } - } -} - pub(crate) async fn parse_req( mut config: Config, args: &ArgMatches, diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 97fff428830..02f8e32283e 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -20,7 +20,7 @@ use spacetimedb_sats::{ raw_identifier::RawIdentifier, AlgebraicType, WithTypespace, }; -use termcolor_formatter::{ColorScheme, TermColorFormatter}; +use termcolor_formatter::TermColorFormatter; use thiserror::Error; mod formatter; mod termcolor_formatter; @@ -69,23 +69,17 @@ impl<'def> MigratePlan<'def> { } pub fn pretty_print(&self, style: PrettyPrintStyle) -> anyhow::Result { - use PrettyPrintStyle::*; match self { MigratePlan::Manual(_) => { anyhow::bail!("Manual migration plans are not yet supported for pretty printing.") } - MigratePlan::Auto(plan) => match style { - NoColor => { - let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::Never); - format_plan(&mut fmt, plan).map(|_| fmt.to_string()) - } - AnsiColor => { - let mut fmt = TermColorFormatter::new(ColorScheme::default(), termcolor::ColorChoice::AlwaysAnsi); - format_plan(&mut fmt, plan).map(|_| fmt.to_string()) - } + MigratePlan::Auto(plan) => { + let mut fmt = TermColorFormatter::new(style); + format_plan(&mut fmt, plan) + .map(|_| fmt.into_string()) + .map_err(|e| anyhow::anyhow!("Failed to format migration plan: {e}")) } - .map_err(|e| anyhow::anyhow!("Failed to format migration plan: {e}")), } } } diff --git a/crates/schema/src/auto_migrate/termcolor_formatter.rs b/crates/schema/src/auto_migrate/termcolor_formatter.rs index 811c04b1860..3b301181ddd 100644 --- a/crates/schema/src/auto_migrate/termcolor_formatter.rs +++ b/crates/schema/src/auto_migrate/termcolor_formatter.rs @@ -1,12 +1,12 @@ -use std::io::Write; -use std::{fmt, io}; +use std::io; use spacetimedb_lib::{db::raw_def::v9::TableAccess, AlgebraicType}; use spacetimedb_primitives::ColId; use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; -use termcolor::{Buffer, Color, ColorChoice, ColorSpec, WriteColor}; use crate::auto_migrate::formatter::ViewInfo; +use crate::auto_migrate::PrettyPrintStyle; +use crate::styled_writer::StyledWriter; use super::formatter::{ AccessChangeInfo, Action, ColumnChange, ColumnChanges, ConstraintInfo, IndexInfo, MigrationFormatter, NewColumns, @@ -14,124 +14,32 @@ use super::formatter::{ }; use crate::identifier::NamespacedIdentifier; -/// Color scheme for consistent formatting -#[derive(Debug, Clone)] -pub struct ColorScheme { - pub created: Color, - pub removed: Color, - pub changed: Color, - pub header: Color, - pub table_name: Color, - pub column_type: Color, - pub section_header: Color, - pub access: Color, - pub warning: Color, -} - -impl Default for ColorScheme { - fn default() -> Self { - Self { - created: Color::Green, - removed: Color::Red, - changed: Color::Yellow, - header: Color::Blue, - table_name: Color::Cyan, - column_type: Color::Magenta, - section_header: Color::Blue, - access: Color::Green, - warning: Color::Red, - } - } -} +const MIGRATION_INDENT_WIDTH: usize = 4; #[derive(Debug)] pub struct TermColorFormatter { - buffer: Buffer, - colors: ColorScheme, - indent_level: usize, -} - -impl fmt::Display for TermColorFormatter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let buffer_content = std::str::from_utf8(self.buffer.as_slice()).map_err(|_| fmt::Error)?; - write!(f, "{buffer_content}") - } + out: StyledWriter, } impl TermColorFormatter { - pub fn new(colors: ColorScheme, choice: ColorChoice) -> Self { + pub fn new(style: PrettyPrintStyle) -> Self { Self { - buffer: if choice == ColorChoice::Never { - Buffer::no_color() - } else { - Buffer::ansi() - }, - colors, - indent_level: 0, + out: StyledWriter::new(style, MIGRATION_INDENT_WIDTH), } } - fn write_indent(&mut self) -> io::Result<()> { - let indent = " ".repeat(self.indent_level); - self.buffer.write_all(indent.as_bytes()) - } - - fn write_line(&mut self, text: impl AsRef) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all(text.as_ref().as_bytes())?; - self.buffer.write_all(b"\n") - } - - fn write_colored(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { - let mut spec = ColorSpec::new(); - if let Some(c) = color { - spec.set_fg(Some(c)); - } - if bold { - spec.set_bold(true); - } - self.buffer.set_color(&spec)?; - self.buffer.write_all(text.as_bytes())?; - self.buffer.reset()?; - Ok(()) - } - - fn write_colored_line(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { - self.write_indent()?; - self.write_colored(text, color, bold)?; - self.buffer.write_all(b"\n") - } - - fn write_with_background(&mut self, text: &str, bg: Color, bold: bool) -> io::Result<()> { - let mut spec = ColorSpec::new(); - spec.set_bg(Some(bg)); - if bold { - spec.set_bold(true); - } - self.buffer.set_color(&spec)?; - self.buffer.write_all(text.as_bytes())?; - self.buffer.reset()?; - Ok(()) + pub fn into_string(self) -> String { + self.out.into_string() } fn write_bullet(&mut self, text: &str) -> io::Result<()> { - self.write_line(format!("• {text}")) + self.out.write_line(format!("• {text}")) } fn write_action_prefix(&mut self, action: &Action) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; - action.write_with_color(&mut self.buffer, &self.colors) - } - - fn indent(&mut self) { - self.indent_level += 1; - } - - fn dedent(&mut self) { - if self.indent_level > 0 { - self.indent_level -= 1; - } + self.out.write_indent()?; + self.out.write_plain("▸ ")?; + action.write_with_color(&mut self.out) } fn format_type_name(&self, ty: &AlgebraicType) -> String { @@ -140,7 +48,7 @@ impl TermColorFormatter { fn write_type_name(&mut self, ty: &AlgebraicType) -> io::Result<()> { let s = self.format_type_name(ty); - self.write_colored(&s, Some(self.colors.column_type), false) + self.out.write_colored(&s, Some(self.out.colors().column_type), false) } fn format_access(&self, access: TableAccess) -> &'static str { @@ -152,164 +60,178 @@ impl TermColorFormatter { fn write_access(&mut self, access: TableAccess) -> io::Result<()> { let s = self.format_access(access); - self.write_colored(s, Some(self.colors.access), false) + self.out.write_colored(s, Some(self.out.colors().access), false) } } impl MigrationFormatter for TermColorFormatter { fn format_header(&mut self) -> io::Result<()> { let line = "━".repeat(60); - self.write_line(&line)?; - self.write_colored_line("Database Migration Plan", Some(self.colors.header), true)?; - self.write_line(&line)?; - self.write_line("") + self.out.write_line(&line)?; + self.out + .write_colored_line("Database Migration Plan", Some(self.out.colors().header), true)?; + self.out.write_line(&line)?; + self.out.write_line("") } fn format_add_table(&mut self, table: &TableInfo) -> io::Result<()> { // Table header - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; - Action::Created.write_with_color(&mut self.buffer, &self.colors)?; + self.out.write_indent()?; + self.out.write_plain("▸ ")?; + Action::Created.write_with_color(&mut self.out)?; let kind = if table.is_system { "system" } else { "user" }; - self.buffer.write_all(format!(" {kind} table: ").as_bytes())?; - self.write_colored(&table.name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b" (")?; + self.out.write_plain(&format!(" {kind} table: "))?; + self.out + .write_colored(&table.name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(" (")?; self.write_access(table.access)?; - self.buffer.write_all(b")\n")?; + self.out.write_plain(")\n")?; - self.indent(); + self.out.indent(); if !table.columns.is_empty() { - self.write_colored_line("Columns:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Columns:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &table.columns { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } if !table.constraints.is_empty() { - self.write_colored_line("Unique constraints:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Unique constraints:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for c in &table.constraints { let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); self.write_bullet(&format!("{} on [{}]", c.name, cols))?; } - self.dedent(); + self.out.dedent(); } if !table.indexes.is_empty() { - self.write_colored_line("Indexes:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Indexes:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for i in &table.indexes { let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); self.write_bullet(&format!("{} on [{}]", i.name, cols))?; } - self.dedent(); + self.out.dedent(); } if !table.sequences.is_empty() { - self.write_colored_line("Auto-increment constraints:", Some(self.colors.section_header), true)?; - self.indent(); + self.out.write_colored_line( + "Auto-increment constraints:", + Some(self.out.colors().section_header), + true, + )?; + self.out.indent(); for s in &table.sequences { self.write_bullet(&format!("{} on {}", s.name, s.column_name))?; } - self.dedent(); + self.out.dedent(); } if let Some(s) = &table.schedule { - self.write_colored_line("Schedule:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Schedule:", Some(self.out.colors().section_header), true)?; + self.out.indent(); self.write_bullet(&format!("Calls {}: {}", s.function_kind, s.function_name))?; - self.dedent(); + self.out.dedent(); } - self.dedent(); - self.write_line("") + self.out.dedent(); + self.out.write_line("") } fn format_remove_table(&mut self, table_name: &NamespacedIdentifier) -> io::Result<()> { self.write_action_prefix(&Action::Removed)?; - self.buffer.write_all(b" table: ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; - self.write_line("") + self.out.write_plain(" table: ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; + self.out.write_line("") } fn format_view(&mut self, view: &ViewInfo, action: Action) -> io::Result<()> { - self.write_indent()?; - self.buffer.write_all("▸ ".to_string().as_bytes())?; + self.out.write_indent()?; + self.out.write_plain("▸ ")?; self.write_action_prefix(&action)?; - self.buffer.write_all(if view.is_anonymous { - b" anonymous view: " + self.out.write_plain(if view.is_anonymous { + " anonymous view: " } else { - b" view: " + " view: " })?; - self.write_colored(&view.name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out + .write_colored(&view.name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); if !view.params.is_empty() { - self.write_colored_line("Parameters:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Parameters:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &view.params { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } if !view.columns.is_empty() { - self.write_colored_line("Columns:", Some(self.colors.section_header), true)?; - self.indent(); + self.out + .write_colored_line("Columns:", Some(self.out.colors().section_header), true)?; + self.out.indent(); for col in &view.columns { - self.write_indent()?; - self.buffer.write_all(format!("• {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("• {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain("\n")?; } - self.dedent(); + self.out.dedent(); } - self.dedent(); - self.write_line("") + self.out.dedent(); + self.out.write_line("") } fn format_constraint(&mut self, c: &ConstraintInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; let cols = c.columns.iter().map(|x| x.to_string()).collect::>().join(", "); - self.buffer - .write_all(format!(" unique constraint {} on [{}] of table ", c.name, cols).as_bytes())?; - self.write_colored(&c.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out + .write_plain(&format!(" unique constraint {} on [{}] of table ", c.name, cols))?; + self.out + .write_colored(&c.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_index(&mut self, i: &IndexInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; let cols = i.columns.iter().map(|x| x.to_string()).collect::>().join(", "); - self.buffer - .write_all(format!(" index {} on [{}] of table ", i.name, cols).as_bytes())?; - self.write_colored(&i.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out + .write_plain(&format!(" index {} on [{}] of table ", i.name, cols))?; + self.out + .write_colored(&i.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_sequence(&mut self, s: &SequenceInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all( - format!( - " auto-increment constraint {} on column {} of table ", - s.name, s.column_name - ) - .as_bytes(), - )?; - self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(&format!( + " auto-increment constraint {} on column {} of table ", + s.name, s.column_name + ))?; + self.out + .write_colored(&s.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_change_access(&mut self, a: &AccessChangeInfo) -> io::Result<()> { @@ -318,11 +240,13 @@ impl MigrationFormatter for TermColorFormatter { TableAccess::Public => "private → public", }; self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" access for table ")?; - self.write_colored(&a.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b" (")?; - self.write_colored(direction, Some(self.colors.access), false)?; - self.buffer.write_all(b")\n") + self.out.write_plain(" access for table ")?; + self.out + .write_colored(&a.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(" (")?; + self.out + .write_colored(direction, Some(self.out.colors().access), false)?; + self.out.write_plain(")\n") } fn format_change_primary_key( @@ -338,90 +262,95 @@ impl MigrationFormatter for TermColorFormatter { (None, None) => return Ok(()), }; self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" primary key on table ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(format!(" ({description})\n").as_bytes()) + self.out.write_plain(" primary key on table ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(&format!(" ({description})\n")) } fn format_schedule(&mut self, s: &ScheduleInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all(b" schedule for table ")?; - self.write_colored(&s.table_name, Some(self.colors.table_name), true)?; - self.buffer - .write_all(format!(" calling {} {}\n", s.function_kind, s.function_name).as_bytes()) + self.out.write_plain(" schedule for table ")?; + self.out + .write_colored(&s.table_name, Some(self.out.colors().table_name), true)?; + self.out + .write_plain(&format!(" calling {} {}\n", s.function_kind, s.function_name)) } fn format_rls(&mut self, r: &RlsInfo, action: Action) -> io::Result<()> { self.write_action_prefix(&action)?; - self.buffer.write_all(b" row level security policy:\n")?; - self.indent(); - self.write_indent()?; - self.buffer.write_all(b"`")?; - self.write_colored(&r.policy, Some(self.colors.section_header), false)?; - self.buffer.write_all(b"`\n")?; - self.dedent(); + self.out.write_plain(" row level security policy:\n")?; + self.out.indent(); + self.out.write_indent()?; + self.out.write_plain("`")?; + self.out + .write_colored(&r.policy, Some(self.out.colors().section_header), false)?; + self.out.write_plain("`\n")?; + self.out.dedent(); Ok(()) } fn format_change_columns(&mut self, cs: &ColumnChanges) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" columns for table ")?; - self.write_colored(&cs.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(" columns for table ")?; + self.out + .write_colored(&cs.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); for ch in &cs.changes { - self.write_indent()?; + self.out.write_indent()?; match ch { ColumnChange::Renamed { old_name, new_name } => { - self.buffer - .write_all(format!("~ Renamed: {old_name} → {new_name}\n").as_bytes())?; + self.out.write_plain(&format!("~ Renamed: {old_name} → {new_name}\n"))?; } ColumnChange::TypeChanged { name, old_type, new_type, } => { - self.buffer.write_all(format!("~ Modified: {name} (").as_bytes())?; + self.out.write_plain(&format!("~ Modified: {name} ("))?; self.write_type_name(old_type)?; - self.buffer.write_all(" → ".to_string().as_bytes())?; + self.out.write_plain(" → ")?; self.write_type_name(new_type)?; - self.buffer.write_all(b")\n")?; + self.out.write_plain(")\n")?; } } } - self.dedent(); + self.out.dedent(); Ok(()) } fn format_add_columns(&mut self, nc: &NewColumns) -> io::Result<()> { let plural = if nc.columns.len() > 1 { "s" } else { "" }; self.write_action_prefix(&Action::Created)?; - self.buffer.write_all(format!(" column{plural} in table ").as_bytes())?; - self.write_colored(&nc.table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(&format!(" column{plural} in table "))?; + self.out + .write_colored(&nc.table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; - self.indent(); + self.out.indent(); for col in &nc.columns { let default = col .default_value .as_ref() .map(|v| format!(" (default: {v:#?})")) .unwrap_or_default(); - self.write_indent()?; - self.buffer.write_all(format!("+ {}: ", col.name).as_bytes())?; + self.out.write_indent()?; + self.out.write_plain(&format!("+ {}: ", col.name))?; self.write_type_name(&col.type_name)?; - self.buffer.write_all(format!("{default}\n").as_bytes())?; + self.out.write_plain(&format!("{default}\n"))?; } - self.dedent(); + self.out.dedent(); Ok(()) } fn format_change_table_accessor_name(&mut self, table_name: &str) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" table accessor name for ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(" table accessor name for ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n") } fn format_change_column_accessor_name( @@ -430,21 +359,23 @@ impl MigrationFormatter for TermColorFormatter { col_name: &str, ) -> io::Result<()> { self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" column accessor name for ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b".")?; - self.write_colored(col_name, Some(self.colors.column_type), true)?; - self.buffer.write_all(b"\n") + self.out.write_plain(" column accessor name for ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain(".")?; + self.out + .write_colored(col_name, Some(self.out.colors().column_type), true)?; + self.out.write_plain("\n") } fn format_disconnect_warning(&mut self) -> io::Result<()> { - self.write_indent()?; - self.write_with_background( + self.out.write_indent()?; + self.out.write_with_background( "!!! Warning: All clients will be disconnected due to breaking schema changes", - self.colors.warning, + self.out.colors().warning, true, )?; - self.buffer.write_all(b"\n") + self.out.write_plain("\n") } fn format_event_table_reschema(&mut self, table_name: &NamespacedIdentifier) -> io::Result<()> { @@ -452,30 +383,27 @@ impl MigrationFormatter for TermColorFormatter { // so for now we're just printing the table name. self.write_action_prefix(&Action::Changed)?; - self.buffer.write_all(b" schema of event table ")?; - self.write_colored(table_name, Some(self.colors.table_name), true)?; - self.buffer.write_all(b"\n")?; + self.out.write_plain(" schema of event table ")?; + self.out + .write_colored(table_name, Some(self.out.colors().table_name), true)?; + self.out.write_plain("\n")?; Ok(()) } } trait ActionColorExt { - fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()>; + fn write_with_color(&self, w: &mut StyledWriter) -> io::Result<()>; } impl ActionColorExt for Action { - fn write_with_color(&self, buffer: &mut Buffer, colors: &ColorScheme) -> io::Result<()> { + fn write_with_color(&self, w: &mut StyledWriter) -> io::Result<()> { + let colors = w.colors(); let (text, color) = match self { Action::Created => ("Created", colors.created), Action::Removed => ("Removed", colors.removed), Action::Changed => ("Changed", colors.changed), }; - let mut spec = ColorSpec::new(); - spec.set_fg(Some(color)).set_bold(true); - buffer.set_color(&spec)?; - buffer.write_all(text.as_bytes())?; - buffer.reset()?; - Ok(()) + w.write_colored(text, Some(color), true) } } diff --git a/crates/schema/src/describe.rs b/crates/schema/src/describe.rs new file mode 100644 index 00000000000..ba14db1ecbf --- /dev/null +++ b/crates/schema/src/describe.rs @@ -0,0 +1,1538 @@ +//! Human-readable rendering of a [`ModuleDef`], for `spacetime describe`. +//! +//! Types are spelled in a language-neutral way: primitives as `fmt_algebraic_type` spells them +//! (`U64`, `Bool`), containers as `Array`, `Option` and `Result`, the unit type as +//! `()`, special types by name (`Timestamp`, `Identity`, ...), and named types by their scoped +//! name joined with `.` (`geo.shapes.Point`). +//! +//! [`describe_module`] renders a whole module as sections (Tables, Views, Reducers, Procedures, +//! HTTP routes, Types, then Row-level security), omitting empty sections. [`describe_table`], +//! [`describe_reducer`], [`describe_procedure`] and [`describe_type`] render a single entity as it +//! appears in its section, and [`describe_tables`], [`describe_reducers`], [`describe_procedures`] +//! and [`describe_types`] render the contents of a whole section. All of them use two-space +//! indentation, end in a single newline, and never leave trailing whitespace. Column +//! widths are worked out from the uncoloured text, so the `AnsiColor` and `NoColor` styles align +//! identically. + +use std::collections::HashSet; +use std::fmt; +use std::io; + +use convert_case::{Case, Casing}; +use itertools::Itertools; +use spacetimedb_lib::db::raw_def::v10::MethodOrAny; +use spacetimedb_lib::db::raw_def::v9::{Lifecycle, TableAccess}; +use spacetimedb_lib::http::Method as HttpMethod; +use spacetimedb_primitives::ColId; +use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; +use spacetimedb_sats::satn::Satn; +use spacetimedb_sats::{AlgebraicType, AlgebraicTypeRef, WithTypespace}; + +use crate::auto_migrate::PrettyPrintStyle; +use crate::def::{ + ColumnDef, HttpRouteDef, IndexAlgorithm, ModuleDef, ProcedureDef, ReducerDef, TableDef, TypeDef, ViewDef, +}; +use crate::identifier::NamespacePath; +use crate::styled_writer::StyledWriter; +use crate::type_for_generate::{AlgebraicTypeDef, AlgebraicTypeUse, ProductTypeDef}; + +/// Displays the name of an [`AlgebraicTypeUse`], resolving refs through the owning [`ModuleDef`]. +pub struct TypeName<'a> { + def: &'a ModuleDef, + ty: &'a AlgebraicTypeUse, +} + +/// The language-neutral name of `ty`, e.g. `Option>`. +pub fn type_name<'a>(def: &'a ModuleDef, ty: &'a AlgebraicTypeUse) -> TypeName<'a> { + TypeName { def, ty } +} + +impl fmt::Display for TypeName<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // No wildcard arm: a new `AlgebraicTypeUse` variant must be given a spelling here. + match self.ty { + AlgebraicTypeUse::Ref(r) => { + if let Some((name, _)) = self.def.type_def_from_ref(*r) { + write!(f, "{}", name.name_segments().format(".")) + } else if let Some(ty) = self.def.typespace().get(*r) { + write!(f, "{}", fmt_algebraic_type(ty)) + } else { + write!(f, "{r}") + } + } + AlgebraicTypeUse::Array(elem) => write!(f, "Array<{}>", type_name(self.def, elem)), + AlgebraicTypeUse::Option(inner) => write!(f, "Option<{}>", type_name(self.def, inner)), + AlgebraicTypeUse::Result { ok_ty, err_ty } => write!( + f, + "Result<{}, {}>", + type_name(self.def, ok_ty), + type_name(self.def, err_ty) + ), + AlgebraicTypeUse::ScheduleAt => f.write_str("ScheduleAt"), + AlgebraicTypeUse::Identity => f.write_str("Identity"), + AlgebraicTypeUse::ConnectionId => f.write_str("ConnectionId"), + AlgebraicTypeUse::Timestamp => f.write_str("Timestamp"), + AlgebraicTypeUse::TimeDuration => f.write_str("TimeDuration"), + AlgebraicTypeUse::Uuid => f.write_str("Uuid"), + AlgebraicTypeUse::Unit => f.write_str("()"), + AlgebraicTypeUse::Never => f.write_str("Never"), + AlgebraicTypeUse::String => f.write_str("String"), + AlgebraicTypeUse::Primitive(prim) => write!(f, "{}", fmt_algebraic_type(&prim.algebraic_type())), + } + } +} + +/// Displays a parameter list as `name: Type, name: Type`, without surrounding parentheses. +pub struct ParamList<'a> { + def: &'a ModuleDef, + params: &'a ProductTypeDef, +} + +/// The parameter list of a reducer, procedure or view, e.g. `name: String, count: U32`. +pub fn param_list<'a>(def: &'a ModuleDef, params: &'a ProductTypeDef) -> ParamList<'a> { + ParamList { def, params } +} + +impl fmt::Display for ParamList<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, (name, ty)) in self.params.elements.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{name}: {}", type_name(self.def, ty))?; + } + Ok(()) + } +} + +const INDENT_WIDTH: usize = 2; + +const BUFFER_WRITE: &str = "writing to an in-memory buffer cannot fail"; + +/// Renders the whole module as human-readable text, one section per kind of entity. +/// +/// The sections are Tables, Views, Reducers, Procedures, HTTP routes, Types and Row-level +/// security, in that order. Empty sections are omitted, so an empty module renders as `""`. +pub fn describe_module(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_module(&mut w, def).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single table's block, as it appears under `Tables` in [`describe_module`] but +/// unindented. +/// +/// `prefix` and `owning` are the namespace path and owning module returned alongside `table` by +/// [`ModuleDef::all_tables_with_prefix`]. +pub fn describe_table(prefix: &NamespacePath, owning: &ModuleDef, table: &TableDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_table_block(&mut w, prefix, owning, table).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single reducer's row, as it appears under `Reducers` in [`describe_module`] but +/// unindented. +/// +/// `owning` is the module returned alongside `reducer` by +/// [`ModuleDef::reducer_by_name_with_module`]. The reducer's name is already qualified with its +/// namespace path, so no prefix is needed. +pub fn describe_reducer(owning: &ModuleDef, reducer: &ReducerDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_reducer_row(&mut w, owning, reducer).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single procedure's row, as it appears under `Procedures` in [`describe_module`] but +/// unindented. +/// +/// `prefix` and `owning` are the namespace path and owning module returned alongside `procedure` by +/// [`ModuleDef::all_procedures_with_prefix`]. +pub fn describe_procedure( + prefix: &NamespacePath, + owning: &ModuleDef, + procedure: &ProcedureDef, + style: PrettyPrintStyle, +) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_procedure_row(&mut w, prefix, owning, procedure).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single named type's row, as it appears under `Types` in [`describe_module`] but +/// unindented. +/// +/// `named` comes from [`sorted_types`] or [`all_named_types`]. A type the `Types` section leaves +/// out, such as a table's row type, renders the same way. +pub fn describe_type(named: &NamedType<'_>, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_type_row(&mut w, named).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders every table in the module, including those in submodules, each as [`describe_table`] +/// renders it, separated by blank lines and in the order [`describe_module`] lists them. +/// +/// A module with no tables renders as `""`. +pub fn describe_tables(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_table_blocks(&mut w, &sorted_tables(def)).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders every reducer in the module, including those in submodules, one row each as +/// [`describe_reducer`] renders it, in the order [`describe_module`] lists them. +/// +/// A module with no reducers renders as `""`. +pub fn describe_reducers(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for (_, owning, reducer) in sorted_reducers(def) { + write_reducer_row(&mut w, owning, reducer).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Renders every procedure in the module, including those in submodules, one row each as +/// [`describe_procedure`] renders it, in the order [`describe_module`] lists them. +/// +/// A module with no procedures renders as `""`. +pub fn describe_procedures(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for (prefix, owning, procedure) in sorted_procedures(def) { + write_procedure_row(&mut w, &prefix, owning, procedure).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Renders the named types [`describe_module`] lists under `Types`, one row each as +/// [`describe_type`] renders it, in the same order. +/// +/// A module with no such types renders as `""`. +pub fn describe_types(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for named in sorted_types(def) { + write_type_row(&mut w, &named).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Every table in the module, including those in submodules, sorted by qualified name: the order +/// in which [`describe_module`] and [`describe_tables`] list them. +pub fn sorted_tables(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &TableDef)> { + def.all_tables_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, table)| format!("{prefix}{}", table.name)) + .collect_vec() +} + +/// Every reducer in the module, including those in submodules, sorted by qualified name: the order +/// in which [`describe_module`] and [`describe_reducers`] list them. +pub fn sorted_reducers(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ReducerDef)> { + // A reducer's name is already qualified with its namespace path. + def.all_reducers_with_prefix() + .into_iter() + .sorted_by_cached_key(|(_, _, reducer)| reducer.name.to_string()) + .collect_vec() +} + +/// Every procedure in the module, including those in submodules, sorted by qualified name: the +/// order in which [`describe_module`] and [`describe_procedures`] list them. +pub fn sorted_procedures(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ProcedureDef)> { + def.all_procedures_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, procedure)| format!("{prefix}{}", procedure.name)) + .collect_vec() +} + +/// The named types [`describe_module`] and [`describe_types`] list, in the order they list them. +/// +/// These are the types reachable from a column, or from a view, reducer or procedure's parameters +/// or return type, including the types those refer to in turn. Table row types are left out, +/// because each table's own block already shows its columns. [`all_named_types`] has every type. +pub fn sorted_types(def: &ModuleDef) -> Vec> { + let tables = def.all_tables_with_prefix(); + let views = def.all_views_with_prefix(); + let reducers = def.all_reducers_with_prefix(); + let procedures = def.all_procedures_with_prefix(); + + let column_roots = tables.iter().flat_map(|&(ref prefix, owning, table)| { + table + .columns + .iter() + .map(move |col| (prefix.clone(), owning, &col.ty_for_generate)) + }); + let view_roots = views.iter().flat_map(|&(ref prefix, owning, view)| { + param_roots(prefix.clone(), owning, &view.params_for_generate).chain([( + prefix.clone(), + owning, + &view.return_type_for_generate, + )]) + }); + let reducer_roots = reducers + .iter() + .flat_map(|&(ref prefix, owning, reducer)| param_roots(prefix.clone(), owning, &reducer.params_for_generate)); + let procedure_roots = procedures.iter().flat_map(|&(ref prefix, owning, procedure)| { + param_roots(prefix.clone(), owning, &procedure.params_for_generate).chain([( + prefix.clone(), + owning, + &procedure.return_type_for_generate, + )]) + }); + reachable_types( + column_roots + .chain(view_roots) + .chain(reducer_roots) + .chain(procedure_roots), + ) +} + +/// Every named type in the module and its submodules, sorted by qualified name. +/// +/// Unlike [`sorted_types`], this includes table row types and types nothing refers to. +pub fn all_named_types(def: &ModuleDef) -> Vec> { + fn collect<'a>(prefix: &NamespacePath, owning: &'a ModuleDef, out: &mut Vec>) { + out.extend(owning.types().map(|type_def| NamedType::new(prefix, owning, type_def))); + for (namespace, submodule) in owning.submodules() { + collect(&prefix.child(namespace.clone()), submodule, out); + } + } + + let mut types = Vec::new(); + collect(&NamespacePath::root(), def, &mut types); + types.sort_by(|a, b| a.qualified.cmp(&b.qualified)); + types +} + +fn write_module(w: &mut StyledWriter, def: &ModuleDef) -> io::Result<()> { + let tables = sorted_tables(def); + let views = def + .all_views_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, view)| format!("{prefix}{}", view.name)) + .collect_vec(); + let reducers = sorted_reducers(def); + let procedures = sorted_procedures(def); + // Only the root module's routes are served, and route order matters, so keep declaration order. + let http_routes = def.http_routes(); + let types = sorted_types(def); + let row_level_security = def.row_level_security().map(|rls| &*rls.sql).sorted().collect_vec(); + + let mut wrote_section = false; + if !tables.is_empty() { + write_section_header(w, &mut wrote_section, "Tables")?; + write_tables(w, &tables)?; + } + if !views.is_empty() { + write_section_header(w, &mut wrote_section, "Views")?; + write_rows(w, &views, |w, (prefix, owning, view)| { + write_view_row(w, prefix, owning, view) + })?; + } + if !reducers.is_empty() { + write_section_header(w, &mut wrote_section, "Reducers")?; + write_rows(w, &reducers, |w, (_, owning, reducer)| { + write_reducer_row(w, owning, reducer) + })?; + } + if !procedures.is_empty() { + write_section_header(w, &mut wrote_section, "Procedures")?; + write_rows(w, &procedures, |w, (prefix, owning, procedure)| { + write_procedure_row(w, prefix, owning, procedure) + })?; + } + if !http_routes.is_empty() { + write_section_header(w, &mut wrote_section, "HTTP routes")?; + write_rows(w, http_routes, write_http_route_row)?; + } + if !types.is_empty() { + write_section_header(w, &mut wrote_section, "Types")?; + write_rows(w, &types, write_type_row)?; + } + if !row_level_security.is_empty() { + write_section_header(w, &mut wrote_section, "Row-level security")?; + write_rows(w, &row_level_security, |w, sql| w.write_line(sql))?; + } + Ok(()) +} + +/// The types of a function's parameters, as roots for [`reachable_types`]. +/// +/// The parameter list's own product type is deliberately not a root: some module languages +/// register it as a named type (`Init` for an `init` reducer), and it is not a type anyone uses. +fn param_roots<'a>( + prefix: NamespacePath, + owning: &'a ModuleDef, + params: &'a ProductTypeDef, +) -> impl Iterator + 'a { + params.elements.iter().map(move |(_, ty)| (prefix.clone(), owning, ty)) +} + +/// Writes one row per item, indented one level below the section heading. +fn write_rows( + w: &mut StyledWriter, + items: &[T], + mut write_row: impl FnMut(&mut StyledWriter, &T) -> io::Result<()>, +) -> io::Result<()> { + w.indent(); + for item in items { + write_row(w, item)?; + } + w.dedent(); + Ok(()) +} + +/// Writes a function's row: `name(param: Type, ...) -> Return [tag] [tag]`. +/// +/// The return type is omitted when `ret` is `None`, and nothing follows the closing parenthesis +/// or return type when there are no tags, so the row never ends in whitespace. +fn write_function_row( + w: &mut StyledWriter, + qualified: &str, + owning: &ModuleDef, + params: &ProductTypeDef, + ret: Option, + tags: &[String], +) -> io::Result<()> { + w.write_indent()?; + w.write_colored(qualified, Some(w.colors().table_name), true)?; + w.write_plain("(")?; + write_params(w, owning, params)?; + w.write_plain(")")?; + if let Some(ret) = ret { + w.write_plain(" -> ")?; + w.write_colored(&ret, Some(w.colors().column_type), false)?; + } + if !tags.is_empty() { + w.write_plain(" ")?; + for (i, tag) in tags.iter().enumerate() { + if i > 0 { + w.write_plain(" ")?; + } + w.write_colored(tag, Some(w.colors().access), false)?; + } + } + w.write_plain("\n") +} + +/// Writes a parameter list as `name: Type, name: Type`, with each type coloured. +fn write_params(w: &mut StyledWriter, owning: &ModuleDef, params: &ProductTypeDef) -> io::Result<()> { + for (i, (name, ty)) in params.elements.iter().enumerate() { + if i > 0 { + w.write_plain(", ")?; + } + w.write_plain(&format!("{name}: "))?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + } + Ok(()) +} + +fn write_view_row(w: &mut StyledWriter, prefix: &NamespacePath, owning: &ModuleDef, view: &ViewDef) -> io::Result<()> { + let ret = type_name(owning, &view.return_type_for_generate).to_string(); + let tags = [ + Some(if view.is_public { "[public]" } else { "[private]" }), + view.is_anonymous.then_some("[anonymous]"), + ] + .into_iter() + .flatten() + .map(str::to_owned) + .collect_vec(); + write_function_row( + w, + &format!("{prefix}{}", view.name), + owning, + &view.params_for_generate, + Some(ret), + &tags, + ) +} + +fn write_reducer_row(w: &mut StyledWriter, owning: &ModuleDef, reducer: &ReducerDef) -> io::Result<()> { + // Validation currently forces this to `()`, so it is always omitted in practice. + let ret = (!reducer.ok_return_type.is_unit()).then(|| fmt_algebraic_type(&reducer.ok_return_type).to_string()); + let tags = [ + reducer + .lifecycle + .map(|lifecycle| format!("[lifecycle: {}]", lifecycle_name(lifecycle))), + reducer.visibility.is_private().then(|| "[private]".to_owned()), + ] + .into_iter() + .flatten() + .collect_vec(); + write_function_row( + w, + reducer.name.as_ref(), + owning, + &reducer.params_for_generate, + ret, + &tags, + ) +} + +fn write_procedure_row( + w: &mut StyledWriter, + prefix: &NamespacePath, + owning: &ModuleDef, + procedure: &ProcedureDef, +) -> io::Result<()> { + let ret = (!matches!(procedure.return_type_for_generate, AlgebraicTypeUse::Unit)) + .then(|| type_name(owning, &procedure.return_type_for_generate).to_string()); + let tags = procedure + .visibility + .is_private() + .then(|| "[private]".to_owned()) + .into_iter() + .collect_vec(); + write_function_row( + w, + &format!("{prefix}{}", procedure.name), + owning, + &procedure.params_for_generate, + ret, + &tags, + ) +} + +/// The name of a lifecycle as it appears in a reducer's `[lifecycle: ...]` tag. +fn lifecycle_name(lifecycle: Lifecycle) -> String { + match lifecycle { + Lifecycle::Init => "init".to_owned(), + Lifecycle::OnConnect => "client_connected".to_owned(), + Lifecycle::OnDisconnect => "client_disconnected".to_owned(), + other => format!("{other:?}").to_case(Case::Snake), + } +} + +/// Writes an HTTP route's row: `METHOD path → handler`. +fn write_http_route_row(w: &mut StyledWriter, route: &HttpRouteDef) -> io::Result<()> { + // An empty path is valid, but would otherwise be invisible. + let path = if route.path.is_empty() { "\"\"" } else { &route.path }; + w.write_indent()?; + w.write_plain(&format!("{} {path} → ", http_method_name(&route.method)))?; + w.write_colored(&route.handler_name.to_string(), Some(w.colors().table_name), true)?; + w.write_plain("\n") +} + +/// The HTTP method in upper case, `ANY` for a route that matches any method, or an extension +/// method's name as written. +fn http_method_name(method: &MethodOrAny) -> String { + match method { + MethodOrAny::Any => "ANY".to_owned(), + MethodOrAny::Method(method) => match method { + HttpMethod::Get => "GET".to_owned(), + HttpMethod::Head => "HEAD".to_owned(), + HttpMethod::Post => "POST".to_owned(), + HttpMethod::Put => "PUT".to_owned(), + HttpMethod::Delete => "DELETE".to_owned(), + HttpMethod::Connect => "CONNECT".to_owned(), + HttpMethod::Options => "OPTIONS".to_owned(), + HttpMethod::Trace => "TRACE".to_owned(), + HttpMethod::Patch => "PATCH".to_owned(), + HttpMethod::Extension(name) => name.clone(), + }, + other => format!("{other:?}").to_uppercase(), + } +} + +/// Writes a section heading, preceded by a blank line unless it is the first section. +fn write_section_header(w: &mut StyledWriter, wrote_section: &mut bool, title: &str) -> io::Result<()> { + if std::mem::replace(wrote_section, true) { + // Not `write_line("")`, which would leave the indent behind as trailing whitespace. + w.write_plain("\n")?; + } + w.write_colored_line(title, Some(w.colors().section_header), true) +} + +/// Writes a subsection label such as `Columns:` on its own line. +fn write_subsection_label(w: &mut StyledWriter, label: &str) -> io::Result<()> { + w.write_colored_line(label, Some(w.colors().section_header), true) +} + +fn write_tables(w: &mut StyledWriter, tables: &[(NamespacePath, &ModuleDef, &TableDef)]) -> io::Result<()> { + w.indent(); + write_table_blocks(w, tables)?; + w.dedent(); + Ok(()) +} + +/// Writes each table's block at the writer's current indent, with a blank line between blocks. +fn write_table_blocks(w: &mut StyledWriter, tables: &[(NamespacePath, &ModuleDef, &TableDef)]) -> io::Result<()> { + for (i, (prefix, owning, table)) in tables.iter().enumerate() { + if i > 0 { + w.write_plain("\n")?; + } + write_table_block(w, prefix, owning, table)?; + } + Ok(()) +} + +/// A column's row under `Columns:`, as uncoloured text so widths can be measured. +struct ColumnRow { + name: String, + ty: String, + flags: String, +} + +fn column_row(owning: &ModuleDef, table: &TableDef, col: &ColumnDef) -> ColumnRow { + let is_primary_key = table.primary_key == Some(col.col_id); + let is_unique = !is_primary_key + && table.constraints.values().any(|constraint| { + constraint + .data + .unique_columns() + .is_some_and(|cols| cols.as_singleton() == Some(col.col_id)) + }); + let is_auto_inc = table.sequences.values().any(|seq| seq.column == col.col_id); + let default = col.default_value.as_ref().map(|value| { + let value = WithTypespace::new(owning.typespace(), &col.ty).with_value(value); + format!("default: {}", value.to_satn()) + }); + + let flags = [ + is_primary_key.then(|| "primary key".to_owned()), + is_unique.then(|| "unique".to_owned()), + is_auto_inc.then(|| "auto-increment".to_owned()), + default, + ] + .into_iter() + .flatten() + .join(", "); + + ColumnRow { + name: col.name.to_string(), + ty: type_name(owning, &col.ty_for_generate).to_string(), + flags, + } +} + +/// The name of column `col` of `table`, or its position if there is no such column. +fn column_name(table: &TableDef, col: ColId) -> String { + table + .get_column(col) + .map_or_else(|| col.idx().to_string(), |col| col.name.to_string()) +} + +/// The width of `text` when padded with `format!("{: usize { + text.chars().count() +} + +/// Writes `table`'s heading and subsections, starting at the writer's current indent. +fn write_table_block( + w: &mut StyledWriter, + prefix: &NamespacePath, + owning: &ModuleDef, + table: &TableDef, +) -> io::Result<()> { + let access = match table.table_access { + TableAccess::Public => "public", + TableAccess::Private => "private", + }; + let access = if table.is_event { + format!("{access}, event") + } else { + access.to_owned() + }; + w.write_indent()?; + w.write_colored(&format!("{prefix}{}", table.name), Some(w.colors().table_name), true)?; + w.write_plain(" (")?; + w.write_colored(&access, Some(w.colors().access), false)?; + w.write_plain(")\n")?; + + w.indent(); + + write_subsection_label(w, "Columns:")?; + let rows = table + .columns + .iter() + .sorted_by_key(|col| col.col_id) + .map(|col| column_row(owning, table, col)) + .collect_vec(); + let name_width = rows.iter().map(|row| text_width(&row.name)).max().unwrap_or(0) + 2; + let type_width = rows.iter().map(|row| text_width(&row.ty)).max().unwrap_or(0) + 2; + w.indent(); + for row in &rows { + w.write_indent()?; + w.write_plain(&format!("{: 1)?; + Some((&constraint.name, cols)) + }) + .sorted_by_key(|(name, _)| *name) + .collect_vec(); + if !multi_column_uniques.is_empty() { + write_subsection_label(w, "Unique constraints:")?; + w.indent(); + for (_, cols) in multi_column_uniques { + let cols = cols.iter().map(|col| column_name(table, col)).join(", "); + w.write_line(format!("({cols})"))?; + } + w.dedent(); + } + + if !table.indexes.is_empty() { + let indexes = table + .indexes + .values() + .map(|index| { + let algorithm = match &index.algorithm { + IndexAlgorithm::BTree(_) => "btree", + IndexAlgorithm::Hash(_) => "hash", + IndexAlgorithm::Direct(_) => "direct", + }; + let cols = index + .algorithm + .columns() + .iter() + .map(|col| column_name(table, col)) + .join(", "); + (format!("{prefix}{}", index.name), format!("{algorithm} ({cols})")) + }) + .sorted() + .collect_vec(); + let name_width = indexes.iter().map(|(name, _)| text_width(name)).max().unwrap_or(0) + 2; + write_subsection_label(w, "Indexes:")?; + w.indent(); + for (name, algorithm) in indexes { + w.write_line(format!("{name: { + /// The type's scoped name joined with `.`, prefixed with the namespace path of its owning + /// module (`lib.geo.Point`). + pub qualified: String, + /// The module that owns the type, against which its refs resolve. + pub owning: &'a ModuleDef, + /// The type's definition, whose `ty` refers into `owning`'s typespace. + pub def: &'a TypeDef, +} + +impl<'a> NamedType<'a> { + fn new(prefix: &NamespacePath, owning: &'a ModuleDef, def: &'a TypeDef) -> Self { + let qualified = format!("{prefix}{}", def.accessor_name.name_segments().format(".")); + Self { qualified, owning, def } + } +} + +/// The named types reachable from `roots`, including the types they refer to in turn, sorted by +/// qualified name. +/// +/// Each root is a type use together with the namespace path and owning module it appears in. +/// Types that are a table's row type are skipped, because those already appear under Tables. +fn reachable_types<'a>( + roots: impl IntoIterator, +) -> Vec> { + let mut work = roots.into_iter().collect_vec(); + let mut visited = HashSet::new(); + let mut found = Vec::new(); + + while let Some((prefix, owning, ty)) = work.pop() { + match ty { + AlgebraicTypeUse::Array(inner) | AlgebraicTypeUse::Option(inner) => work.push((prefix, owning, inner)), + AlgebraicTypeUse::Result { ok_ty, err_ty } => { + work.push((prefix.clone(), owning, ok_ty)); + work.push((prefix, owning, err_ty)); + } + AlgebraicTypeUse::Ref(r) => { + if !visited.insert((prefix.clone(), *r)) { + continue; + } + if let Some((_, type_def)) = owning.type_def_from_ref(*r) + && !owning.tables().any(|table| table.product_type_ref == *r) + { + found.push(NamedType::new(&prefix, owning, type_def)); + } + match owning.typespace_for_generate().get(*r) { + Some(AlgebraicTypeDef::Product(product)) => { + work.extend(product.elements.iter().map(|(_, ty)| (prefix.clone(), owning, ty))) + } + Some(AlgebraicTypeDef::Sum(sum)) => { + work.extend(sum.variants.iter().map(|(_, ty)| (prefix.clone(), owning, ty))) + } + Some(AlgebraicTypeDef::PlainEnum(_)) | None => {} + } + } + AlgebraicTypeUse::ScheduleAt + | AlgebraicTypeUse::Identity + | AlgebraicTypeUse::ConnectionId + | AlgebraicTypeUse::Timestamp + | AlgebraicTypeUse::TimeDuration + | AlgebraicTypeUse::Uuid + | AlgebraicTypeUse::Unit + | AlgebraicTypeUse::Never + | AlgebraicTypeUse::String + | AlgebraicTypeUse::Primitive(_) => {} + } + } + + found.sort_by(|a, b| a.qualified.cmp(&b.qualified)); + found +} + +/// Writes a named type's row: `Name = body`. +fn write_type_row(w: &mut StyledWriter, named: &NamedType<'_>) -> io::Result<()> { + w.write_indent()?; + w.write_colored(&named.qualified, Some(w.colors().table_name), true)?; + w.write_plain(" = ")?; + write_type_body(w, named.owning, named.def.ty)?; + w.write_plain("\n") +} + +/// The canonical names of the fields or variants of the product or sum type `r`. +/// +/// `typespace_for_generate` keeps the names from the module source (`imageUrl`), while the +/// validated typespace has the canonical ones (`image_url`). Falls back to `source_names` if the +/// two disagree about the type's shape. +fn element_names<'a>(owning: &'a ModuleDef, r: AlgebraicTypeRef, source_names: Vec<&'a str>) -> Vec<&'a str> { + let canonical: Option> = match owning.typespace().get(r) { + Some(AlgebraicType::Product(product)) => product.elements.iter().map(|e| e.name().map(|n| &**n)).collect(), + Some(AlgebraicType::Sum(sum)) => sum.variants.iter().map(|v| v.name().map(|n| &**n)).collect(), + _ => None, + }; + canonical + .filter(|names| names.len() == source_names.len()) + .unwrap_or(source_names) +} + +/// Writes the right-hand side of a `Name = ...` row in the Types section. +fn write_type_body(w: &mut StyledWriter, owning: &ModuleDef, r: AlgebraicTypeRef) -> io::Result<()> { + match owning.typespace_for_generate().get(r) { + Some(AlgebraicTypeDef::Product(product)) => { + if product.elements.is_empty() { + return w.write_plain("{}"); + } + let names = element_names(owning, r, product.elements.iter().map(|(n, _)| &**n).collect()); + w.write_plain("{ ")?; + for (i, (name, (_, ty))) in names.iter().zip(&product.elements).enumerate() { + if i > 0 { + w.write_plain(", ")?; + } + w.write_plain(&format!("{name}: "))?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + } + w.write_plain(" }") + } + Some(AlgebraicTypeDef::Sum(sum)) => { + let names = element_names(owning, r, sum.variants.iter().map(|(n, _)| &**n).collect()); + for (i, (name, (_, ty))) in names.iter().zip(&sum.variants).enumerate() { + if i > 0 { + w.write_plain(" | ")?; + } + w.write_plain(name)?; + if !matches!(ty, AlgebraicTypeUse::Unit) { + w.write_plain("(")?; + w.write_colored(&type_name(owning, ty).to_string(), Some(w.colors().column_type), false)?; + w.write_plain(")")?; + } + } + Ok(()) + } + Some(AlgebraicTypeDef::PlainEnum(plain)) => { + let names = element_names(owning, r, plain.variants.iter().map(|n| &**n).collect()); + w.write_plain(&names.join(" | ")) + } + // Validation gives every named type a definition for generation, but if one is missing, + // show its structure rather than nothing. + None => match owning.typespace().get(r) { + Some(ty) => w.write_colored(&fmt_algebraic_type(ty).to_string(), Some(w.colors().column_type), false), + None => w.write_plain(&r.to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identifier::Identifier; + use spacetimedb_lib::db::raw_def::v10::{ + CaseConversionPolicy, FunctionVisibility as RawFunctionVisibility, RawModuleDefV10Builder, + RawModuleDefV10Section, RawSubmoduleV10, + }; + use spacetimedb_lib::db::raw_def::v9::{btree, direct, hash}; + use spacetimedb_lib::{ProductType, ScheduleAt}; + use spacetimedb_sats::layout::PrimitiveType; + use spacetimedb_sats::{AlgebraicValue, SumValue}; + use std::sync::Arc; + + fn create_module_def_v10(build_module: impl Fn(&mut RawModuleDefV10Builder)) -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + build_module(&mut builder); + builder + .finish() + .try_into() + .expect("new_def should be a valid database definition") + } + + fn empty_module() -> ModuleDef { + create_module_def_v10(|_| {}) + } + + fn add_thumbnail(builder: &mut RawModuleDefV10Builder) -> AlgebraicTypeRef { + let thumbnail = AlgebraicType::product([("url", AlgebraicType::String), ("width", AlgebraicType::U32)]); + builder.add_algebraic_type([], "Thumbnail", thumbnail, true) + } + + /// The type of parameter `param` of reducer `reducer`, as it appears in `params_for_generate`. + fn param_ty<'a>(def: &'a ModuleDef, reducer: &str, param: &str) -> &'a AlgebraicTypeUse { + let reducer = def.reducer(reducer).expect("reducer should exist"); + reducer + .params_for_generate + .elements + .iter() + .find(|(name, _)| &**name == param) + .map(|(_, ty)| ty) + .expect("param should exist") + } + + fn name_of(def: &ModuleDef, ty: &AlgebraicTypeUse) -> String { + type_name(def, ty).to_string() + } + + #[test] + fn primitives() { + let def = empty_module(); + let cases = [ + (PrimitiveType::Bool, "Bool"), + (PrimitiveType::I8, "I8"), + (PrimitiveType::U8, "U8"), + (PrimitiveType::I16, "I16"), + (PrimitiveType::U16, "U16"), + (PrimitiveType::I32, "I32"), + (PrimitiveType::U32, "U32"), + (PrimitiveType::I64, "I64"), + (PrimitiveType::U64, "U64"), + (PrimitiveType::I128, "I128"), + (PrimitiveType::U128, "U128"), + (PrimitiveType::I256, "I256"), + (PrimitiveType::U256, "U256"), + (PrimitiveType::F32, "F32"), + (PrimitiveType::F64, "F64"), + ]; + for (prim, expected) in cases { + assert_eq!(name_of(&def, &AlgebraicTypeUse::Primitive(prim)), expected); + } + } + + #[test] + fn special_and_builtin_types() { + let def = empty_module(); + let cases = [ + (AlgebraicTypeUse::String, "String"), + (AlgebraicTypeUse::Unit, "()"), + (AlgebraicTypeUse::Never, "Never"), + (AlgebraicTypeUse::ScheduleAt, "ScheduleAt"), + (AlgebraicTypeUse::Identity, "Identity"), + (AlgebraicTypeUse::ConnectionId, "ConnectionId"), + (AlgebraicTypeUse::Timestamp, "Timestamp"), + (AlgebraicTypeUse::TimeDuration, "TimeDuration"), + (AlgebraicTypeUse::Uuid, "Uuid"), + ]; + for (ty, expected) in cases { + assert_eq!(name_of(&def, &ty), expected); + } + } + + #[test] + fn containers() { + let def = empty_module(); + let u32_ty = Arc::new(AlgebraicTypeUse::Primitive(PrimitiveType::U32)); + let string_ty = Arc::new(AlgebraicTypeUse::String); + + assert_eq!(name_of(&def, &AlgebraicTypeUse::Array(u32_ty.clone())), "Array"); + assert_eq!( + name_of(&def, &AlgebraicTypeUse::Option(string_ty.clone())), + "Option" + ); + assert_eq!( + name_of( + &def, + &AlgebraicTypeUse::Result { + ok_ty: u32_ty, + err_ty: string_ty, + } + ), + "Result" + ); + } + + #[test] + fn named_ref() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + builder.add_reducer( + "set_thumb", + ProductType::from([("thumb", AlgebraicType::Ref(thumbnail))]), + ); + }); + assert_eq!(name_of(&def, param_ty(&def, "set_thumb", "thumb")), "Thumbnail"); + } + + #[test] + fn nested_containers_with_ref() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + let thumbs = AlgebraicType::option(AlgebraicType::array(AlgebraicType::Ref(thumbnail))); + builder.add_reducer("set_thumbs", ProductType::from([("thumbs", thumbs)])); + }); + assert_eq!( + name_of(&def, param_ty(&def, "set_thumbs", "thumbs")), + "Option>" + ); + } + + #[test] + fn scoped_ref() { + let build = |policy: Option| { + create_module_def_v10(move |builder| { + if let Some(policy) = policy { + builder.set_case_conversion_policy(policy); + } + let point = AlgebraicType::product([("x", AlgebraicType::F32), ("y", AlgebraicType::F32)]); + let point = builder.add_algebraic_type(["geo".into(), "shapes".into()], "Point", point, true); + builder.add_reducer("move_to", ProductType::from([("point", AlgebraicType::Ref(point))])); + }) + }; + + // The default policy converts type names and their scope segments to PascalCase. + let def = build(None); + assert_eq!(name_of(&def, param_ty(&def, "move_to", "point")), "Geo.Shapes.Point"); + + // With no conversion, the source names are kept, which also shows the separator is `.`. + let def = build(Some(CaseConversionPolicy::None)); + assert_eq!(name_of(&def, param_ty(&def, "move_to", "point")), "geo.shapes.Point"); + } + + #[test] + fn result_through_module() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + let outcome = AlgebraicType::result(AlgebraicType::U32, AlgebraicType::Ref(thumbnail)); + builder.add_reducer("try_thumb", ProductType::from([("outcome", outcome)])); + }); + assert_eq!( + name_of(&def, param_ty(&def, "try_thumb", "outcome")), + "Result" + ); + } + + #[test] + fn unresolvable_ref_falls_back() { + // Validation gives every ref in a `ModuleDef` a name in its refmap, so the fallback from a + // missing name to `fmt_algebraic_type` of the resolved type can't be reached through a + // validated module. A ref that is out of range for the typespace is the reachable case: + // it must print the raw ref rather than panic. + let def = empty_module(); + let ty = AlgebraicTypeUse::Ref(AlgebraicTypeRef(u32::MAX)); + assert_eq!(name_of(&def, &ty), "&4294967295"); + } + + #[test] + fn param_list() { + let def = create_module_def_v10(|builder| { + let thumbnail = add_thumbnail(builder); + builder.add_reducer( + "update", + ProductType::from([ + ("name", AlgebraicType::String), + ("count", AlgebraicType::U32), + ( + "thumbs", + AlgebraicType::option(AlgebraicType::array(AlgebraicType::Ref(thumbnail))), + ), + ]), + ); + builder.add_reducer("tick", ProductType::unit()); + }); + + let update = def.reducer("update").expect("reducer should exist"); + assert_eq!( + super::param_list(&def, &update.params_for_generate).to_string(), + "name: String, count: U32, thumbs: Option>" + ); + + let tick = def.reducer("tick").expect("reducer should exist"); + assert_eq!(super::param_list(&def, &tick.params_for_generate).to_string(), ""); + } + + /// A module exercising every section of the describe output. + fn describe_fixture() -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + + // A camelCase field, to check the canonical `image_url` is shown. + let thumbnail = builder.add_algebraic_type( + [], + "Thumbnail", + AlgebraicType::product([ + ("imageUrl", AlgebraicType::String), + ("width", AlgebraicType::U32), + ("height", AlgebraicType::U32), + ]), + true, + ); + // Reachable only through `Shape`. + let size = builder.add_algebraic_type( + [], + "Size", + AlgebraicType::product([("width", AlgebraicType::F32), ("height", AlgebraicType::F32)]), + true, + ); + let shape = builder.add_algebraic_type( + [], + "Shape", + AlgebraicType::sum([ + ("Point", AlgebraicType::unit()), + ("Circle", AlgebraicType::F32), + ("Rect", AlgebraicType::Ref(size)), + ]), + true, + ); + let color = builder.add_algebraic_type( + [], + "Color", + AlgebraicType::simple_enum(["Red", "Green", "Blue"].into_iter()), + true, + ); + // Declared but never used, so it must not be listed. + builder.add_algebraic_type([], "Unused", AlgebraicType::product([("x", AlgebraicType::U8)]), true); + // Reachable only through reducer `move_player`. + let direction = builder.add_algebraic_type( + [], + "Direction", + AlgebraicType::simple_enum(["North", "South", "East", "West"].into_iter()), + true, + ); + // Reachable only through procedure `player_stats`'s return type. + let stats = builder.add_algebraic_type( + [], + "Stats", + AlgebraicType::product([("wins", AlgebraicType::U32), ("losses", AlgebraicType::U32)]), + true, + ); + let schedule_at = builder.add_type::(); + + let player = builder + .build_table_with_new_type( + "player", + ProductType::from([ + ("id", AlgebraicType::U64), + ("name", AlgebraicType::String), + ("rank", AlgebraicType::U32), + ("nickname", AlgebraicType::option(AlgebraicType::String)), + ("joined_at", AlgebraicType::timestamp()), + ("avatar", AlgebraicType::Ref(thumbnail)), + ("color", AlgebraicType::Ref(color)), + ("level", AlgebraicType::U32), + ]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index(btree(0), "player_id", "id") + .with_index(hash(1), "player_name", "name") + .with_unique_constraint(2) + .with_index(direct(2), "player_rank", "rank") + .with_default_column_value(6, AlgebraicValue::Sum(SumValue::new(0, ()))) + .with_default_column_value(7, AlgebraicValue::U32(1)) + .finish(); + + builder + .build_table_with_new_type( + "match_result", + ProductType::from([ + ("player_id", AlgebraicType::U64), + ("round", AlgebraicType::U32), + ("scores", AlgebraicType::array(AlgebraicType::U32)), + ("shape", AlgebraicType::Ref(shape)), + ]), + true, + ) + .with_access(TableAccess::Private) + .with_unique_constraint([0, 1]) + .with_index(btree([0, 1]), "match_result_player_round", "player_round") + .finish(); + + builder + .build_table_with_new_type( + "player_joined", + ProductType::from([("player_id", AlgebraicType::U64), ("name", AlgebraicType::String)]), + true, + ) + .with_event(true) + .finish(); + + let reminder = builder + .build_table_with_new_type( + "reminder", + ProductType::from([ + ("scheduled_id", AlgebraicType::U64), + ("scheduled_at", schedule_at), + ("message", AlgebraicType::String), + ]), + true, + ) + .with_access(TableAccess::Private) + .with_auto_inc_primary_key(0) + .with_index(btree(0), "reminder_scheduled_id", "scheduled_id") + .finish(); + builder.add_procedure( + "send_reminder", + ProductType::from([("job", AlgebraicType::Ref(reminder))]), + AlgebraicType::unit(), + ); + builder.add_schedule("reminder", 1, "send_reminder"); + + builder.add_lifecycle_reducer(Lifecycle::Init, "init", ProductType::unit()); + builder.add_reducer( + "move_player", + ProductType::from([ + ("player_id", AlgebraicType::U64), + ("direction", AlgebraicType::Ref(direction)), + ]), + ); + // Made private below, since the builder only makes lifecycle reducers private. + builder.add_reducer("reset_ranks", ProductType::unit()); + builder.add_procedure( + "player_stats", + ProductType::from([("player_id", AlgebraicType::U64)]), + AlgebraicType::option(AlgebraicType::Ref(stats)), + ); + builder.add_view( + "players_above_rank", + 0, + true, + false, + ProductType::from([("min_rank", AlgebraicType::U32)]), + AlgebraicType::array(AlgebraicType::Ref(player)), + ); + builder.add_view( + "top_player", + 0, + true, + true, + ProductType::unit(), + AlgebraicType::option(AlgebraicType::Ref(player)), + ); + builder.add_http_handler("webhook"); + builder.add_http_handler("health"); + builder.add_http_route("webhook", MethodOrAny::Method(HttpMethod::Post), "/webhook"); + // Declared after `/webhook`, so the output shows declaration order is kept. + builder.add_http_route("health", MethodOrAny::Any, "/health"); + builder.add_row_level_security("SELECT * FROM player WHERE rank > 0"); + // Declared second but sorts first. + builder.add_row_level_security("SELECT * FROM match_result WHERE round > 0"); + + let mut lib = RawModuleDefV10Builder::new(); + lib.build_table_with_new_type( + "session", + ProductType::from([("id", AlgebraicType::U64), ("owner", AlgebraicType::identity())]), + true, + ) + .with_access(TableAccess::Private) + .with_unique_constraint(0) + .with_primary_key(0) + .with_index(btree(0), "session_id", "id") + .finish(); + // A submodule reducer, whose name must be shown qualified exactly once. + lib.add_reducer("end_session", ProductType::from([("id", AlgebraicType::U64)])); + // A submodule procedure, whose name must be shown qualified. + lib.add_procedure("session_count", ProductType::unit(), AlgebraicType::U32); + + let mut raw = builder.finish(); + for section in &mut raw.sections { + if let RawModuleDefV10Section::Reducers(reducers) = section { + for reducer in reducers.iter_mut().filter(|r| &*r.source_name == "reset_ranks") { + reducer.visibility = RawFunctionVisibility::Private; + } + } + } + raw.sections + .push(RawModuleDefV10Section::Submodules(vec![RawSubmoduleV10 { + namespace: "lib".into(), + module: lib.finish(), + }])); + raw.try_into() + .expect("the describe fixture should be a valid module definition") + } + + fn table_with_prefix<'a>(def: &'a ModuleDef, name: &str) -> (NamespacePath, &'a ModuleDef, &'a TableDef) { + def.all_tables_with_prefix() + .into_iter() + .find(|(prefix, _, table)| format!("{prefix}{}", table.name) == name) + .expect("table should exist") + } + + #[test] + fn describe_module_no_color() { + let def = describe_fixture(); + insta::assert_snapshot!( + "describe_module_no_color", + describe_module(&def, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_module_ansi() { + let def = describe_fixture(); + insta::assert_snapshot!( + "describe_module_ansi", + describe_module(&def, PrettyPrintStyle::AnsiColor) + ); + } + + #[test] + fn describe_table_no_color() { + let def = describe_fixture(); + let (prefix, owning, table) = table_with_prefix(&def, "player"); + insta::assert_snapshot!( + "describe_table_no_color", + describe_table(&prefix, owning, table, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_reducer_no_color() { + let def = describe_fixture(); + let (_, reducer, owning) = def.reducer_by_name_with_module("init").expect("reducer should exist"); + insta::assert_snapshot!( + "describe_reducer_no_color", + describe_reducer(owning, reducer, PrettyPrintStyle::NoColor) + ); + } + + #[test] + fn describe_reducer_qualifies_submodule_names() { + let def = describe_fixture(); + let (_, reducer, owning) = def + .reducer_by_name_with_module("lib.end_session") + .expect("reducer should exist"); + assert_eq!( + describe_reducer(owning, reducer, PrettyPrintStyle::NoColor), + "lib.end_session(id: U64)\n" + ); + } + + /// The body of section `title` in [`describe_module`] output, dedented back to column 0. + fn module_section(module: &str, title: &str) -> String { + let mut body = module + .lines() + .skip_while(|line| *line != title) + .skip(1) + .take_while(|line| line.is_empty() || line.starts_with(" ")) + .collect_vec(); + // The blank line separating this section from the next one isn't part of it. + while body.last().is_some_and(|line| line.is_empty()) { + body.pop(); + } + body.iter() + .map(|line| format!("{}\n", line.strip_prefix(" ").unwrap_or(line))) + .collect() + } + + #[test] + fn describe_tables_matches_the_module_tables_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let tables = describe_tables(&def, PrettyPrintStyle::NoColor); + assert_eq!(tables, module_section(&module, "Tables")); + // Submodule tables are included, under their qualified names. + assert!(tables.starts_with("lib.session (private)\n"), "{tables}"); + } + + #[test] + fn describe_reducers_matches_the_module_reducers_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let reducers = describe_reducers(&def, PrettyPrintStyle::NoColor); + assert_eq!( + reducers, + "init() [lifecycle: init] [private]\n\ + lib.end_session(id: U64)\n\ + move_player(player_id: U64, direction: Direction)\n\ + reset_ranks() [private]\n" + ); + assert_eq!(reducers, module_section(&module, "Reducers")); + } + + #[test] + fn describe_procedure_qualifies_submodule_names() { + let def = describe_fixture(); + let (prefix, owning, procedure) = def + .all_procedures_with_prefix() + .into_iter() + .find(|(prefix, _, procedure)| format!("{prefix}{}", procedure.name) == "lib.session_count") + .expect("procedure should exist"); + assert_eq!( + describe_procedure(&prefix, owning, procedure, PrettyPrintStyle::NoColor), + "lib.session_count() -> U32\n" + ); + } + + #[test] + fn describe_procedures_matches_the_module_procedures_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let procedures = describe_procedures(&def, PrettyPrintStyle::NoColor); + assert_eq!( + procedures, + "lib.session_count() -> U32\n\ + player_stats(player_id: U64) -> Option\n\ + send_reminder(job: Reminder) [private]\n" + ); + assert_eq!(procedures, module_section(&module, "Procedures")); + } + + #[test] + fn describe_types_matches_the_module_types_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let types = describe_types(&def, PrettyPrintStyle::NoColor); + assert_eq!(types, module_section(&module, "Types")); + assert!(types.starts_with("Color = red | green | blue\n"), "{types}"); + } + + #[test] + fn every_listed_type_can_be_found_by_its_listed_name() { + let def = describe_fixture(); + let all = all_named_types(&def); + for listed in sorted_types(&def) { + let found = all + .iter() + .find(|named| named.qualified == listed.qualified) + .unwrap_or_else(|| panic!("{} should be found", listed.qualified)); + assert!( + std::ptr::eq(found.def, listed.def), + "{} found the wrong type", + listed.qualified + ); + } + } + + #[test] + fn all_named_types_include_row_types_and_unused_types() { + let def = describe_fixture(); + let all = all_named_types(&def); + let find = |qualified: &str| { + all.iter() + .find(|named| named.qualified == qualified) + .unwrap_or_else(|| panic!("{qualified} should be found")) + }; + assert_eq!( + describe_type(find("Unused"), PrettyPrintStyle::NoColor), + "Unused = { x: U8 }\n" + ); + + // A submodule's row type is found under its qualified name, and only that name. + let (_, lib, session) = table_with_prefix(&def, "lib.session"); + let (name, _) = lib + .type_def_from_ref(session.product_type_ref) + .expect("the row type should be named"); + let unqualified = name.name_segments().format(".").to_string(); + assert_eq!( + describe_type(find(&format!("lib.{unqualified}")), PrettyPrintStyle::NoColor), + format!("lib.{unqualified} = {{ id: U64, owner: Identity }}\n") + ); + assert!(all.iter().all(|named| named.qualified != unqualified)); + } + + #[test] + fn listings_of_an_empty_module_are_empty() { + let def = empty_module(); + assert_eq!(describe_tables(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_reducers(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_procedures(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_types(&def, PrettyPrintStyle::NoColor), ""); + } + + #[test] + fn function_signatures_are_type_roots() { + let def = create_module_def_v10(|builder| { + let direction = builder.add_algebraic_type( + [], + "Direction", + AlgebraicType::simple_enum(["North", "South"].into_iter()), + true, + ); + let params = ProductType::from([("direction", AlgebraicType::Ref(direction))]); + // Some module languages register a reducer's parameter list as a named type. It is + // never referred to, so it must not be listed. + builder.add_algebraic_type([], "Walk", AlgebraicType::Product(params.clone()), true); + builder.add_reducer("walk", params); + }); + let text = describe_module(&def, PrettyPrintStyle::NoColor); + assert!(text.contains("\nTypes\n Direction = north | south\n"), "{text}"); + assert!(!text.contains("Walk ="), "{text}"); + } + + #[test] + fn describe_empty_module() { + assert_eq!(describe_module(&empty_module(), PrettyPrintStyle::NoColor), ""); + } + + #[test] + fn no_trailing_whitespace() { + let text = describe_module(&describe_fixture(), PrettyPrintStyle::NoColor); + for (i, line) in text.lines().enumerate() { + assert_eq!( + line, + line.trim_end(), + "line {} has trailing whitespace: {line:?}", + i + 1 + ); + } + assert!(text.ends_with('\n'), "output should end with a newline"); + assert!(!text.ends_with("\n\n"), "output should end with a single newline"); + assert!( + !text.contains("\n\n\n"), + "output should have at most one blank line in a row" + ); + } + + #[test] + fn reachable_types_skip_row_types_and_follow_refs() { + let def = describe_fixture(); + let qualified = |ty: &AlgebraicTypeUse| { + reachable_types([(NamespacePath::root(), &def, ty)]) + .into_iter() + .map(|named| named.qualified) + .collect_vec() + }; + + let (_, _, reminder) = table_with_prefix(&def, "reminder"); + assert!(qualified(&AlgebraicTypeUse::Ref(reminder.product_type_ref)).is_empty()); + + let (_, _, match_result) = table_with_prefix(&def, "match_result"); + let shape = &match_result + .get_column_by_name(&Identifier::for_test("shape")) + .expect("column should exist") + .ty_for_generate; + assert_eq!(qualified(shape), ["Shape", "Size"]); + } +} diff --git a/crates/schema/src/lib.rs b/crates/schema/src/lib.rs index aa703e37b2d..07588ab48bf 100644 --- a/crates/schema/src/lib.rs +++ b/crates/schema/src/lib.rs @@ -4,10 +4,12 @@ pub mod auto_migrate; pub mod def; +pub mod describe; pub mod error; pub mod identifier; pub mod reducer_name; pub mod relation; pub mod schema; +mod styled_writer; pub mod table_name; pub mod type_for_generate; diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap new file mode 100644 index 00000000000..a3aa71a8791 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap @@ -0,0 +1,82 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_module(&def, PrettyPrintStyle::AnsiColor)" +--- +Tables + lib.session (private) + Columns: + id U64 primary key + owner Identity + Indexes: + lib.session_id_idx_btree btree (id) + + match_result (private) + Columns: + player_id U64 + round U32 + scores Array + shape Shape + Unique constraints: + (player_id, round) + Indexes: + match_result_player_id_round_idx_btree btree (player_id, round) + + player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) + + player_joined (public, event) + Columns: + player_id U64 + name String + + reminder (private) + Columns: + scheduled_id U64 primary key, auto-increment + scheduled_at ScheduleAt + message String + Indexes: + reminder_scheduled_id_idx_btree btree (scheduled_id) + Schedule: calls procedure send_reminder + +Views + players_above_rank(min_rank: U32) -> Array [public] + top_player() -> Option [public] [anonymous] + +Reducers + init() [lifecycle: init] [private] + lib.end_session(id: U64) + move_player(player_id: U64, direction: Direction) + reset_ranks() [private] + +Procedures + lib.session_count() -> U32 + player_stats(player_id: U64) -> Option + send_reminder(job: Reminder) [private] + +HTTP routes + POST /webhook → webhook + ANY /health → health + +Types + Color = red | green | blue + Direction = north | south | east | west + Shape = point | circle(F32) | rect(Size) + Size = { width: F32, height: F32 } + Stats = { wins: U32, losses: U32 } + Thumbnail = { image_url: String, width: U32, height: U32 } + +Row-level security + SELECT * FROM match_result WHERE round > 0 + SELECT * FROM player WHERE rank > 0 diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap new file mode 100644 index 00000000000..fb62bb1dde4 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap @@ -0,0 +1,82 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_module(&def, PrettyPrintStyle::NoColor)" +--- +Tables + lib.session (private) + Columns: + id U64 primary key + owner Identity + Indexes: + lib.session_id_idx_btree btree (id) + + match_result (private) + Columns: + player_id U64 + round U32 + scores Array + shape Shape + Unique constraints: + (player_id, round) + Indexes: + match_result_player_id_round_idx_btree btree (player_id, round) + + player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) + + player_joined (public, event) + Columns: + player_id U64 + name String + + reminder (private) + Columns: + scheduled_id U64 primary key, auto-increment + scheduled_at ScheduleAt + message String + Indexes: + reminder_scheduled_id_idx_btree btree (scheduled_id) + Schedule: calls procedure send_reminder + +Views + players_above_rank(min_rank: U32) -> Array [public] + top_player() -> Option [public] [anonymous] + +Reducers + init() [lifecycle: init] [private] + lib.end_session(id: U64) + move_player(player_id: U64, direction: Direction) + reset_ranks() [private] + +Procedures + lib.session_count() -> U32 + player_stats(player_id: U64) -> Option + send_reminder(job: Reminder) [private] + +HTTP routes + POST /webhook → webhook + ANY /health → health + +Types + Color = red | green | blue + Direction = north | south | east | west + Shape = point | circle(F32) | rect(Size) + Size = { width: F32, height: F32 } + Stats = { wins: U32, losses: U32 } + Thumbnail = { image_url: String, width: U32, height: U32 } + +Row-level security + SELECT * FROM match_result WHERE round > 0 + SELECT * FROM player WHERE rank > 0 diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap new file mode 100644 index 00000000000..e562c77294b --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_reducer_no_color.snap @@ -0,0 +1,5 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_reducer(owning, reducer, PrettyPrintStyle::NoColor)" +--- +init() [lifecycle: init] [private] diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap new file mode 100644 index 00000000000..31197b50759 --- /dev/null +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_table_no_color.snap @@ -0,0 +1,18 @@ +--- +source: crates/schema/src/describe.rs +expression: "describe_table(&prefix, owning, table, PrettyPrintStyle::NoColor)" +--- +player (public) + Columns: + id U64 primary key, auto-increment + name String + rank U32 unique + nickname Option + joined_at Timestamp + avatar Thumbnail + color Color default: (red = ()) + level U32 default: 1 + Indexes: + player_id_idx_btree btree (id) + player_name_idx_hash hash (name) + player_rank_idx_direct direct (rank) diff --git a/crates/schema/src/styled_writer.rs b/crates/schema/src/styled_writer.rs new file mode 100644 index 00000000000..5e32fe7b386 --- /dev/null +++ b/crates/schema/src/styled_writer.rs @@ -0,0 +1,126 @@ +use std::io::{self, Write}; + +use termcolor::{Buffer, Color, ColorSpec, WriteColor}; + +use crate::auto_migrate::PrettyPrintStyle; + +/// Color scheme for consistent formatting +#[derive(Debug, Clone)] +pub(crate) struct ColorScheme { + pub created: Color, + pub removed: Color, + pub changed: Color, + pub header: Color, + pub table_name: Color, + pub column_type: Color, + pub section_header: Color, + pub access: Color, + pub warning: Color, +} + +impl Default for ColorScheme { + fn default() -> Self { + Self { + created: Color::Green, + removed: Color::Red, + changed: Color::Yellow, + header: Color::Blue, + table_name: Color::Cyan, + column_type: Color::Magenta, + section_header: Color::Blue, + access: Color::Green, + warning: Color::Red, + } + } +} + +/// An indent-aware, optionally-coloured text buffer. +/// +/// Shared by the migration-plan formatter and the describe output, so both render +/// with the same colour scheme and the same colour/no-colour handling. +#[derive(Debug)] +pub(crate) struct StyledWriter { + buffer: Buffer, + colors: ColorScheme, + indent_level: usize, + indent_width: usize, +} + +impl StyledWriter { + pub(crate) fn new(style: PrettyPrintStyle, indent_width: usize) -> Self { + Self { + buffer: match style { + PrettyPrintStyle::NoColor => Buffer::no_color(), + PrettyPrintStyle::AnsiColor => Buffer::ansi(), + }, + colors: ColorScheme::default(), + indent_level: 0, + indent_width, + } + } + + pub(crate) fn colors(&self) -> &ColorScheme { + &self.colors + } + + pub(crate) fn indent(&mut self) { + self.indent_level += 1; + } + + pub(crate) fn dedent(&mut self) { + if self.indent_level > 0 { + self.indent_level -= 1; + } + } + + pub(crate) fn write_indent(&mut self) -> io::Result<()> { + let indent = " ".repeat(self.indent_width * self.indent_level); + self.buffer.write_all(indent.as_bytes()) + } + + pub(crate) fn write_plain(&mut self, text: &str) -> io::Result<()> { + self.buffer.write_all(text.as_bytes()) + } + + pub(crate) fn write_line(&mut self, text: impl AsRef) -> io::Result<()> { + self.write_indent()?; + self.buffer.write_all(text.as_ref().as_bytes())?; + self.buffer.write_all(b"\n") + } + + pub(crate) fn write_colored(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + if let Some(c) = color { + spec.set_fg(Some(c)); + } + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + pub(crate) fn write_colored_line(&mut self, text: &str, color: Option, bold: bool) -> io::Result<()> { + self.write_indent()?; + self.write_colored(text, color, bold)?; + self.buffer.write_all(b"\n") + } + + pub(crate) fn write_with_background(&mut self, text: &str, bg: Color, bold: bool) -> io::Result<()> { + let mut spec = ColorSpec::new(); + spec.set_bg(Some(bg)); + if bold { + spec.set_bold(true); + } + self.buffer.set_color(&spec)?; + self.buffer.write_all(text.as_bytes())?; + self.buffer.reset()?; + Ok(()) + } + + pub(crate) fn into_string(self) -> String { + String::from_utf8(self.buffer.into_inner()).expect("StyledWriter only writes &str, so output is UTF-8") + } +} diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 3ec8f5b7141..423d4524e35 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -1580,7 +1580,7 @@ log = "0.4" self.spacetime(&["describe", "--server", &self.server_url, identity.as_str()]) } - /// Describes the database schema anonymously (requires --json). + /// Describes the database schema anonymously, passing `--json` for JSON output. pub fn describe_anon(&self) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; diff --git a/crates/smoketests/tests/cluster/describe.rs b/crates/smoketests/tests/cluster/describe.rs index 72d66c57008..f4caedc93d9 100644 --- a/crates/smoketests/tests/cluster/describe.rs +++ b/crates/smoketests/tests/cluster/describe.rs @@ -1,37 +1,72 @@ use spacetimedb_smoketests::Smoketest; -/// Check describing a module +/// Check describing a module, as human-readable text and as JSON #[test] fn test_describe() { let test = Smoketest::builder().precompiled_module("describe").build(); - let identity = test.database_identity.as_ref().unwrap(); + let identity = test.database_identity.as_ref().unwrap().as_str(); + let describe = |args: &[&str]| -> String { + let full = [&["describe", "--server", test.server_url.as_str()][..], args].concat(); + test.spacetime(&full).unwrap() + }; - // Describe the whole module - test.spacetime(&["describe", "--server", &test.server_url, "--json", identity]) - .unwrap(); + // Describe the whole module: text by default, uncoloured because stdout is piped, and the + // unstable warning goes to stderr. + let text = describe(&[identity]); + for expected in [ + "Tables", + "person (private)", + "name", + "String", + "Reducers", + "add(name: String)", + "say_hello()", + ] { + assert!(text.contains(expected), "expected {expected:?} in:\n{text}"); + } + assert!( + !text.contains('\x1b'), + "piped describe output has ANSI escapes: {text:?}" + ); + assert!( + !text.contains("UNSTABLE"), + "unstable warning leaked into stdout:\n{text}" + ); - // Describe a specific reducer - test.spacetime(&[ - "describe", - "--server", - &test.server_url, - "--json", - identity, - "reducer", - "say_hello", - ]) - .unwrap(); + // Describing a single entity prints only that entity. + assert_eq!( + describe(&[identity, "tables", "person"]), + "person (private)\n Columns:\n name String\n" + ); + assert_eq!(describe(&[identity, "reducers", "say_hello"]), "say_hello()\n"); + + // `--format json` is the same as `--json`, for the whole module and for each entity. + let entities: [&[&str]; 3] = [&[], &["tables", "person"], &["reducers", "say_hello"]]; + for entity in entities { + let parse = |flag: &[&str]| -> serde_json::Value { + let args = [flag, &[identity], entity].concat(); + let out = describe(&args); + serde_json::from_str(&out).unwrap_or_else(|e| panic!("describe {args:?} is not JSON: {e}\n{out}")) + }; + assert_eq!(parse(&["--json"]), parse(&["--format", "json"]), "entity {entity:?}"); + } - // Describe a specific table - test.spacetime(&[ + // `--json` conflicts with an explicit `--format`. + let out = test.spacetime_cmd(&[ "describe", "--server", &test.server_url, "--json", + "--format", + "text", identity, - "table", - "person", - ]) - .unwrap(); + ]); + assert_eq!( + out.status.code(), + Some(2), + "`--json --format text` should be a usage error" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("cannot be used with"), "unexpected stderr: {stderr}"); } diff --git a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md index 3a7d9661e39..0e3176957d7 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md +++ b/docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md @@ -928,7 +928,8 @@ spacetime delete # Delete database spacetime logs # View logs spacetime logs --follow # Stream logs spacetime sql "SELECT * FROM t" # Run SQL query -spacetime describe --json # Show schema +spacetime describe # Show schema (human-readable) +spacetime describe --json # Show schema as JSON (machine-readable) spacetime call reducer arg1 arg2 # Call reducer # Code generation diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index b293fd95c20..d41acc59e7e 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -285,18 +285,24 @@ Run `spacetime help call` for more detailed information. Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime describe [OPTIONS] --json [describe_parts]...` +**Usage:** `spacetime describe [OPTIONS] [describe_parts]...` Run `spacetime help describe` for more detailed information. ###### **Arguments:** -* `` — Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME] +* `` — Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]] ###### **Options:** -* `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. +* `--format ` — Output format for the schema + + Default value: `text` + + Possible values: `text`, `json` + +* `--json` — Output the schema in JSON format. Shorthand for `--format json`. * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index d66ee69e330..229e1028656 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -81,9 +81,11 @@ spacetime logs my-database -f # follow logs spacetime logs my-database -n 100 # up to 100 log lines # Describe schema +# (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database table users --json -spacetime describe my-database reducer my_reducer --json +spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables users --json +spacetime describe my-database reducers my_reducer --json ``` ### Database Management From d07a48220508747355caf0332e8c8d43b729ab8c Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Tue, 22 Sep 2026 07:35:14 +0100 Subject: [PATCH 2/3] Drop a reference to past behaviour from a describe comment. Review feedback on #5947: the comment on the whole-module JSON branch said the output was the raw def "exactly as before". A reader who comes to this code fresh has no idea what "before" was, so the phrase tells them nothing. The comment now just says what the code does. --- crates/cli/src/subcommands/describe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 9373716ae97..14c2aaf95a8 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -142,7 +142,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error serde_json::to_string_pretty(sats::serde::SerdeWrapper::from_ref(v)) } - // The whole-module JSON is the raw, unvalidated def, exactly as before. + // The whole-module JSON is the raw, unvalidated def. if format == Format::Json && matches!(selection, Selection::Module) { // TODO: validate the JSON output println!("{}", sats_to_json(&raw)?); From 7e1f792534aee62ec5c7355335fcba9b22a3400d Mon Sep 17 00:00:00 2001 From: Kris Jenkins Date: Tue, 22 Sep 2026 08:01:09 +0100 Subject: [PATCH 3/3] Let `spacetime describe` show environment variables, and select views, HTTP routes and environment variables. Review feedback on #5947 pointed out that `describe` predates views, environment variables and HTTP handlers, and was never taught about any of them. The human-readable output from the previous change already had Views and HTTP routes sections, but that was as far as it went. Say a module declares `API_KEY` and `MODE` with `#[spacetimedb::env]`. Run `spacetime describe mydb` and they appear nowhere. Ask for them directly with `spacetime describe mydb env`, or for a view with `spacetime describe mydb views top_player`, and the command refuses: the only entity types it knows are tables, reducers, procedures and types. The only way to see a module's environment declarations was to read the raw `--json` dump. The module output gains an Environment variables section, between HTTP routes and Types. Each declaration is a row with the key, its type, and an `optional` flag, aligned like table columns. The type is `String`, or the allowed values as quoted literals joined by `|`: Environment variables API_KEY String LOG_LEVEL "debug" | "info" optional MODE "development" | "production" Optional is a flag rather than `Option<...>` because the value is always a string; the flag only says it may be absent. Literals are quoted with Rust string escaping, so an empty string or one containing spaces stays readable. Three new entity types can be selected, in text or JSON like the existing ones: - `views [NAME]`, with submodule views qualified as usual (`lib.active`). - `routes [PATH]`. A path can be routed once per method, so naming one shows every matching route. - `env [KEY]`, accepting `environment` as well. `env` matches `ctx.env` and `publish --env-only`. Supporting changes: - `crates/schema`: `describe_view(s)`, `describe_http_route(s)`, `describe_env_var(s)` and `sorted_views`, alongside the existing per-entity renderers. - `From for RawHttpRouteDefV10`, used for the routes JSON and by the module-def conversion that previously built it inline. - The describe smoketest module now declares a view, an HTTP route and an optional environment variable, and the smoketest checks each new selector in text and JSON, plus that a missing entity is an error. - The `describe_parts` help lists the entity types; the CLI reference and CLI skill docs are updated to match. --- .../plugins/spacetimedb/skills/cli/SKILL.md | 2 +- crates/cli/src/subcommands/describe.rs | 118 ++++++++- crates/schema/src/def.rs | 20 +- crates/schema/src/describe.rs | 246 +++++++++++++++++- ...describe__tests__describe_module_ansi.snap | 6 + ...ribe__tests__describe_module_no_color.snap | 6 + crates/smoketests/modules/describe/src/lib.rs | 24 +- crates/smoketests/tests/cluster/describe.rs | 35 ++- .../00100-cli-reference.md | 2 +- skills/cli/SKILL.md | 2 +- 10 files changed, 423 insertions(+), 38 deletions(-) diff --git a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md index 229e1028656..e7a51487fc1 100644 --- a/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md +++ b/codex-plugin/plugins/spacetimedb/skills/cli/SKILL.md @@ -83,7 +83,7 @@ spacetime logs my-database -n 100 # up to 100 log lines # Describe schema # (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables --json # also views, reducers, procedures, routes, env, types spacetime describe my-database tables users --json spacetime describe my-database reducers my_reducer --json ``` diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 14c2aaf95a8..5af816b77af 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -8,14 +8,18 @@ use crate::util::{database_identity, get_auth_header}; use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; use spacetimedb_client_api_messages::name::PrettyPrintStyle as EnvStyle; -use spacetimedb_lib::db::raw_def::v10::{RawProcedureDefV10, RawReducerDefV10, RawTableDefV10, RawTypeDefV10}; +use spacetimedb_lib::db::raw_def::v10::{ + RawHttpRouteDefV10, RawProcedureDefV10, RawReducerDefV10, RawTableDefV10, RawTypeDefV10, RawViewDefV10, +}; +use spacetimedb_lib::environment::EnvironmentDeclaration; use spacetimedb_lib::sats; use spacetimedb_schema::auto_migrate::PrettyPrintStyle; -use spacetimedb_schema::def::ModuleDef; +use spacetimedb_schema::def::{HttpRouteDef, ModuleDef}; use spacetimedb_schema::describe::{ - all_named_types, describe_module, describe_procedure, describe_procedures, describe_reducer, describe_reducers, - describe_table, describe_tables, describe_type, describe_types, sorted_procedures, sorted_reducers, sorted_tables, - sorted_types, + all_named_types, describe_env_var, describe_env_vars, describe_http_route, describe_http_routes, describe_module, + describe_procedure, describe_procedures, describe_reducer, describe_reducers, describe_table, describe_tables, + describe_type, describe_types, describe_view, describe_views, sorted_procedures, sorted_reducers, sorted_tables, + sorted_types, sorted_views, }; use std::io::IsTerminal; @@ -26,11 +30,11 @@ pub fn cli() -> clap::Command { .about(format!( "Describe the structure of a database or entities within it. {UNSTABLE_WARNING}" )) - .arg( - Arg::new("describe_parts") - .num_args(0..) - .help("Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]]"), - ) + .arg(Arg::new("describe_parts").num_args(0..).help( + "Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]]. \ + ENTITY_TYPE is one of tables, views, reducers, procedures, routes, env or types. \ + A route is named by its path, and an environment variable by its key.", + )) .arg(common_args::format().help("Output format for the schema")) .arg( Arg::new("json") @@ -53,10 +57,13 @@ pub fn cli() -> clap::Command { #[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)] enum EntityType { + EnvVar, + HttpRoute, Procedure, Reducer, Table, Type, + View, } /// What to describe: the whole module, every entity of one type, or a single named entity. @@ -68,15 +75,22 @@ enum Selection<'a> { } /// Parses an entity type. The plural is canonical, matching the section headings of the module -/// output, but the singular is accepted too. +/// output, but the singular is accepted too. Environment variables are `env`, as in `ctx.env` and +/// `spacetime publish --env-only`, with `environment` accepted too. fn parse_entity_type(entity_type: &str) -> anyhow::Result { match entity_type { + "env" | "environment" => Ok(EntityType::EnvVar), + "routes" | "route" => Ok(EntityType::HttpRoute), "procedures" | "procedure" => Ok(EntityType::Procedure), "reducers" | "reducer" => Ok(EntityType::Reducer), "tables" | "table" => Ok(EntityType::Table), "types" | "type" => Ok(EntityType::Type), + "views" | "view" => Ok(EntityType::View), _ => { - anyhow::bail!("Invalid entity_type '{entity_type}'. Expected one of: procedures, reducers, tables, types.") + anyhow::bail!( + "Invalid entity_type '{entity_type}'. \ + Expected one of: env, procedures, reducers, routes, tables, types, views." + ) } } } @@ -184,6 +198,31 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error } Format::Text => print!("{}", describe_tables(&module_def, text_style())), }, + Selection::All(EntityType::View) => match format { + Format::Json => { + let views: Vec = sorted_views(&module_def) + .into_iter() + .map(|(_, _, view)| view.clone().into()) + .collect(); + println!("{}", sats_to_json(&views)?) + } + Format::Text => print!("{}", describe_views(&module_def, text_style())), + }, + Selection::All(EntityType::HttpRoute) => match format { + Format::Json => { + let routes: Vec = + module_def.http_routes().iter().cloned().map(Into::into).collect(); + println!("{}", sats_to_json(&routes)?) + } + Format::Text => print!("{}", describe_http_routes(&module_def, text_style())), + }, + Selection::All(EntityType::EnvVar) => match format { + Format::Json => { + let declarations: Vec<&EnvironmentDeclaration> = module_def.environment().declarations().collect(); + println!("{}", sats_to_json(&declarations)?) + } + Format::Text => print!("{}", describe_env_vars(&module_def, text_style())), + }, Selection::All(EntityType::Type) => match format { Format::Json => { let types: Vec = sorted_types(&module_def) @@ -225,6 +264,47 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error Format::Text => print!("{}", describe_table(&prefix, owning, table, text_style())), } } + Selection::One(EntityType::View, view_name) => { + let (prefix, owning, view) = module_def + .all_views_with_prefix() + .into_iter() + .find(|(prefix, _, v)| format!("{prefix}{}", v.name) == *view_name) + .context("no such view")?; + match format { + Format::Json => println!("{}", sats_to_json(&RawViewDefV10::from(view.clone()))?), + Format::Text => print!("{}", describe_view(&prefix, owning, view, text_style())), + } + } + Selection::One(EntityType::HttpRoute, path) => { + // A path can be routed once per method, so this can match several routes. + let routes: Vec<&HttpRouteDef> = module_def + .http_routes() + .iter() + .filter(|route| &*route.path == path) + .collect(); + anyhow::ensure!(!routes.is_empty(), "no such route"); + match format { + Format::Json => { + let routes: Vec = routes.into_iter().cloned().map(Into::into).collect(); + println!("{}", sats_to_json(&routes)?) + } + Format::Text => { + for route in routes { + print!("{}", describe_http_route(route, text_style())); + } + } + } + } + Selection::One(EntityType::EnvVar, key) => { + let declaration = module_def + .environment() + .get(key) + .context("no such environment variable")?; + match format { + Format::Json => println!("{}", sats_to_json(declaration)?), + Format::Text => print!("{}", describe_env_var(declaration, text_style())), + } + } Selection::One(EntityType::Type, type_name) => { // Any named type can be looked up, including the row types the listing leaves out. let named = all_named_types(&module_def) @@ -309,10 +389,13 @@ mod tests { #[test] fn entity_type_alone_selects_all_of_that_type() { for (entity_type, expected) in [ + ("env", EntityType::EnvVar), ("procedures", EntityType::Procedure), ("reducers", EntityType::Reducer), + ("routes", EntityType::HttpRoute), ("tables", EntityType::Table), ("types", EntityType::Type), + ("views", EntityType::View), ] { assert_eq!( parse_selection(&parts(&[entity_type])).unwrap(), @@ -324,10 +407,13 @@ mod tests { #[test] fn entity_type_and_name_select_one_entity() { for (entity_type, name, expected) in [ + ("env", "API_KEY", EntityType::EnvVar), ("procedures", "lib.count", EntityType::Procedure), ("reducers", "lib.add", EntityType::Reducer), + ("routes", "/webhook", EntityType::HttpRoute), ("tables", "person", EntityType::Table), ("types", "geo.Point", EntityType::Type), + ("views", "lib.active", EntityType::View), ] { assert_eq!( parse_selection(&parts(&[entity_type, name])).unwrap(), @@ -339,10 +425,13 @@ mod tests { #[test] fn singular_entity_types_are_accepted() { for (singular, plural) in [ + ("environment", "env"), ("procedure", "procedures"), ("reducer", "reducers"), + ("route", "routes"), ("table", "tables"), ("type", "types"), + ("view", "views"), ] { assert_eq!( parse_selection(&parts(&[singular])).unwrap(), @@ -360,7 +449,10 @@ mod tests { for bad in [parts(&["Tables"]), parts(&["columns", "name"])] { let err = parse_selection(&bad).unwrap_err().to_string(); assert!(err.contains("Invalid entity_type"), "{err}"); - assert!(err.contains("procedures, reducers, tables, types"), "{err}"); + assert!( + err.contains("env, procedures, reducers, routes, tables, types, views"), + "{err}" + ); } } diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index e324d93b0ea..0d5ce696ac2 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -1175,14 +1175,8 @@ impl From for RawModuleDefV10 { } if !http_routes.is_empty() { - let raw_http_routes: Vec = http_routes - .into_iter() - .map(|route| RawHttpRouteDefV10 { - handler_function: route.handler_name.into(), - method: route.method, - path: RawIdentifier::new(route.path.as_ref()), - }) - .collect(); + let raw_http_routes: Vec = + http_routes.into_iter().map(RawHttpRouteDefV10::from).collect(); sections.push(RawModuleDefV10Section::HttpRoutes(raw_http_routes)); } @@ -2459,6 +2453,16 @@ pub struct HttpRouteDef { pub path: Box, } +impl From for RawHttpRouteDefV10 { + fn from(val: HttpRouteDef) -> Self { + RawHttpRouteDefV10 { + handler_function: val.handler_name.into(), + method: val.method, + path: RawIdentifier::new(val.path.as_ref()), + } + } +} + impl From for RawProcedureDefV9 { fn from(val: ProcedureDef) -> Self { RawProcedureDefV9 { diff --git a/crates/schema/src/describe.rs b/crates/schema/src/describe.rs index ba14db1ecbf..ead8deb4ed2 100644 --- a/crates/schema/src/describe.rs +++ b/crates/schema/src/describe.rs @@ -6,11 +6,12 @@ //! name joined with `.` (`geo.shapes.Point`). //! //! [`describe_module`] renders a whole module as sections (Tables, Views, Reducers, Procedures, -//! HTTP routes, Types, then Row-level security), omitting empty sections. [`describe_table`], -//! [`describe_reducer`], [`describe_procedure`] and [`describe_type`] render a single entity as it -//! appears in its section, and [`describe_tables`], [`describe_reducers`], [`describe_procedures`] -//! and [`describe_types`] render the contents of a whole section. All of them use two-space -//! indentation, end in a single newline, and never leave trailing whitespace. Column +//! HTTP routes, Environment variables, Types, then Row-level security), omitting empty sections. +//! [`describe_table`], [`describe_view`], [`describe_reducer`], [`describe_procedure`], +//! [`describe_http_route`], [`describe_env_var`] and [`describe_type`] render a single entity as it +//! appears in its section, and [`describe_tables`], [`describe_views`], [`describe_reducers`], +//! [`describe_procedures`], [`describe_http_routes`], [`describe_env_vars`] and [`describe_types`] +//! render the contents of a whole section. All of them use two-space indentation, end in a single newline, and never leave trailing whitespace. Column //! widths are worked out from the uncoloured text, so the `AnsiColor` and `NoColor` styles align //! identically. @@ -22,6 +23,7 @@ use convert_case::{Case, Casing}; use itertools::Itertools; use spacetimedb_lib::db::raw_def::v10::MethodOrAny; use spacetimedb_lib::db::raw_def::v9::{Lifecycle, TableAccess}; +use spacetimedb_lib::environment::{EnvVarType, EnvironmentDeclaration}; use spacetimedb_lib::http::Method as HttpMethod; use spacetimedb_primitives::ColId; use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type; @@ -111,8 +113,8 @@ const BUFFER_WRITE: &str = "writing to an in-memory buffer cannot fail"; /// Renders the whole module as human-readable text, one section per kind of entity. /// -/// The sections are Tables, Views, Reducers, Procedures, HTTP routes, Types and Row-level -/// security, in that order. Empty sections are omitted, so an empty module renders as `""`. +/// The sections are Tables, Views, Reducers, Procedures, HTTP routes, Environment variables, Types +/// and Row-level security, in that order. Empty sections are omitted, so an empty module renders as `""`. pub fn describe_module(def: &ModuleDef, style: PrettyPrintStyle) -> String { let mut w = StyledWriter::new(style, INDENT_WIDTH); write_module(&mut w, def).expect(BUFFER_WRITE); @@ -130,6 +132,16 @@ pub fn describe_table(prefix: &NamespacePath, owning: &ModuleDef, table: &TableD w.into_string() } +/// Renders a single view's row, as it appears under `Views` in [`describe_module`] but unindented. +/// +/// `prefix` and `owning` are the namespace path and owning module returned alongside `view` by +/// [`ModuleDef::all_views_with_prefix`]. +pub fn describe_view(prefix: &NamespacePath, owning: &ModuleDef, view: &ViewDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_view_row(&mut w, prefix, owning, view).expect(BUFFER_WRITE); + w.into_string() +} + /// Renders a single reducer's row, as it appears under `Reducers` in [`describe_module`] but /// unindented. /// @@ -158,6 +170,22 @@ pub fn describe_procedure( w.into_string() } +/// Renders a single HTTP route's row, as it appears under `HTTP routes` in [`describe_module`] but +/// unindented. +pub fn describe_http_route(route: &HttpRouteDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_http_route_row(&mut w, route).expect(BUFFER_WRITE); + w.into_string() +} + +/// Renders a single environment variable's row, as it appears under `Environment variables` in +/// [`describe_module`] but unindented, and aligned on its own rather than with the other rows. +pub fn describe_env_var(declaration: &EnvironmentDeclaration, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_env_var_rows(&mut w, &[declaration]).expect(BUFFER_WRITE); + w.into_string() +} + /// Renders a single named type's row, as it appears under `Types` in [`describe_module`] but /// unindented. /// @@ -179,6 +207,18 @@ pub fn describe_tables(def: &ModuleDef, style: PrettyPrintStyle) -> String { w.into_string() } +/// Renders every view in the module, including those in submodules, one row each as +/// [`describe_view`] renders it, in the order [`describe_module`] lists them. +/// +/// A module with no views renders as `""`. +pub fn describe_views(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for (prefix, owning, view) in sorted_views(def) { + write_view_row(&mut w, &prefix, owning, view).expect(BUFFER_WRITE); + } + w.into_string() +} + /// Renders every reducer in the module, including those in submodules, one row each as /// [`describe_reducer`] renders it, in the order [`describe_module`] lists them. /// @@ -203,6 +243,27 @@ pub fn describe_procedures(def: &ModuleDef, style: PrettyPrintStyle) -> String { w.into_string() } +/// Renders the module's HTTP routes, one row each as [`describe_http_route`] renders it, in +/// declaration order. +/// +/// A module with no HTTP routes renders as `""`. +pub fn describe_http_routes(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + for route in def.http_routes() { + write_http_route_row(&mut w, route).expect(BUFFER_WRITE); + } + w.into_string() +} + +/// Renders the module's environment variable declarations, one aligned row each, sorted by name. +/// +/// A module with no environment variables renders as `""`. +pub fn describe_env_vars(def: &ModuleDef, style: PrettyPrintStyle) -> String { + let mut w = StyledWriter::new(style, INDENT_WIDTH); + write_env_var_rows(&mut w, &def.environment().declarations().collect_vec()).expect(BUFFER_WRITE); + w.into_string() +} + /// Renders the named types [`describe_module`] lists under `Types`, one row each as /// [`describe_type`] renders it, in the same order. /// @@ -224,6 +285,15 @@ pub fn sorted_tables(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &TableD .collect_vec() } +/// Every view in the module, including those in submodules, sorted by qualified name: the order +/// in which [`describe_module`] and [`describe_views`] list them. +pub fn sorted_views(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ViewDef)> { + def.all_views_with_prefix() + .into_iter() + .sorted_by_cached_key(|(prefix, _, view)| format!("{prefix}{}", view.name)) + .collect_vec() +} + /// Every reducer in the module, including those in submodules, sorted by qualified name: the order /// in which [`describe_module`] and [`describe_reducers`] list them. pub fn sorted_reducers(def: &ModuleDef) -> Vec<(NamespacePath, &ModuleDef, &ReducerDef)> { @@ -304,15 +374,13 @@ pub fn all_named_types(def: &ModuleDef) -> Vec> { fn write_module(w: &mut StyledWriter, def: &ModuleDef) -> io::Result<()> { let tables = sorted_tables(def); - let views = def - .all_views_with_prefix() - .into_iter() - .sorted_by_cached_key(|(prefix, _, view)| format!("{prefix}{}", view.name)) - .collect_vec(); + let views = sorted_views(def); let reducers = sorted_reducers(def); let procedures = sorted_procedures(def); // Only the root module's routes are served, and route order matters, so keep declaration order. let http_routes = def.http_routes(); + // Only the root module declares environment variables, and the schema keeps them sorted by name. + let env_vars = def.environment().declarations().collect_vec(); let types = sorted_types(def); let row_level_security = def.row_level_security().map(|rls| &*rls.sql).sorted().collect_vec(); @@ -343,6 +411,12 @@ fn write_module(w: &mut StyledWriter, def: &ModuleDef) -> io::Result<()> { write_section_header(w, &mut wrote_section, "HTTP routes")?; write_rows(w, http_routes, write_http_route_row)?; } + if !env_vars.is_empty() { + write_section_header(w, &mut wrote_section, "Environment variables")?; + w.indent(); + write_env_var_rows(w, &env_vars)?; + w.dedent(); + } if !types.is_empty() { write_section_header(w, &mut wrote_section, "Types")?; write_rows(w, &types, write_type_row)?; @@ -532,6 +606,50 @@ fn http_method_name(method: &MethodOrAny) -> String { } } +/// Writes environment variable declarations as aligned rows: `NAME type optional`. +/// +/// The type is `String` for any string, or the allowed values as quoted literals separated by `|`. +/// Optional declarations are flagged rather than wrapped in `Option<...>`, because a value is +/// always a string: the flag only says that it may be absent. +fn write_env_var_rows(w: &mut StyledWriter, declarations: &[&EnvironmentDeclaration]) -> io::Result<()> { + let rows = declarations + .iter() + .map(|declaration| { + ( + &*declaration.name, + env_var_type_name(&declaration.ty), + declaration.optional, + ) + }) + .collect_vec(); + let name_width = rows.iter().map(|(name, _, _)| text_width(name)).max().unwrap_or(0) + 2; + let type_width = rows.iter().map(|(_, ty, _)| text_width(ty)).max().unwrap_or(0) + 2; + for (name, ty, optional) in rows { + w.write_indent()?; + w.write_colored(name, Some(w.colors().table_name), true)?; + w.write_plain(&" ".repeat(name_width - text_width(name)))?; + w.write_colored(&ty, Some(w.colors().column_type), false)?; + if optional { + w.write_plain(&" ".repeat(type_width - text_width(&ty)))?; + w.write_colored("optional", Some(w.colors().access), false)?; + } + w.write_plain("\n")?; + } + Ok(()) +} + +/// The spelling of an environment variable's type: `String`, `"literal"`, or `"a" | "b"`. +/// +/// Literals are quoted and escaped like Rust strings, so an empty string or one with spaces or +/// quotes stays unambiguous. +fn env_var_type_name(ty: &EnvVarType) -> String { + match ty { + EnvVarType::String => "String".to_owned(), + EnvVarType::StringLiteral(literal) => format!("{literal:?}"), + EnvVarType::Union(literals) => literals.iter().map(|literal| format!("{literal:?}")).join(" | "), + } +} + /// Writes a section heading, preceded by a blank line unless it is the first section. fn write_section_header(w: &mut StyledWriter, wrote_section: &mut bool, title: &str) -> io::Result<()> { if std::mem::replace(wrote_section, true) { @@ -1245,6 +1363,20 @@ mod tests { builder.add_http_route("webhook", MethodOrAny::Method(HttpMethod::Post), "/webhook"); // Declared after `/webhook`, so the output shows declaration order is kept. builder.add_http_route("health", MethodOrAny::Any, "/health"); + builder.add_environment(vec![ + env_declaration("API_KEY", EnvVarType::String, false), + env_declaration( + "MODE", + EnvVarType::Union(vec!["production".into(), "development".into()]), + false, + ), + env_declaration("REGION", EnvVarType::StringLiteral("eu west".into()), true), + env_declaration( + "LOG_LEVEL", + EnvVarType::Union(vec!["info".into(), "debug".into()]), + true, + ), + ]); builder.add_row_level_security("SELECT * FROM player WHERE rank > 0"); // Declared second but sorts first. builder.add_row_level_security("SELECT * FROM match_result WHERE round > 0"); @@ -1282,6 +1414,14 @@ mod tests { .expect("the describe fixture should be a valid module definition") } + fn env_declaration(name: &str, ty: EnvVarType, optional: bool) -> EnvironmentDeclaration { + EnvironmentDeclaration { + name: name.into(), + ty, + optional, + } + } + fn table_with_prefix<'a>(def: &'a ModuleDef, name: &str) -> (NamespacePath, &'a ModuleDef, &'a TableDef) { def.all_tables_with_prefix() .into_iter() @@ -1409,6 +1549,85 @@ mod tests { assert_eq!(procedures, module_section(&module, "Procedures")); } + #[test] + fn describe_views_matches_the_module_views_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let views = describe_views(&def, PrettyPrintStyle::NoColor); + assert_eq!( + views, + "players_above_rank(min_rank: U32) -> Array [public]\n\ + top_player() -> Option [public] [anonymous]\n" + ); + assert_eq!(views, module_section(&module, "Views")); + } + + #[test] + fn describe_view_renders_one_row() { + let def = describe_fixture(); + let (prefix, owning, view) = sorted_views(&def) + .into_iter() + .find(|(_, _, view)| &*view.name == "top_player") + .expect("view should exist"); + assert_eq!( + describe_view(&prefix, owning, view, PrettyPrintStyle::NoColor), + "top_player() -> Option [public] [anonymous]\n" + ); + } + + #[test] + fn describe_http_routes_matches_the_module_http_routes_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let routes = describe_http_routes(&def, PrettyPrintStyle::NoColor); + assert_eq!(routes, "POST /webhook → webhook\nANY /health → health\n"); + assert_eq!(routes, module_section(&module, "HTTP routes")); + assert_eq!( + describe_http_route(&def.http_routes()[1], PrettyPrintStyle::NoColor), + "ANY /health → health\n" + ); + } + + #[test] + fn describe_env_vars_matches_the_module_environment_section() { + let def = describe_fixture(); + let module = describe_module(&def, PrettyPrintStyle::NoColor); + let env_vars = describe_env_vars(&def, PrettyPrintStyle::NoColor); + // Sorted by name, with union members sorted too. + assert_eq!( + env_vars, + "API_KEY String\n\ + LOG_LEVEL \"debug\" | \"info\" optional\n\ + MODE \"development\" | \"production\"\n\ + REGION \"eu west\" optional\n" + ); + assert_eq!(env_vars, module_section(&module, "Environment variables")); + } + + #[test] + fn describe_env_var_aligns_on_its_own() { + let def = describe_fixture(); + let region = def.environment().get("REGION").expect("declaration should exist"); + assert_eq!( + describe_env_var(region, PrettyPrintStyle::NoColor), + "REGION \"eu west\" optional\n" + ); + let api_key = def.environment().get("API_KEY").expect("declaration should exist"); + assert_eq!( + describe_env_var(api_key, PrettyPrintStyle::NoColor), + "API_KEY String\n" + ); + } + + #[test] + fn env_var_literals_are_quoted_and_escaped() { + assert_eq!(env_var_type_name(&EnvVarType::StringLiteral(String::new())), "\"\""); + assert_eq!( + env_var_type_name(&EnvVarType::StringLiteral("say \"hi\"".into())), + "\"say \\\"hi\\\"\"" + ); + } + #[test] fn describe_types_matches_the_module_types_section() { let def = describe_fixture(); @@ -1466,8 +1685,11 @@ mod tests { fn listings_of_an_empty_module_are_empty() { let def = empty_module(); assert_eq!(describe_tables(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_views(&def, PrettyPrintStyle::NoColor), ""); assert_eq!(describe_reducers(&def, PrettyPrintStyle::NoColor), ""); assert_eq!(describe_procedures(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_http_routes(&def, PrettyPrintStyle::NoColor), ""); + assert_eq!(describe_env_vars(&def, PrettyPrintStyle::NoColor), ""); assert_eq!(describe_types(&def, PrettyPrintStyle::NoColor), ""); } diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap index a3aa71a8791..c0bb052da26 100644 --- a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_ansi.snap @@ -69,6 +69,12 @@ expression: "describe_module(&def, PrettyPrintStyle::AnsiColor)" POST /webhook → webhook ANY /health → health +Environment variables + API_KEY String + LOG_LEVEL "debug" | "info" optional + MODE "development" | "production" + REGION "eu west" optional + Types Color = red | green | blue Direction = north | south | east | west diff --git a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap index fb62bb1dde4..1168a51a753 100644 --- a/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap +++ b/crates/schema/src/snapshots/spacetimedb_schema__describe__tests__describe_module_no_color.snap @@ -69,6 +69,12 @@ HTTP routes POST /webhook → webhook ANY /health → health +Environment variables + API_KEY String + LOG_LEVEL "debug" | "info" optional + MODE "development" | "production" + REGION "eu west" optional + Types Color = red | green | blue Direction = north | south | east | west diff --git a/crates/smoketests/modules/describe/src/lib.rs b/crates/smoketests/modules/describe/src/lib.rs index c6e695e24cb..0828f258d73 100644 --- a/crates/smoketests/modules/describe/src/lib.rs +++ b/crates/smoketests/modules/describe/src/lib.rs @@ -1,4 +1,11 @@ -use spacetimedb::{log, ReducerContext, Table}; +use spacetimedb::http::{Body, HandlerContext, Request, Response, Router}; +use spacetimedb::{log, ReducerContext, Table, ViewContext}; + +#[spacetimedb::env] +pub struct Env { + #[env(values("en", "fr"))] + pub LANGUAGE: Option, +} #[spacetimedb::table(accessor = person)] pub struct Person { @@ -17,3 +24,18 @@ pub fn say_hello(ctx: &ReducerContext) { } log::info!("Hello, World!"); } + +#[spacetimedb::view(accessor = nobody, public)] +pub fn nobody(_ctx: &ViewContext) -> Option { + None +} + +#[spacetimedb::http::handler] +fn health(_ctx: &mut HandlerContext, _req: Request) -> Response { + Response::new(Body::from_bytes("ok")) +} + +#[spacetimedb::http::router] +fn router() -> Router { + Router::new().get("/health", health) +} diff --git a/crates/smoketests/tests/cluster/describe.rs b/crates/smoketests/tests/cluster/describe.rs index f4caedc93d9..7b02e0fda8f 100644 --- a/crates/smoketests/tests/cluster/describe.rs +++ b/crates/smoketests/tests/cluster/describe.rs @@ -22,6 +22,9 @@ fn test_describe() { "Reducers", "add(name: String)", "say_hello()", + "Views", + "HTTP routes", + "Environment variables", ] { assert!(text.contains(expected), "expected {expected:?} in:\n{text}"); } @@ -40,9 +43,39 @@ fn test_describe() { "person (private)\n Columns:\n name String\n" ); assert_eq!(describe(&[identity, "reducers", "say_hello"]), "say_hello()\n"); + assert_eq!( + describe(&[identity, "views", "nobody"]), + "nobody() -> Option [public]\n" + ); + assert_eq!(describe(&[identity, "routes", "/health"]), "GET /health → health\n"); + assert_eq!( + describe(&[identity, "env", "LANGUAGE"]), + "LANGUAGE \"en\" | \"fr\" optional\n" + ); + + // An entity that doesn't exist is an error, not empty output. + for entity in [["views", "missing"], ["routes", "/missing"], ["env", "MISSING"]] { + let args = [ + &["describe", "--server", test.server_url.as_str(), identity][..], + &entity[..], + ] + .concat(); + let out = test.spacetime_cmd(&args); + assert!(!out.status.success(), "describe {entity:?} should fail"); + } // `--format json` is the same as `--json`, for the whole module and for each entity. - let entities: [&[&str]; 3] = [&[], &["tables", "person"], &["reducers", "say_hello"]]; + let entities: [&[&str]; 9] = [ + &[], + &["tables", "person"], + &["reducers", "say_hello"], + &["views"], + &["views", "nobody"], + &["routes"], + &["routes", "/health"], + &["env"], + &["env", "LANGUAGE"], + ]; for entity in entities { let parse = |flag: &[&str]| -> serde_json::Value { let args = [flag, &[identity], entity].concat(); diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index d41acc59e7e..cd18db04c0e 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -292,7 +292,7 @@ Run `spacetime help describe` for more detailed information. ###### **Arguments:** -* `` — Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]] +* `` — Describe arguments: [DATABASE] [ENTITY_TYPE [ENTITY_NAME]]. ENTITY_TYPE is one of tables, views, reducers, procedures, routes, env or types. A route is named by its path, and an environment variable by its key. ###### **Options:** diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index 229e1028656..e7a51487fc1 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -83,7 +83,7 @@ spacetime logs my-database -n 100 # up to 100 log lines # Describe schema # (without --json, output is human-readable text) spacetime describe my-database --json -spacetime describe my-database tables --json # also reducers, procedures, types +spacetime describe my-database tables --json # also views, reducers, procedures, routes, env, types spacetime describe my-database tables users --json spacetime describe my-database reducers my_reducer --json ```