///|
/// Severity order used when a caller compares risks or configures a policy
/// threshold. `Safe` is `0`, `Review` is `1`, `Destructive` is `2`.
pub fn Risk::severity(self : Risk) -> Int {
match self {
Safe => 0
Review => 1
Destructive => 2
}
}
///|
/// The stable lowercase spelling used in SQL comments, reports and the CLI.
pub fn Risk::label(self : Risk) -> String {
match self {
Safe => "safe"
Review => "review"
Destructive => "destructive"
}
}
///|
/// Parse the spelling produced by `Risk::label`. Unknown input returns `None`
/// rather than silently selecting a weaker policy.
pub fn Risk::parse(text : String) -> Risk? {
match text {
"safe" => Some(Safe)
"review" => Some(Review)
"destructive" => Some(Destructive)
_ => None
}
}
///|
/// The stable lowercase spelling of a dialect.
pub fn Dialect::label(self : Dialect) -> String {
match self {
SQLite => "sqlite"
PostgreSQL => "postgresql"
}
}
///|
/// Parse a dialect name. `postgres` is accepted as an alias for `postgresql`
/// because both spellings are common in tooling.
pub fn Dialect::parse(text : String) -> Dialect? {
match text {
"sqlite" => Some(SQLite)
"postgres" | "postgresql" => Some(PostgreSQL)
_ => None
}
}
///|
/// Step counts per risk level, used for policy decisions and for the headline
/// of a review report.
pub(all) struct PlanSummary {
total : Int
safe : Int
review : Int
destructive : Int
} derive(Eq, Debug, ToJson, FromJson)
///|
pub fn Plan::summary(self : Plan) -> PlanSummary {
let mut safe = 0
let mut review = 0
let mut destructive = 0
for step in self.steps {
match step.risk {
Safe => safe = safe + 1
Review => review = review + 1
Destructive => destructive = destructive + 1
}
}
{ total: self.steps.length(), safe, review, destructive, }
}
///|
/// The highest risk present in the plan. An empty plan is `Safe`.
pub fn Plan::max_risk(self : Plan) -> Risk {
let mut highest = Risk::Safe
for step in self.steps {
if step.risk.severity() > highest.severity() {
highest = step.risk
}
}
highest
}
///|
/// Every step whose risk exceeds `max_risk`. An empty result means the plan
/// satisfies the policy.
pub fn Plan::steps_above(self : Plan, max_risk : Risk) -> Array[MigrationStep] {
self.steps.filter(step => step.risk.severity() > max_risk.severity())
}
///|
/// A short, stable description of a change, suitable for a report table or a
/// commit message. It never includes SQL.
pub fn Change::describe(self : Change) -> String {
match self {
AddTable(table) => "create table \{table.name}"
DropTable(table) => "drop table \{table.name}"
RenameTable(before, after) => "rename table \{before} to \{after}"
AddColumn(table, column) => "add column \{table}.\{column.name}"
DropColumn(table, column) => "drop column \{table}.\{column.name}"
RenameColumn(table, before, after) =>
"rename column \{table}.\{before} to \{table}.\{after}"
AlterColumn(table, _, after) => "alter column \{table}.\{after.name}"
AddIndex(table, index) => "create index \{index.name} on \{table}"
DropIndex(table, index) => "drop index \{index.name} on \{table}"
AddForeignKey(table, foreign_key) =>
"add foreign key \{foreign_key.name} on \{table}"
DropForeignKey(table, foreign_key) =>
"drop foreign key \{foreign_key.name} on \{table}"
AddCheck(table, check) => "add check \{check.name} on \{table}"
DropCheck(table, check) => "drop check \{check.name} on \{table}"
AddTrigger(table, trigger) => "add trigger \{trigger.name} on \{table}"
DropTrigger(table, trigger) => "drop trigger \{trigger.name} on \{table}"
AddView(view) => "create view \{view.name}"
DropView(view) => "drop view \{view.name}"
RebuildTable(_, after, _) => "rebuild table \{after.name}"
}
}
///|
/// Escape the characters that would otherwise break a GitHub-flavoured
/// Markdown table cell. Identifiers come from configuration, not from a
/// database, but a review artefact still must not be corrupted by them.
fn markdown_cell(text : String) -> String {
text.replace_all(old="\\", new="\\\\").replace_all(old="|", new="\\|")
}
///|
fn summary_rows(plan : Plan) -> Array[String] {
let summary = plan.summary()
[
"| Risk | Steps |",
"| --- | --- |",
"| safe | \{summary.safe} |",
"| review | \{summary.review} |",
"| destructive | \{summary.destructive} |",
"| **total** | **\{summary.total}** |",
]
}
///|
/// Render a plan as a deterministic Markdown review document. The output is
/// intended to be attached to a pull request so that a human approves the
/// migration before any SQL runs.
pub fn Plan::to_markdown(self : Plan) -> String {
let lines : Array[String] = [
"# Migration plan",
"",
"| Field | Value |",
"| --- | --- |",
"| From version | `\{markdown_cell(self.from_version)}` |",
"| To version | `\{markdown_cell(self.to_version)}` |",
"| Dialect | `\{self.dialect.label()}` |",
"| Highest risk | `\{self.max_risk().label()}` |",
"",
"## Risk summary",
"",
]
lines.push_iter(summary_rows(self).iter())
lines.push("")
lines.push("## Steps")
lines.push("")
if self.steps.is_empty() {
lines.push("No schema changes were detected.")
} else {
lines.push("| Step | Risk | Change | Reason |")
lines.push("| --- | --- | --- | --- |")
for step in self.steps {
let id = markdown_cell(step.id)
let risk = step.risk.label()
let change = markdown_cell(step.change.describe())
let reason = markdown_cell(step.reason)
lines.push("| \{id} | \{risk} | \{change} | \{reason} |")
}
}
lines.push("")
lines.join("\n")
}
///|
/// Render a plan together with the SQL that was approved for it.
pub fn RenderedPlan::to_markdown(self : RenderedPlan) -> String {
let lines : Array[String] = [self.plan.to_markdown(), "## SQL", "", "```sql"]
lines.push_iter(self.sql.iter())
lines.push("```")
lines.push("")
lines.join("\n")
}