// `mctl migrate` (← goctl's `migrate`): the SQL a spec's types imply.
//
// A `.api` spec describes the shapes a service carries, and those shapes are, more
// often than not, also the tables the service stores. `generate_migrations` reads
// every `type` block as a table: one column per field, the field's name as the
// column, its type as a SQL type, and the `id` field as the primary key.
//
// It is a starting point on purpose — a spec knows the shape of a value, not
// whether it is unique, indexed, or a foreign key. The generated file says so at
// the top, so nobody mistakes it for a schema a database already agrees with.
///|
/// The SQL type a MoonBit field type is stored as, in MySQL's spelling. A structure
/// a database has no column type for — a list, a map, another `type` block — is
/// `JSON`, which every engine mctl targets accepts.
fn sql_type(t : String) -> String {
let s = @text.trim(t)
if @text.ends_with(s, "?") {
return sql_type(s[0:s.length() - 1].to_owned())
}
if @text.starts_with(s, "Array[") || @text.starts_with(s, "Map[") {
return "JSON"
}
match s {
"String" => "VARCHAR(191)"
"Bytes" => "BLOB"
"Bool" => "TINYINT(1)"
"Int" | "Int32" => "INT"
"Int64" => "BIGINT"
"UInt" | "UInt32" => "INT UNSIGNED"
"UInt64" => "BIGINT UNSIGNED"
"Byte" => "TINYINT"
"Float" => "FLOAT"
"Double" => "DOUBLE"
"Json" => "JSON"
_ => "JSON"
}
}
///|
/// The table a type block becomes: its name in `snake_case`, pluralised the way a
/// table usually is. A name that already ends in `s` is left alone, so `Address`
/// does not become `addresss`.
fn table_name(type_name : String) -> String {
let base = @text.to_snake(type_name)
if @text.ends_with(base, "s") {
base
} else {
base + "s"
}
}
///|
/// Whether a field is the primary key: the one called `id`, which is the one every
/// other row is referred to by.
fn is_primary(f : @api.Field) -> Bool {
f.mbt_name() == "id" || f.json_name() == "id"
}
///|
/// One `type` block as one `CREATE TABLE`, or `None` for a block with no field —
/// there is nothing to create, and an empty table is a mistake rather than a
/// schema.
fn create_table(t : @api.TypeDef, types : Array[@api.TypeDef]) -> String? {
let fields = @api.flat_fields(t, types)
if fields.length() == 0 {
return None
}
let out : Array[String] = []
let table = table_name(t.name)
out.push("-- " + t.name)
out.push("CREATE TABLE IF NOT EXISTS `" + table + "` (")
let mut key = ""
let lines : Array[String] = []
for f in fields {
let column = @text.to_snake(f.json_name())
let mut line = " `" +
@text.to_snake(f.json_name()) +
"` " +
sql_type(f.type_)
if is_primary(f) && sql_type(f.type_) != "JSON" {
line = line + " NOT NULL"
key = column
} else if f.optional() {
line = line + " NULL"
} else {
line = line + " NOT NULL"
}
lines.push(line)
}
if key != "" {
lines.push(" PRIMARY KEY (`" + key + "`)")
}
for i = 0; i < lines.length(); i = i + 1 {
out.push(lines[i] + (if i + 1 < lines.length() { "," } else { "" }))
}
out.push(");")
out.push("")
Some(@text.join_lines(out))
}
///|
/// The migration file for a spec: one `CREATE TABLE` per type. `service` names the
/// file and the header, so two specs migrated into one directory do not look alike.
pub fn generate_migrations(spec : @api.Spec) -> String {
let out : Array[String] = []
out.push(
"-- mctl migrate: " +
(if spec.service == "" { "schema" } else { spec.service }),
)
out.push("--")
out.push(
"-- One table per `type` block of the spec. A spec says what a value looks",
)
out.push(
"-- like, not what the database should enforce: indexes, uniqueness and foreign",
)
out.push(
"-- keys are yours to add. Types used only as requests or responses are tables",
)
out.push("-- here too — drop the ones that are not stored.")
out.push("")
let mut wrote = false
for t in spec.types {
match create_table(t, spec.types) {
Some(sql) => {
out.push(sql)
wrote = true
}
None => ()
}
}
if wrote == false {
out.push(
"-- the spec declares no type with a field, so there is nothing to create",
)
}
@text.join_lines(out)
}
///|
/// The name a migration file takes: `greet` → `greet_migrate.sql`.
pub fn migrate_file_name(service : String) -> String {
(if service == "" { "schema" } else { @text.to_snake(service) }) +
"_migrate.sql"
}