///|
/// Compatibility classification for schema evolution.
pub(all) enum ChangeKind {
Compatible
Warning
Breaking
} derive(Debug, Eq, ToJson)
///|
/// One stable schema-evolution finding.
pub(all) struct SchemaChange {
code : String
kind : ChangeKind
path : String
message : String
} derive(Debug, Eq, ToJson)
///|
fn change(
code : String,
kind : ChangeKind,
path : String,
message : String,
) -> SchemaChange {
{ code, kind, path, message, }
}
///|
fn find_definition(schema : Schema, name : String) -> Definition? {
for definition in schema.definitions {
if definition.name() == name {
return Some(definition)
}
}
None
}
///|
fn find_field(fields : ArrayView[Field], id : Int) -> Field? {
for field in fields {
if field.id == id {
return Some(field)
}
}
None
}
///|
fn find_enum_value(members : ArrayView[EnumMember], value : Int) -> EnumMember? {
for enum_member in members {
if enum_member.value == value {
return Some(enum_member)
}
}
None
}
///|
fn find_function(
functions : ArrayView[FunctionDef],
name : String,
) -> FunctionDef? {
for function in functions {
if function.name == name {
return Some(function)
}
}
None
}
///|
fn compare_fields(
path : String,
before_fields : ArrayView[Field],
after_fields : ArrayView[Field],
changes : Array[SchemaChange],
) -> Unit {
for before_field in before_fields {
let field_path = "\{path}.\{before_field.name}#\{before_field.id}"
match find_field(after_fields, before_field.id) {
None => {
let kind = if before_field.requiredness is Required {
Breaking
} else {
Warning
}
changes.push(
change(
"MTHC103",
kind,
field_path,
"field id \{before_field.id} was removed",
),
)
}
Some(after_field) => {
if before_field.ty != after_field.ty {
changes.push(
change(
"MTHC104",
Breaking,
field_path,
"field type changed from \{Repr(before_field.ty)} to \{Repr(after_field.ty)}",
),
)
}
if before_field.name != after_field.name {
changes.push(
change(
"MTHC105",
Warning,
field_path,
"field name changed to `\{after_field.name}` while retaining its id",
),
)
}
if !(before_field.requiredness is Required) &&
after_field.requiredness is Required {
changes.push(
change("MTHC106", Breaking, field_path, "field became required"),
)
}
}
}
}
for after_field in after_fields {
if find_field(before_fields, after_field.id) is None {
let field_path = "\{path}.\{after_field.name}#\{after_field.id}"
let kind = if after_field.requiredness is Required {
Breaking
} else {
Compatible
}
changes.push(
change(
"MTHC107",
kind,
field_path,
if kind is Breaking {
"new required field cannot be read from older payloads"
} else {
"optional field was added"
},
),
)
}
}
}
///|
fn compare_enums(
path : String,
before_members : ArrayView[EnumMember],
after_members : ArrayView[EnumMember],
changes : Array[SchemaChange],
) -> Unit {
for before_member in before_members {
match find_enum_value(after_members, before_member.value) {
None =>
changes.push(
change(
"MTHC108",
Breaking,
"\{path}.\{before_member.name}",
"enum value \{before_member.value} was removed",
),
)
Some(after_member) if before_member.name != after_member.name =>
changes.push(
change(
"MTHC109",
Warning,
"\{path}.\{before_member.name}",
"enum value \{before_member.value} was renamed to `\{after_member.name}`",
),
)
_ => ()
}
}
for after_member in after_members {
if find_enum_value(before_members, after_member.value) is None {
changes.push(
change(
"MTHC110",
Compatible,
"\{path}.\{after_member.name}",
"enum value \{after_member.value} was added",
),
)
}
}
}
///|
fn compare_functions(
path : String,
before_functions : ArrayView[FunctionDef],
after_functions : ArrayView[FunctionDef],
changes : Array[SchemaChange],
) -> Unit {
for before_function in before_functions {
let function_path = "\{path}.\{before_function.name}"
match find_function(after_functions, before_function.name) {
None =>
changes.push(
change(
"MTHC111",
Breaking,
function_path,
"service function was removed",
),
)
Some(after_function) => {
if before_function.return_type != after_function.return_type ||
before_function.oneway != after_function.oneway {
changes.push(
change(
"MTHC112",
Breaking,
function_path,
"return type or oneway behavior changed",
),
)
}
compare_fields(
"\{function_path}.args",
before_function.arguments,
after_function.arguments,
changes,
)
compare_fields(
"\{function_path}.throws",
before_function.throws,
after_function.throws,
changes,
)
}
}
}
for after_function in after_functions {
if find_function(before_functions, after_function.name) is None {
changes.push(
change(
"MTHC113",
Compatible,
"\{path}.\{after_function.name}",
"service function was added",
),
)
}
}
}
///|
fn compare_definition(
before_definition : Definition,
after_definition : Definition,
changes : Array[SchemaChange],
) -> Unit {
let path = before_definition.name()
if before_definition.kind() != after_definition.kind() {
changes.push(
change(
"MTHC102",
Breaking,
path,
"definition kind changed from \{before_definition.kind()} to \{after_definition.kind()}",
),
)
return
}
match (before_definition, after_definition) {
(Typedef(target=before_target, ..), Typedef(target=after_target, ..)) =>
if before_target != after_target {
changes.push(
change("MTHC114", Breaking, path, "typedef target changed"),
)
}
(Enum(members=before_members, ..), Enum(members=after_members, ..)) =>
compare_enums(path, before_members, after_members, changes)
(
Struct(fields=before_fields, ..)
| Union(fields=before_fields, ..)
| Exception(fields=before_fields, ..),
Struct(fields=after_fields, ..)
| Union(fields=after_fields, ..)
| Exception(fields=after_fields, ..),
) => compare_fields(path, before_fields, after_fields, changes)
(
Service(extends=before_extends, functions=before_functions, ..),
Service(extends=after_extends, functions=after_functions, ..),
) => {
if before_extends != after_extends {
changes.push(
change("MTHC115", Breaking, path, "service inheritance changed"),
)
}
compare_functions(path, before_functions, after_functions, changes)
}
(
Const(ty=before_type, value=before_value, ..),
Const(ty=after_type, value=after_value, ..),
) =>
if before_type != after_type || before_value != after_value {
changes.push(
change("MTHC116", Warning, path, "constant type or value changed"),
)
}
_ => ()
}
}
///|
/// Compares two checked schemas by stable field IDs, enum values, and service
/// method names. Results are ordered deterministically by the old schema first,
/// followed by additions from the new schema.
pub fn compare_schemas(before : Schema, after : Schema) -> Array[SchemaChange] {
let changes : Array[SchemaChange] = []
for before_definition in before.definitions {
match find_definition(after, before_definition.name()) {
None =>
changes.push(
change(
"MTHC101",
Breaking,
before_definition.name(),
"definition was removed",
),
)
Some(after_definition) =>
compare_definition(before_definition, after_definition, changes)
}
}
for after_definition in after.definitions {
if find_definition(before, after_definition.name()) is None {
changes.push(
change(
"MTHC117",
Compatible,
after_definition.name(),
"definition was added",
),
)
}
}
changes
}
///|
pub fn has_breaking_changes(changes : ArrayView[SchemaChange]) -> Bool {
changes.any(item => item.kind is Breaking)
}
///|
pub fn SchemaChange::to_text(self : SchemaChange) -> String {
let level = match self.kind {
Compatible => "compatible"
Warning => "warning"
Breaking => "breaking"
}
"\{level}[\{self.code}] \{self.path}: \{self.message}"
}