mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite. Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers. Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
1024 lines
34 KiB
Rust
1024 lines
34 KiB
Rust
pub mod dialect;
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SchemaError {
|
|
#[error("failed to read schema file {path}: {source}")]
|
|
Read {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
#[error("failed to parse schema file {path}: {source}")]
|
|
Parse {
|
|
path: PathBuf,
|
|
source: toml::de::Error,
|
|
},
|
|
#[error("failed to write generated schema file {path}: {source}")]
|
|
Write {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
#[error("schema validation failed: {0}")]
|
|
Validation(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LogicalSchema {
|
|
pub tables: BTreeMap<String, Table>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadedSchema {
|
|
pub schema: LogicalSchema,
|
|
pub sources: Vec<SchemaSource>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SchemaSource {
|
|
pub name: String,
|
|
pub tables: Vec<String>,
|
|
}
|
|
|
|
impl LogicalSchema {
|
|
pub fn ordered_table_names(&self) -> Vec<String> {
|
|
let mut names = self.tables.keys().cloned().collect::<Vec<_>>();
|
|
names.sort_by(|left, right| {
|
|
let left_table = self
|
|
.tables
|
|
.get(left)
|
|
.expect("ordered table should exist in schema");
|
|
let right_table = self
|
|
.tables
|
|
.get(right)
|
|
.expect("ordered table should exist in schema");
|
|
left_table
|
|
.order
|
|
.unwrap_or(u32::MAX)
|
|
.cmp(&right_table.order.unwrap_or(u32::MAX))
|
|
.then_with(|| left.cmp(right))
|
|
});
|
|
names
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
pub struct SchemaFile {
|
|
#[serde(default)]
|
|
pub table: BTreeMap<String, Table>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Table {
|
|
#[serde(default)]
|
|
pub domain: Option<String>,
|
|
#[serde(default)]
|
|
pub order: Option<u32>,
|
|
#[serde(default)]
|
|
pub columns: Vec<Column>,
|
|
#[serde(default)]
|
|
pub primary_key: Vec<String>,
|
|
#[serde(default)]
|
|
pub uniques: Vec<UniqueConstraint>,
|
|
#[serde(default)]
|
|
pub indexes: Vec<Index>,
|
|
#[serde(default)]
|
|
pub foreign_keys: Vec<ForeignKey>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Column {
|
|
pub name: String,
|
|
#[serde(rename = "type")]
|
|
pub logical_type: LogicalType,
|
|
#[serde(default)]
|
|
pub nullable: bool,
|
|
#[serde(default)]
|
|
pub auto_increment: bool,
|
|
#[serde(default)]
|
|
pub default: Option<DefaultValue>,
|
|
#[serde(default)]
|
|
pub length: Option<u32>,
|
|
#[serde(default)]
|
|
pub driver: DriverColumnOverrides,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LogicalType {
|
|
TextId,
|
|
Text,
|
|
LongText,
|
|
Bool,
|
|
Int32,
|
|
Int64,
|
|
Float64,
|
|
DecimalMoney,
|
|
UnixSeconds,
|
|
UnixMillis,
|
|
Timestamp,
|
|
Json,
|
|
Bytes,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(untagged)]
|
|
pub enum DefaultValue {
|
|
String(String),
|
|
Integer(i64),
|
|
Bool(bool),
|
|
Raw { raw: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DriverColumnOverrides {
|
|
#[serde(default)]
|
|
pub postgres: Option<DriverColumnOverride>,
|
|
#[serde(default)]
|
|
pub mysql: Option<DriverColumnOverride>,
|
|
#[serde(default)]
|
|
pub sqlite: Option<DriverColumnOverride>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DriverColumnOverride {
|
|
#[serde(rename = "type")]
|
|
#[serde(default)]
|
|
pub sql_type: Option<String>,
|
|
#[serde(default)]
|
|
pub default: Option<DefaultValue>,
|
|
#[serde(default)]
|
|
pub nullable: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct UniqueConstraint {
|
|
pub name: String,
|
|
pub columns: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Index {
|
|
pub name: String,
|
|
pub columns: Vec<String>,
|
|
#[serde(default)]
|
|
pub unique: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ForeignKey {
|
|
pub name: String,
|
|
pub columns: Vec<String>,
|
|
pub references_table: String,
|
|
pub references_columns: Vec<String>,
|
|
#[serde(default)]
|
|
pub on_delete: Option<ReferentialAction>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ReferentialAction {
|
|
Cascade,
|
|
SetNull,
|
|
Restrict,
|
|
NoAction,
|
|
}
|
|
|
|
pub fn load_schema_dir(root: impl AsRef<Path>) -> Result<LogicalSchema, SchemaError> {
|
|
Ok(load_schema_sources(root)?.schema)
|
|
}
|
|
|
|
pub fn load_schema_sources(root: impl AsRef<Path>) -> Result<LoadedSchema, SchemaError> {
|
|
let mut paths = fs::read_dir(root.as_ref())
|
|
.map_err(|source| SchemaError::Read {
|
|
path: root.as_ref().to_path_buf(),
|
|
source,
|
|
})?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|source| SchemaError::Read {
|
|
path: root.as_ref().to_path_buf(),
|
|
source,
|
|
})?;
|
|
paths.sort();
|
|
paths.retain(|path| path.extension().and_then(|ext| ext.to_str()) == Some("toml"));
|
|
|
|
let mut tables = BTreeMap::new();
|
|
let mut sources = Vec::new();
|
|
for path in paths {
|
|
let text = fs::read_to_string(&path).map_err(|source| SchemaError::Read {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
let file: SchemaFile = toml::from_str(&text).map_err(|source| SchemaError::Parse {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
let mut source_tables = Vec::new();
|
|
for (name, table) in file.table {
|
|
source_tables.push(name.clone());
|
|
if tables.insert(name.clone(), table).is_some() {
|
|
return Err(SchemaError::Validation(format!(
|
|
"duplicate table definition: {name}"
|
|
)));
|
|
}
|
|
}
|
|
source_tables.sort_by(|left, right| {
|
|
let left_table = tables
|
|
.get(left)
|
|
.expect("source table should exist in merged schema");
|
|
let right_table = tables
|
|
.get(right)
|
|
.expect("source table should exist in merged schema");
|
|
left_table
|
|
.order
|
|
.unwrap_or(u32::MAX)
|
|
.cmp(&right_table.order.unwrap_or(u32::MAX))
|
|
.then_with(|| left.cmp(right))
|
|
});
|
|
let name = path
|
|
.file_stem()
|
|
.and_then(|stem| stem.to_str())
|
|
.ok_or_else(|| {
|
|
SchemaError::Validation(format!("invalid logical schema filename: {path:?}"))
|
|
})?
|
|
.to_string();
|
|
sources.push(SchemaSource {
|
|
name,
|
|
tables: source_tables,
|
|
});
|
|
}
|
|
|
|
let schema = LogicalSchema { tables };
|
|
validate_schema(&schema)?;
|
|
Ok(LoadedSchema { schema, sources })
|
|
}
|
|
|
|
pub fn validate_schema(schema: &LogicalSchema) -> Result<(), SchemaError> {
|
|
if schema.tables.is_empty() {
|
|
return Err(SchemaError::Validation(
|
|
"logical schema must define at least one table".to_string(),
|
|
));
|
|
}
|
|
|
|
for (table_name, table) in &schema.tables {
|
|
if table.columns.is_empty() {
|
|
return Err(SchemaError::Validation(format!(
|
|
"table {table_name} must define at least one column"
|
|
)));
|
|
}
|
|
|
|
let mut columns = BTreeSet::new();
|
|
for column in &table.columns {
|
|
validate_identifier(&column.name, "column", table_name)?;
|
|
if !columns.insert(column.name.as_str()) {
|
|
return Err(SchemaError::Validation(format!(
|
|
"table {table_name} defines duplicate column {}",
|
|
column.name
|
|
)));
|
|
}
|
|
}
|
|
|
|
for column_name in &table.primary_key {
|
|
require_column(table_name, &columns, column_name, "primary key")?;
|
|
}
|
|
|
|
let mut names = BTreeSet::new();
|
|
for unique in &table.uniques {
|
|
validate_named_columns(
|
|
table_name,
|
|
&columns,
|
|
&mut names,
|
|
&unique.name,
|
|
&unique.columns,
|
|
)?;
|
|
}
|
|
for index in &table.indexes {
|
|
validate_named_columns(
|
|
table_name,
|
|
&columns,
|
|
&mut names,
|
|
&index.name,
|
|
&index.columns,
|
|
)?;
|
|
}
|
|
for foreign_key in &table.foreign_keys {
|
|
validate_named_columns(
|
|
table_name,
|
|
&columns,
|
|
&mut names,
|
|
&foreign_key.name,
|
|
&foreign_key.columns,
|
|
)?;
|
|
let Some(referenced) = schema.tables.get(&foreign_key.references_table) else {
|
|
return Err(SchemaError::Validation(format!(
|
|
"foreign key {} on {table_name} references unknown table {}",
|
|
foreign_key.name, foreign_key.references_table
|
|
)));
|
|
};
|
|
let referenced_columns = referenced
|
|
.columns
|
|
.iter()
|
|
.map(|column| column.name.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
if foreign_key.columns.len() != foreign_key.references_columns.len() {
|
|
return Err(SchemaError::Validation(format!(
|
|
"foreign key {} on {table_name} has mismatched local/reference column count",
|
|
foreign_key.name
|
|
)));
|
|
}
|
|
for column_name in &foreign_key.references_columns {
|
|
require_column(
|
|
&foreign_key.references_table,
|
|
&referenced_columns,
|
|
column_name,
|
|
"foreign key reference",
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_named_columns(
|
|
table_name: &str,
|
|
columns: &BTreeSet<&str>,
|
|
names: &mut BTreeSet<String>,
|
|
name: &str,
|
|
column_names: &[String],
|
|
) -> Result<(), SchemaError> {
|
|
validate_identifier(name, "constraint/index", table_name)?;
|
|
if !names.insert(name.to_string()) {
|
|
return Err(SchemaError::Validation(format!(
|
|
"table {table_name} defines duplicate constraint/index name {name}"
|
|
)));
|
|
}
|
|
if column_names.is_empty() {
|
|
return Err(SchemaError::Validation(format!(
|
|
"table {table_name} constraint/index {name} must reference at least one column"
|
|
)));
|
|
}
|
|
for column_name in column_names {
|
|
require_column(table_name, columns, column_name, name)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn require_column(
|
|
table_name: &str,
|
|
columns: &BTreeSet<&str>,
|
|
column_name: &str,
|
|
context: &str,
|
|
) -> Result<(), SchemaError> {
|
|
if columns.contains(column_name) {
|
|
Ok(())
|
|
} else {
|
|
Err(SchemaError::Validation(format!(
|
|
"table {table_name} {context} references unknown column {column_name}"
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn validate_identifier(value: &str, kind: &str, table_name: &str) -> Result<(), SchemaError> {
|
|
let mut chars = value.chars();
|
|
let valid = matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase() || ch == '_')
|
|
&& chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_');
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(SchemaError::Validation(format!(
|
|
"invalid {kind} identifier {value} in table {table_name}"
|
|
)))
|
|
}
|
|
}
|
|
|
|
pub fn generate_to_dir(
|
|
schema: &LogicalSchema,
|
|
output_root: impl AsRef<Path>,
|
|
) -> Result<(), SchemaError> {
|
|
let source = SchemaSource {
|
|
name: "schema".to_string(),
|
|
tables: schema.ordered_table_names(),
|
|
};
|
|
generate_sources_to_dir(schema, &[source], output_root)
|
|
}
|
|
|
|
pub fn generate_loaded_to_dir(
|
|
loaded: &LoadedSchema,
|
|
output_root: impl AsRef<Path>,
|
|
) -> Result<(), SchemaError> {
|
|
generate_sources_to_dir(&loaded.schema, &loaded.sources, output_root)
|
|
}
|
|
|
|
pub fn generate_sources_to_dir(
|
|
schema: &LogicalSchema,
|
|
sources: &[SchemaSource],
|
|
output_root: impl AsRef<Path>,
|
|
) -> Result<(), SchemaError> {
|
|
let output_root = output_root.as_ref();
|
|
write_generated_readme(output_root)?;
|
|
write_driver_sources(output_root, "postgres", sources, |tables| {
|
|
dialect::postgres::emit_named_schema(schema, tables)
|
|
})?;
|
|
write_driver_sources(output_root, "mysql", sources, |tables| {
|
|
dialect::mysql::emit_named_schema(schema, tables)
|
|
})?;
|
|
write_driver_sources(output_root, "sqlite", sources, |tables| {
|
|
dialect::sqlite::emit_named_schema(schema, tables)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn check_generated_dir(
|
|
loaded: &LoadedSchema,
|
|
output_root: impl AsRef<Path>,
|
|
) -> Result<(), SchemaError> {
|
|
let output_root = output_root.as_ref();
|
|
assert_file_contents(output_root.join("README.md"), &generated_readme())?;
|
|
assert_generated_root_files(output_root)?;
|
|
check_driver_sources(output_root, "postgres", &loaded.sources, |tables| {
|
|
dialect::postgres::emit_named_schema(&loaded.schema, tables)
|
|
})?;
|
|
check_driver_sources(output_root, "mysql", &loaded.sources, |tables| {
|
|
dialect::mysql::emit_named_schema(&loaded.schema, tables)
|
|
})?;
|
|
check_driver_sources(output_root, "sqlite", &loaded.sources, |tables| {
|
|
dialect::sqlite::emit_named_schema(&loaded.schema, tables)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn check_required_tables(
|
|
schema: &LogicalSchema,
|
|
sql_paths: &[PathBuf],
|
|
) -> Result<(), SchemaError> {
|
|
for path in sql_paths {
|
|
let text = fs::read_to_string(path).map_err(|source| SchemaError::Read {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
let required = extract_table_shapes(&text);
|
|
let mut missing_tables = Vec::new();
|
|
let mut missing_columns = Vec::new();
|
|
for (table_name, required_columns) in required {
|
|
let Some(table) = schema.tables.get(&table_name) else {
|
|
missing_tables.push(table_name);
|
|
continue;
|
|
};
|
|
let defined_columns = table
|
|
.columns
|
|
.iter()
|
|
.map(|column| column.name.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
let missing = required_columns
|
|
.iter()
|
|
.filter(|column| !defined_columns.contains(column.as_str()))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if !missing.is_empty() {
|
|
missing_columns.push(format!("{table_name}.{}", missing.join("|")));
|
|
}
|
|
}
|
|
if !missing_tables.is_empty() || !missing_columns.is_empty() {
|
|
let mut details = Vec::new();
|
|
if !missing_tables.is_empty() {
|
|
details.push(format!("missing tables: {}", missing_tables.join(", ")));
|
|
}
|
|
if !missing_columns.is_empty() {
|
|
details.push(format!("missing columns: {}", missing_columns.join(", ")));
|
|
}
|
|
return Err(SchemaError::Validation(format!(
|
|
"logical schema is missing definitions required by {}: {}",
|
|
path.display(),
|
|
details.join("; ")
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn extract_create_table_names(sql: &str) -> BTreeSet<String> {
|
|
extract_table_shapes(sql).into_keys().collect()
|
|
}
|
|
|
|
pub fn extract_table_shapes(sql: &str) -> BTreeMap<String, BTreeSet<String>> {
|
|
const PREFIX: &str = "CREATE TABLE IF NOT EXISTS ";
|
|
let mut tables = BTreeMap::<String, BTreeSet<String>>::new();
|
|
let mut current_table = None::<String>;
|
|
let mut skipping_table_constraint = false;
|
|
for line in sql.lines() {
|
|
let trimmed = line.trim_start();
|
|
let upper = trimmed.to_ascii_uppercase();
|
|
if !upper.starts_with(PREFIX) {
|
|
if let Some(table_name) = ¤t_table {
|
|
if trimmed.starts_with(");") || (trimmed.starts_with(')') && trimmed.ends_with(';'))
|
|
{
|
|
current_table = None;
|
|
skipping_table_constraint = false;
|
|
} else if trimmed == ")" || trimmed == ")," {
|
|
skipping_table_constraint = false;
|
|
} else if skipping_table_constraint {
|
|
if table_definition_entry_ends(trimmed) {
|
|
skipping_table_constraint = false;
|
|
}
|
|
} else if is_table_constraint_start(trimmed) {
|
|
if !table_definition_entry_ends(trimmed) {
|
|
skipping_table_constraint = true;
|
|
}
|
|
} else if let Some(column_name) = parse_create_table_column_name(trimmed) {
|
|
tables
|
|
.entry(table_name.clone())
|
|
.or_default()
|
|
.insert(column_name);
|
|
}
|
|
} else if let Some((table_name, column_name)) = parse_alter_table_add_column(trimmed) {
|
|
tables.entry(table_name).or_default().insert(column_name);
|
|
}
|
|
continue;
|
|
}
|
|
let rest = &trimmed[PREFIX.len()..];
|
|
if let Some(table) = rest
|
|
.split_whitespace()
|
|
.next()
|
|
.and_then(normalize_table_token)
|
|
{
|
|
tables.entry(table.clone()).or_default();
|
|
current_table = Some(table);
|
|
skipping_table_constraint = false;
|
|
}
|
|
}
|
|
tables
|
|
}
|
|
|
|
fn is_table_constraint_start(line: &str) -> bool {
|
|
let Some(first) = line.split_whitespace().next() else {
|
|
return false;
|
|
};
|
|
let first = first.trim_end_matches(',').to_ascii_uppercase();
|
|
matches!(
|
|
first.as_str(),
|
|
"CHECK" | "CONSTRAINT" | "FOREIGN" | "KEY" | "PRIMARY" | "UNIQUE"
|
|
)
|
|
}
|
|
|
|
fn table_definition_entry_ends(line: &str) -> bool {
|
|
let line = line.trim_end();
|
|
line.ends_with(',') || line == ")" || line == ");"
|
|
}
|
|
|
|
fn parse_create_table_column_name(line: &str) -> Option<String> {
|
|
let first = line.split_whitespace().next()?;
|
|
let column_name = normalize_identifier_token(first)?;
|
|
let upper = column_name.to_ascii_uppercase();
|
|
if matches!(
|
|
upper.as_str(),
|
|
"CONSTRAINT" | "FOREIGN" | "KEY" | "PRIMARY" | "UNIQUE" | "INDEX" | "CHECK"
|
|
) {
|
|
None
|
|
} else {
|
|
Some(column_name)
|
|
}
|
|
}
|
|
|
|
fn parse_alter_table_add_column(line: &str) -> Option<(String, String)> {
|
|
let upper = line.to_ascii_uppercase();
|
|
if !upper.starts_with("ALTER TABLE ") || !upper.contains(" ADD COLUMN ") {
|
|
return None;
|
|
}
|
|
let tokens = line.split_whitespace().collect::<Vec<_>>();
|
|
let table_name = normalize_table_token(tokens.get(2)?)?;
|
|
let add_index = tokens
|
|
.iter()
|
|
.position(|token| token.eq_ignore_ascii_case("ADD"))?;
|
|
let mut column_index = add_index + 2;
|
|
if tokens
|
|
.get(column_index)
|
|
.is_some_and(|token| token.eq_ignore_ascii_case("IF"))
|
|
{
|
|
column_index += 3;
|
|
}
|
|
let column_name = normalize_identifier_token(tokens.get(column_index)?)?;
|
|
Some((table_name, column_name))
|
|
}
|
|
|
|
fn normalize_table_token(token: &str) -> Option<String> {
|
|
let mut value = token
|
|
.trim()
|
|
.trim_end_matches('(')
|
|
.trim_end_matches(';')
|
|
.trim()
|
|
.to_string();
|
|
if let Some((_, table)) = value.rsplit_once('.') {
|
|
value = table.to_string();
|
|
}
|
|
value = value
|
|
.trim_matches('`')
|
|
.trim_matches('"')
|
|
.trim_matches('[')
|
|
.trim_matches(']')
|
|
.to_string();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
}
|
|
|
|
fn normalize_identifier_token(token: &str) -> Option<String> {
|
|
let value = token
|
|
.trim()
|
|
.trim_end_matches(',')
|
|
.trim_end_matches('(')
|
|
.trim_end_matches(';')
|
|
.trim_matches('`')
|
|
.trim_matches('"')
|
|
.trim_matches('[')
|
|
.trim_matches(']')
|
|
.to_string();
|
|
if value.is_empty() {
|
|
None
|
|
} else {
|
|
Some(value)
|
|
}
|
|
}
|
|
|
|
fn write_driver_sources(
|
|
output_root: &Path,
|
|
driver: &str,
|
|
sources: &[SchemaSource],
|
|
emit: impl Fn(&[String]) -> String,
|
|
) -> Result<(), SchemaError> {
|
|
let baseline_dir = output_root.join(driver).join("baseline");
|
|
if baseline_dir.exists() {
|
|
fs::remove_dir_all(&baseline_dir).map_err(|source| SchemaError::Write {
|
|
path: baseline_dir.clone(),
|
|
source,
|
|
})?;
|
|
}
|
|
fs::create_dir_all(&baseline_dir).map_err(|source| SchemaError::Write {
|
|
path: baseline_dir.clone(),
|
|
source,
|
|
})?;
|
|
|
|
let mut manifest = generated_header();
|
|
for source in sources {
|
|
let filename = format!("{}.sql", source.name);
|
|
manifest.push_str(&filename);
|
|
manifest.push('\n');
|
|
let mut output = generated_header();
|
|
output.push_str(&emit(&source.tables));
|
|
write_generated(baseline_dir.join(filename), &output)?;
|
|
}
|
|
write_generated(baseline_dir.join("manifest.txt"), &manifest)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_generated_readme(output_root: &Path) -> Result<(), SchemaError> {
|
|
write_generated(output_root.join("README.md"), &generated_readme())
|
|
}
|
|
|
|
fn assert_generated_root_files(output_root: &Path) -> Result<(), SchemaError> {
|
|
let expected = BTreeSet::from([
|
|
"README.md".to_string(),
|
|
"mysql".to_string(),
|
|
"postgres".to_string(),
|
|
"sqlite".to_string(),
|
|
]);
|
|
let actual = fs::read_dir(output_root)
|
|
.map_err(|source| SchemaError::Read {
|
|
path: output_root.to_path_buf(),
|
|
source,
|
|
})?
|
|
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().to_string()))
|
|
.collect::<Result<BTreeSet<_>, _>>()
|
|
.map_err(|source| SchemaError::Read {
|
|
path: output_root.to_path_buf(),
|
|
source,
|
|
})?;
|
|
if actual == expected {
|
|
Ok(())
|
|
} else {
|
|
Err(SchemaError::Validation(format!(
|
|
"generated root file set mismatch: expected {:?}, got {:?}",
|
|
expected, actual
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn check_driver_sources(
|
|
output_root: &Path,
|
|
driver: &str,
|
|
sources: &[SchemaSource],
|
|
emit: impl Fn(&[String]) -> String,
|
|
) -> Result<(), SchemaError> {
|
|
let baseline_dir = output_root.join(driver).join("baseline");
|
|
let expected_manifest = generated_header()
|
|
+ &sources
|
|
.iter()
|
|
.map(|source| format!("{}.sql\n", source.name))
|
|
.collect::<String>();
|
|
assert_file_contents(baseline_dir.join("manifest.txt"), &expected_manifest)?;
|
|
|
|
let expected_files = sources
|
|
.iter()
|
|
.map(|source| format!("{}.sql", source.name))
|
|
.chain(std::iter::once("manifest.txt".to_string()))
|
|
.collect::<BTreeSet<_>>();
|
|
let actual_files = fs::read_dir(&baseline_dir)
|
|
.map_err(|source| SchemaError::Read {
|
|
path: baseline_dir.clone(),
|
|
source,
|
|
})?
|
|
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().to_string()))
|
|
.collect::<Result<BTreeSet<_>, _>>()
|
|
.map_err(|source| SchemaError::Read {
|
|
path: baseline_dir.clone(),
|
|
source,
|
|
})?;
|
|
if actual_files != expected_files {
|
|
return Err(SchemaError::Validation(format!(
|
|
"generated {driver} file set mismatch: expected {:?}, got {:?}",
|
|
expected_files, actual_files
|
|
)));
|
|
}
|
|
|
|
for source in sources {
|
|
let expected = generated_header() + &emit(&source.tables);
|
|
assert_file_contents(baseline_dir.join(format!("{}.sql", source.name)), &expected)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn generated_header() -> String {
|
|
"-- Generated by aether-data-schema from schema/logical/*.toml.\n\
|
|
-- Do not edit generated files directly; edit logical schema or explicit overrides instead.\n\n"
|
|
.to_string()
|
|
}
|
|
|
|
fn generated_readme() -> String {
|
|
"# Generated Schema\n\n\
|
|
This directory is generated by `aether-data-schema` from `../logical/*.toml`.\n\
|
|
It is checked in only as an auditable compiler output and drift-detection fixture.\n\n\
|
|
Do not edit files in this directory by hand. Update `../logical/*.toml`, then run:\n\n\
|
|
```bash\n\
|
|
bash crates/aether-data/schema/compose_schema.sh generate\n\
|
|
```\n\n\
|
|
Runtime migrations are not loaded from this directory. The executable SQL remains under \
|
|
`crates/aether-data/migrations/{postgres,mysql,sqlite}`, and the Postgres bootstrap snapshot \
|
|
is generated at build time from `crates/aether-data/schema/bootstrap/postgres` into the crate \
|
|
build output until a generated fragment is deliberately promoted into the driver-specific \
|
|
schema manifests.\n"
|
|
.to_string()
|
|
}
|
|
|
|
fn assert_file_contents(path: PathBuf, expected: &str) -> Result<(), SchemaError> {
|
|
let actual = fs::read_to_string(&path).map_err(|source| SchemaError::Read {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
if actual == expected {
|
|
Ok(())
|
|
} else {
|
|
Err(SchemaError::Validation(format!(
|
|
"generated schema file is stale: {path:?}"
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn write_generated(path: PathBuf, contents: &str) -> Result<(), SchemaError> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|source| SchemaError::Write {
|
|
path: parent.to_path_buf(),
|
|
source,
|
|
})?;
|
|
}
|
|
fs::write(&path, contents).map_err(|source| SchemaError::Write { path, source })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dialect::{mysql, postgres, sqlite};
|
|
|
|
fn announcements_schema() -> LogicalSchema {
|
|
LogicalSchema {
|
|
tables: BTreeMap::from([(
|
|
"announcements".to_string(),
|
|
Table {
|
|
domain: None,
|
|
order: Some(1),
|
|
columns: vec![
|
|
Column {
|
|
name: "id".to_string(),
|
|
logical_type: LogicalType::TextId,
|
|
nullable: false,
|
|
auto_increment: false,
|
|
default: None,
|
|
length: Some(64),
|
|
driver: DriverColumnOverrides::default(),
|
|
},
|
|
Column {
|
|
name: "is_active".to_string(),
|
|
logical_type: LogicalType::Bool,
|
|
nullable: false,
|
|
auto_increment: false,
|
|
default: Some(DefaultValue::Bool(true)),
|
|
length: None,
|
|
driver: DriverColumnOverrides::default(),
|
|
},
|
|
],
|
|
primary_key: vec!["id".to_string()],
|
|
uniques: vec![],
|
|
indexes: vec![Index {
|
|
name: "announcements_is_active_idx".to_string(),
|
|
columns: vec!["is_active".to_string()],
|
|
unique: false,
|
|
}],
|
|
foreign_keys: vec![],
|
|
},
|
|
)]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validator_rejects_unknown_index_column() {
|
|
let mut schema = announcements_schema();
|
|
schema
|
|
.tables
|
|
.get_mut("announcements")
|
|
.expect("fixture table exists")
|
|
.indexes
|
|
.push(Index {
|
|
name: "ix_missing".to_string(),
|
|
columns: vec!["missing".to_string()],
|
|
unique: false,
|
|
});
|
|
|
|
let err = validate_schema(&schema).expect_err("invalid schema should fail");
|
|
assert!(err.to_string().contains("unknown column missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn emitters_generate_expected_driver_types() {
|
|
let schema = announcements_schema();
|
|
validate_schema(&schema).expect("fixture schema should be valid");
|
|
|
|
let postgres_sql = postgres::emit_schema(&schema);
|
|
assert!(postgres_sql.contains("id character varying(64) NOT NULL"));
|
|
assert!(postgres_sql.contains("is_active boolean DEFAULT true NOT NULL"));
|
|
assert!(postgres_sql.contains(
|
|
"CREATE INDEX IF NOT EXISTS announcements_is_active_idx ON public.announcements USING btree (is_active);"
|
|
));
|
|
|
|
let mysql_sql = mysql::emit_schema(&schema);
|
|
assert!(mysql_sql.contains("`id` VARCHAR(64) NOT NULL"));
|
|
assert!(mysql_sql.contains("`is_active` TINYINT(1) NOT NULL DEFAULT 1"));
|
|
assert!(mysql_sql.contains("KEY announcements_is_active_idx (`is_active`)"));
|
|
|
|
let sqlite_sql = sqlite::emit_schema(&schema);
|
|
assert!(sqlite_sql.contains("id TEXT PRIMARY KEY NOT NULL"));
|
|
assert!(sqlite_sql.contains("is_active INTEGER NOT NULL DEFAULT 1"));
|
|
assert!(sqlite_sql.contains(
|
|
"CREATE INDEX IF NOT EXISTS announcements_is_active_idx ON announcements (is_active);"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_create_table_names_across_driver_quoting() {
|
|
let tables = extract_create_table_names(
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS public.users (
|
|
CREATE TABLE IF NOT EXISTS `usage` (
|
|
CREATE TABLE IF NOT EXISTS "stats_daily" (
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
tables,
|
|
BTreeSet::from([
|
|
"stats_daily".to_string(),
|
|
"usage".to_string(),
|
|
"users".to_string()
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_table_shapes_from_create_and_alter_sql() {
|
|
let shapes = extract_table_shapes(
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS public.users (
|
|
id VARCHAR(64) PRIMARY KEY,
|
|
email VARCHAR(320),
|
|
CHECK (
|
|
(id IS NOT NULL AND email IS NULL)
|
|
OR (id IS NULL AND email IS NOT NULL)
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_email
|
|
ON users (email);
|
|
ALTER TABLE users ADD COLUMN ldap_dn VARCHAR(1024);
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
shapes.get("users"),
|
|
Some(&BTreeSet::from([
|
|
"email".to_string(),
|
|
"id".to_string(),
|
|
"ldap_dn".to_string()
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn required_table_check_reports_missing_tables() {
|
|
let schema = announcements_schema();
|
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let path = manifest_dir.join("target/required-table-fixture.sql");
|
|
std::fs::create_dir_all(path.parent().expect("fixture should have parent"))
|
|
.expect("fixture dir should be writable");
|
|
std::fs::write(
|
|
&path,
|
|
"CREATE TABLE IF NOT EXISTS announcements (\n);\nCREATE TABLE IF NOT EXISTS users (\n);\n",
|
|
)
|
|
.expect("fixture should be writable");
|
|
|
|
let err = check_required_tables(&schema, std::slice::from_ref(&path))
|
|
.expect_err("missing required table should fail");
|
|
std::fs::remove_file(&path).expect("fixture should be removable");
|
|
assert!(err.to_string().contains("users"));
|
|
}
|
|
|
|
#[test]
|
|
fn required_table_check_reports_missing_columns() {
|
|
let schema = announcements_schema();
|
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let path = manifest_dir.join("target/required-column-fixture.sql");
|
|
std::fs::create_dir_all(path.parent().expect("fixture should have parent"))
|
|
.expect("fixture dir should be writable");
|
|
std::fs::write(
|
|
&path,
|
|
"CREATE TABLE IF NOT EXISTS announcements (\n id TEXT PRIMARY KEY,\n title TEXT\n);\n",
|
|
)
|
|
.expect("fixture should be writable");
|
|
|
|
let err = check_required_tables(&schema, std::slice::from_ref(&path))
|
|
.expect_err("missing required column should fail");
|
|
std::fs::remove_file(&path).expect("fixture should be removable");
|
|
assert!(err.to_string().contains("announcements.title"));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_logical_schema_generated_artifacts_are_current() {
|
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let workspace = manifest_dir
|
|
.parent()
|
|
.and_then(Path::parent)
|
|
.expect("crate should live under workspace/crates");
|
|
let schema_dir = workspace.join("crates/aether-data/schema/logical");
|
|
let generated_dir = workspace.join("crates/aether-data/schema/generated");
|
|
|
|
let loaded = load_schema_sources(schema_dir).expect("workspace logical schema should load");
|
|
check_generated_dir(&loaded, generated_dir)
|
|
.expect("workspace generated schema should be current");
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_logical_schema_covers_required_sql_tables() {
|
|
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let workspace = manifest_dir
|
|
.parent()
|
|
.and_then(Path::parent)
|
|
.expect("crate should live under workspace/crates");
|
|
let schema_dir = workspace.join("crates/aether-data/schema/logical");
|
|
let migrations_dir = workspace.join("crates/aether-data/migrations");
|
|
let mut required_sql_paths =
|
|
vec![migrations_dir.join("postgres/20260403000000_baseline.sql")];
|
|
for driver in ["mysql", "sqlite"] {
|
|
let driver_dir = migrations_dir.join(driver);
|
|
let mut paths = std::fs::read_dir(&driver_dir)
|
|
.unwrap_or_else(|err| panic!("failed to read {}: {err}", driver_dir.display()))
|
|
.map(|entry| entry.expect("migration entry should be readable").path())
|
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sql"))
|
|
.collect::<Vec<_>>();
|
|
paths.sort();
|
|
required_sql_paths.extend(paths);
|
|
}
|
|
|
|
let loaded = load_schema_sources(schema_dir).expect("workspace logical schema should load");
|
|
check_required_tables(&loaded.schema, &required_sql_paths)
|
|
.expect("portable SQL tables should be defined in logical schema");
|
|
}
|
|
}
|