#[cfg(any(
feature = "rusqlite",
feature = "turso",
feature = "libsql",
feature = "postgres",
feature = "mysql"
))]
mod parity_util {
use std::collections::BTreeMap;
pub fn assert_entity_parity(label: &str, macro_side: Vec<String>, parser_side: Vec<String>) {
let mut counts: BTreeMap<String, i64> = BTreeMap::new();
for entity in ¯o_side {
*counts.entry(entity.clone()).or_default() += 1;
}
for entity in &parser_side {
*counts.entry(entity.clone()).or_default() -= 1;
}
let only_macro: Vec<&String> = counts
.iter()
.filter(|(_, n)| **n > 0)
.map(|(e, _)| e)
.collect();
let only_parser: Vec<&String> = counts
.iter()
.filter(|(_, n)| **n < 0)
.map(|(e, _)| e)
.collect();
assert!(
only_macro.is_empty() && only_parser.is_empty(),
"{label}: parser and macro snapshots diverge\n\n\
== entities only in the macro (Schema::to_snapshot) side ==\n{}\n\n\
== entities only in the parser side ==\n{}\n",
only_macro
.iter()
.map(|e| format!(" {e}"))
.collect::<Vec<_>>()
.join("\n"),
only_parser
.iter()
.map(|e| format!(" {e}"))
.collect::<Vec<_>>()
.join("\n"),
);
}
}
#[cfg(any(feature = "rusqlite", feature = "turso", feature = "libsql"))]
mod sqlite_parity {
use drizzle::sqlite::prelude::*;
#[SQLiteTable(strict)]
pub struct Users {
#[column(primary)]
pub id: i64,
#[column(unique)]
pub email: String,
#[column(default = "guest")]
pub display_name: String,
#[column(default = CURRENT_TIMESTAMP)]
pub created_at: String,
#[column(default = strftime("%s", "now"))]
pub updated_at: i64,
#[column(collate = NOCASE)]
pub nickname: Option<String>,
}
#[SQLiteTable]
pub struct OrderItems {
#[column(primary)]
pub order_id: i64,
#[column(primary)]
pub item_no: i64,
pub quantity: i64,
}
#[SQLiteTable(
FOREIGN_KEY(
columns(order_id, item_no),
references(OrderItems, order_id, item_no),
on_delete = "CASCADE"
),
UNIQUE(columns(first, last), name = "shipments_name_unique"),
CHECK(expr = "weight >= 0")
)]
pub struct Shipments {
#[column(primary)]
pub id: i64,
pub order_id: i64,
pub item_no: i64,
#[column(references = Users::id, on_delete = SET_NULL, on_update = CASCADE)]
pub courier_id: Option<i64>,
pub first: String,
pub last: String,
pub weight: i64,
#[column(generated(stored, "first || ' ' || last"))]
pub full_name: String,
#[column(generated(virtual, "weight * 2"))]
pub double_weight: i64,
}
#[SQLiteTable(name = "registry_master")]
pub struct Registry {
#[column(primary)]
pub id: i64,
#[column(unique, name = "registry_code")]
pub code: String,
}
#[SQLiteTable(FOREIGN_KEY(
columns(reg_id, reg_code),
references(Registry, id, code),
on_update = "CASCADE"
))]
pub struct RegistryEntries {
#[column(primary)]
pub id: i64,
#[column(name = "entry_registry_id")]
pub reg_id: i64,
pub reg_code: String,
#[column(references = Registry::code, on_delete = SET_NULL)]
pub linked_code: Option<String>,
}
#[SQLiteIndex(unique, where = "email IS NOT NULL")]
pub struct UsersEmailIdx(Users::email);
#[SQLiteView(DEFINITION = "SELECT id, email FROM users")]
pub struct UserEmails {
pub id: i64,
pub email: String,
}
#[derive(SQLiteSchema)]
pub struct SqliteParitySchema {
pub users: Users,
pub order_items: OrderItems,
pub shipments: Shipments,
pub registry: Registry,
pub registry_entries: RegistryEntries,
pub users_email_idx: UsersEmailIdx,
pub user_emails: UserEmails,
}
#[test]
fn sqlite_producer_parity() {
use drizzle::migrations::parser::SchemaParser;
use drizzle::migrations::schema::{Schema as _, Snapshot};
use drizzle_types::Dialect;
let macro_snapshot = match SqliteParitySchema::new().to_snapshot() {
Snapshot::Sqlite(s) => s,
Snapshot::Postgres(_) | Snapshot::MySQL(_) => panic!("expected a SQLite snapshot"),
};
let parsed = SchemaParser::parse(include_str!("parser_parity.rs"));
assert!(
parsed.errors.is_empty(),
"parser reported errors on the fixture: {:?}",
parsed.errors
);
let parser_snapshot = match drizzle::migrations::Snapshot::from_parse_result(
&parsed,
Dialect::SQLite,
None,
) {
Snapshot::Sqlite(s) => s,
Snapshot::Postgres(_) | Snapshot::MySQL(_) => panic!("expected a SQLite snapshot"),
};
super::parity_util::assert_entity_parity(
"sqlite",
macro_snapshot
.ddl
.iter()
.map(|e| format!("{e:?}"))
.collect(),
parser_snapshot
.ddl
.iter()
.map(|e| format!("{e:?}"))
.collect(),
);
use drizzle::migrations::sqlite::SqliteEntity;
let email_index = macro_snapshot.ddl.iter().find_map(|entity| match entity {
SqliteEntity::Index(index) if index.name.as_ref() == "users_email_idx" => Some(index),
_ => None,
});
assert_eq!(
email_index.and_then(|index| index.where_clause.as_deref()),
Some("email IS NOT NULL"),
"compiled SQLite schema dropped the partial-index predicate"
);
let fks: Vec<_> = macro_snapshot
.ddl
.iter()
.filter_map(|e| match e {
SqliteEntity::ForeignKey(fk) if fk.table.as_ref() == "registry_entries" => Some(fk),
_ => None,
})
.collect();
assert!(
fks.iter().any(|fk| {
fk.name.as_ref()
== "fk_registry_entries_linked_code_registry_master_registry_code_fk"
&& fk.table_to.as_ref() == "registry_master"
&& fk
.columns_to
.iter()
.map(AsRef::as_ref)
.eq(["registry_code"])
}),
"renamed single-column FK target not resolved: {fks:?}"
);
assert!(
fks.iter().any(|fk| {
fk.name.as_ref()
== "fk_registry_entries_entry_registry_id_reg_code_registry_master_id_registry_code_fk"
&& fk.columns.iter().map(AsRef::as_ref).eq(["entry_registry_id", "reg_code"])
&& fk.columns_to.iter().map(AsRef::as_ref).eq(["id", "registry_code"])
}),
"renamed composite FK source/target not resolved: {fks:?}"
);
}
}
#[cfg(feature = "postgres")]
mod postgres_parity {
use drizzle::postgres::prelude::*;
#[derive(PostgresEnum, Default, Clone, Copy, PartialEq, Debug)]
pub enum AccountStatus {
#[default]
Active,
Suspended,
Closed,
}
#[derive(PostgresEnum, Default, Clone, Copy, PartialEq, Debug)]
#[postgres_enum(schema = "auth")]
pub enum AuthRole {
#[default]
Member,
Admin,
}
#[derive(PostgresEnum, Default, Clone, Copy, PartialEq, Debug)]
#[postgres_enum(schema = "lookup")]
pub enum Currency {
#[default]
Usd,
Eur,
}
#[PostgresTable(schema = "auth")]
pub struct AuthUsers {
#[column(primary)]
pub id: i64,
#[column(unique)]
pub email: String,
#[column(unique, name = "auth_handle")]
pub handle: String,
}
#[PostgresTable(RLS)]
pub struct Accounts {
#[column(primary, identity(by_default))]
pub id: i64,
#[column(enum)]
pub status: AccountStatus,
#[column(enum)]
pub role: AuthRole,
pub tags: Vec<String>,
#[column(default = "basic")]
pub tier: String,
#[column(references = AuthUsers::id, on_delete = CASCADE, deferrable)]
pub owner_id: i64,
#[column(references = AuthUsers::handle)]
pub owner_handle: String,
}
#[PostgresTable(UNIQUE(columns(label), nulls_not_distinct))]
pub struct AccountEvents {
#[column(primary)]
pub account_id: i64,
#[column(primary)]
pub seq: i64,
#[column(identity(
always,
start = 100,
increment = 5,
min_value = 10,
max_value = 100000,
cache = 10,
cycle
))]
pub audit_no: i64,
pub label: Option<String>,
}
#[PostgresIndex(unique)]
pub struct AuthUsersEmailIdx(AuthUsers::email);
#[PostgresIndex(method = "hash", concurrent, where = "tier <> 'basic'")]
pub struct AccountsTierIdx(Accounts::tier);
#[PostgresView(DEFINITION = "SELECT id, tier FROM accounts")]
pub struct AccountTiers {
pub id: i64,
pub tier: String,
}
#[PostgresPolicy(
NAME = "accounts_owner_only",
AS = "PERMISSIVE",
FOR = "SELECT",
TO(authenticated),
USING = "owner_id = current_setting('app.user_id')::bigint"
)]
pub struct AccountsPolicy(Accounts);
#[derive(PostgresSchema)]
pub struct PostgresParitySchema {
pub account_status: AccountStatus,
pub auth_role: AuthRole,
pub currency: Currency,
pub auth_users: AuthUsers,
pub accounts: Accounts,
pub account_events: AccountEvents,
pub auth_users_email_idx: AuthUsersEmailIdx,
pub accounts_tier_idx: AccountsTierIdx,
pub account_tiers: AccountTiers,
pub accounts_policy: AccountsPolicy,
}
#[test]
fn postgres_producer_parity() {
use drizzle::migrations::parser::SchemaParser;
use drizzle::migrations::schema::{Schema as _, Snapshot};
use drizzle_types::Dialect;
let macro_snapshot = match PostgresParitySchema::new().to_snapshot() {
Snapshot::Postgres(s) => s,
Snapshot::Sqlite(_) | Snapshot::MySQL(_) => panic!("expected a Postgres snapshot"),
};
let parsed = SchemaParser::parse(include_str!("parser_parity.rs"));
assert!(
parsed.errors.is_empty(),
"parser reported errors on the fixture: {:?}",
parsed.errors
);
let parser_snapshot = match drizzle::migrations::Snapshot::from_parse_result(
&parsed,
Dialect::PostgreSQL,
None,
) {
Snapshot::Postgres(s) => s,
Snapshot::Sqlite(_) | Snapshot::MySQL(_) => panic!("expected a Postgres snapshot"),
};
super::parity_util::assert_entity_parity(
"postgres",
macro_snapshot
.ddl
.iter()
.map(|e| format!("{e:?}"))
.collect(),
parser_snapshot
.ddl
.iter()
.map(|e| format!("{e:?}"))
.collect(),
);
use drizzle::migrations::postgres::PostgresEntity;
let ddl = ¯o_snapshot.ddl;
let event_pks: Vec<_> = ddl
.iter()
.filter_map(|e| match e {
PostgresEntity::PrimaryKey(pk) if pk.table.as_ref() == "account_events" => Some(pk),
_ => None,
})
.collect();
assert_eq!(
event_pks.len(),
1,
"expected exactly one PrimaryKey entity: {event_pks:?}"
);
assert!(
event_pks[0].name.as_ref() == "account_events_pkey"
&& event_pks[0]
.columns
.iter()
.map(AsRef::as_ref)
.eq(["account_id", "seq"]),
"composite PK shape wrong: {event_pks:?}"
);
assert!(
ddl.iter().any(|e| matches!(
e,
PostgresEntity::Schema(s) if s.name.as_ref() == "lookup"
)),
"enum-only schema `lookup` must be registered as a Schema entity"
);
let audit_no = ddl
.iter()
.find_map(|e| match e {
PostgresEntity::Column(c)
if c.table.as_ref() == "account_events" && c.name.as_ref() == "audit_no" =>
{
Some(c)
}
_ => None,
})
.expect("audit_no column");
let identity = audit_no.identity.as_ref().expect("identity");
assert_eq!(identity.start_with.as_deref(), Some("100"));
assert_eq!(identity.increment.as_deref(), Some("5"));
assert_eq!(identity.min_value.as_deref(), Some("10"));
assert_eq!(identity.max_value.as_deref(), Some("100000"));
assert_eq!(identity.cache, Some(10));
assert_eq!(identity.cycle, Some(true));
let tier_idx = ddl
.iter()
.find_map(|e| match e {
PostgresEntity::Index(i) if i.name.as_ref() == "accounts_tier_idx" => Some(i),
_ => None,
})
.expect("accounts_tier_idx");
assert_eq!(tier_idx.method.as_deref(), Some("hash"));
assert_eq!(tier_idx.where_clause.as_deref(), Some("tier <> 'basic'"));
assert!(tier_idx.concurrently);
let email_idx = ddl
.iter()
.find_map(|e| match e {
PostgresEntity::Index(i) if i.name.as_ref() == "auth_users_email_idx" => Some(i),
_ => None,
})
.expect("auth_users_email_idx");
assert_eq!(email_idx.method, None, "implicit btree must stay None");
assert!(
ddl.iter().any(|e| matches!(
e,
PostgresEntity::UniqueConstraint(u)
if u.table.as_ref() == "account_events" && u.nulls_not_distinct
)),
"nulls_not_distinct dropped"
);
assert!(
ddl.iter().any(|e| matches!(
e,
PostgresEntity::Enum(en)
if en.name.as_ref() == "AuthRole" && en.schema.as_ref() == "auth"
)),
"enum schema dropped"
);
let role_col = ddl
.iter()
.find_map(|e| match e {
PostgresEntity::Column(c)
if c.table.as_ref() == "accounts" && c.name.as_ref() == "role" =>
{
Some(c)
}
_ => None,
})
.expect("accounts.role column");
assert_eq!(role_col.type_schema.as_deref(), Some("auth"));
assert!(
ddl.iter().any(|e| matches!(
e,
PostgresEntity::ForeignKey(fk)
if fk.name.as_ref() == "accounts_owner_handle_fkey"
&& fk.columns_to.iter().map(AsRef::as_ref).eq(["auth_handle"])
)),
"renamed PG FK target not resolved"
);
}
}
#[cfg(feature = "mysql")]
mod mysql_parity {
use drizzle::mysql::prelude::*;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, MySQLEnum)]
pub enum AccountState {
#[default]
Pending,
Active,
Suspended,
}
#[MySQLTable(
DATABASE = "parity_db",
NAME = "mysql_parity_tenants",
ENGINE = "InnoDB",
DEFAULT_CHARSET = "utf8mb4",
COLLATE = "utf8mb4_0900_ai_ci",
COMMENT = "tenant records"
)]
pub struct Tenants {
#[column(NAME = "tenant_key", PRIMARY, AUTO_INCREMENT)]
pub id: u64,
#[column(VARCHAR(255), UNIQUE)]
pub slug: String,
}
#[MySQLTable(
DATABASE = "parity_db",
NAME = "mysql_parity_accounts",
ENGINE = "InnoDB",
DEFAULT_CHARSET = "utf8mb4",
COLLATE = "utf8mb4_0900_ai_ci",
UNIQUE(columns(label, state), name = "accounts_label_state_key"),
CHECK(name = "accounts_login_count_check", expr = "login_count >= 0")
)]
pub struct Accounts {
#[column(PRIMARY, AUTO_INCREMENT)]
pub id: u64,
#[column(REFERENCES = Tenants::id, ON_DELETE = CASCADE, ON_UPDATE = RESTRICT)]
pub tenant_id: u64,
#[column(ENUM, CHARSET = "utf8mb4", COLLATE = "utf8mb4_bin")]
pub state: AccountState,
#[column(SET("reader", "writer", "admin"))]
pub roles: String,
#[column(VARCHAR(255))]
pub label: String,
#[column(COMMENT = "stored in the schema")]
pub note: String,
#[column(DEFAULT = 0, CHECK = "login_count >= 0")]
pub login_count: u32,
#[column(
TIMESTAMP,
DEFAULT = CURRENT_TIMESTAMP,
ON_UPDATE = "CURRENT_TIMESTAMP"
)]
pub updated_at: String,
#[column(generated(STORED, "CHAR_LENGTH(label)"))]
pub label_length: u32,
}
#[MySQLIndex(unique, using = "HASH", algorithm = "INPLACE", lock = "NONE")]
pub struct AccountsLabelIndex(Accounts::label);
#[MySQLView(
DATABASE = "parity_db",
NAME = "active_account_labels",
DEFINITION = "SELECT id, label FROM mysql_parity_accounts WHERE login_count > 0",
ALGORITHM = "MERGE",
SQL_SECURITY = "INVOKER",
CHECK_OPTION
)]
pub struct ActiveAccountLabels {
pub id: u64,
pub label: String,
}
#[derive(MySQLSchema)]
pub struct MySqlParitySchema {
pub tenants: Tenants,
pub accounts: Accounts,
pub accounts_label_index: AccountsLabelIndex,
pub active_account_labels: ActiveAccountLabels,
}
#[test]
fn mysql_producer_parity() {
use drizzle::migrations::mysql::{
GeneratedType, IndexAlgorithm, IndexLock, IndexMethod, InlineType, MySQLEntity,
ReferentialAction,
};
use drizzle::migrations::parser::SchemaParser;
use drizzle::migrations::schema::{Schema as _, Snapshot};
use drizzle_types::Dialect;
let macro_snapshot = match MySqlParitySchema::new().to_snapshot() {
Snapshot::MySQL(snapshot) => snapshot,
Snapshot::Sqlite(_) | Snapshot::Postgres(_) => panic!("expected a MySQL snapshot"),
};
let parsed = SchemaParser::parse(include_str!("parser_parity.rs"));
assert!(
parsed.errors.is_empty(),
"parser reported errors on the fixture: {:?}",
parsed.errors
);
let parser_snapshot =
match drizzle::migrations::Snapshot::from_parse_result(&parsed, Dialect::MySQL, None) {
Snapshot::MySQL(snapshot) => snapshot,
Snapshot::Sqlite(_) | Snapshot::Postgres(_) => panic!("expected a MySQL snapshot"),
};
super::parity_util::assert_entity_parity(
"mysql",
macro_snapshot
.ddl
.iter()
.map(|entity| format!("{entity:?}"))
.collect(),
parser_snapshot
.ddl
.iter()
.map(|entity| format!("{entity:?}"))
.collect(),
);
let ddl = ¯o_snapshot.ddl;
let accounts = ddl
.iter()
.find_map(|entity| match entity {
MySQLEntity::Table(table) if table.name.as_ref() == "mysql_parity_accounts" => {
Some(table)
}
_ => None,
})
.expect("accounts table");
assert_eq!(accounts.database.as_deref(), Some("parity_db"));
assert_eq!(accounts.engine.as_deref(), Some("InnoDB"));
assert_eq!(accounts.charset.as_deref(), Some("utf8mb4"));
assert_eq!(accounts.collation.as_deref(), Some("utf8mb4_0900_ai_ci"));
let column = |name: &str| {
ddl.iter()
.find_map(|entity| match entity {
MySQLEntity::Column(column)
if column.table.as_ref() == "mysql_parity_accounts"
&& column.name.as_ref() == name =>
{
Some(column)
}
_ => None,
})
.unwrap_or_else(|| panic!("accounts.{name} column"))
};
assert_eq!(
column("label").comment,
None,
"Rust docs became SQL COMMENT"
);
assert_eq!(
column("note").comment.as_deref(),
Some("stored in the schema")
);
assert!(matches!(
column("state").inline_type.as_ref(),
Some(InlineType::Enum(values))
if values.values.iter().map(AsRef::as_ref).eq(["Pending", "Active", "Suspended"])
));
assert!(matches!(
column("roles").inline_type.as_ref(),
Some(InlineType::Set(values))
if values.values.iter().map(AsRef::as_ref).eq(["reader", "writer", "admin"])
));
assert!(matches!(
column("label_length").generated.as_ref(),
Some(generated)
if generated.expression.as_ref() == "CHAR_LENGTH(label)"
&& generated.generation_type == GeneratedType::Stored
));
assert_eq!(
column("updated_at").on_update.as_deref(),
Some("CURRENT_TIMESTAMP")
);
assert!(ddl.iter().any(|entity| matches!(
entity,
MySQLEntity::ForeignKey(foreign_key)
if foreign_key.table.as_ref() == "mysql_parity_accounts"
&& foreign_key.foreign_columns.iter().map(AsRef::as_ref).eq(["tenant_key"])
&& foreign_key.on_delete == Some(ReferentialAction::Cascade)
&& foreign_key.on_update == Some(ReferentialAction::Restrict)
)));
assert!(ddl.iter().any(|entity| matches!(
entity,
MySQLEntity::Index(index)
if index.name.as_ref() == "accounts_label_index"
&& index.using == Some(IndexMethod::Hash)
&& index.algorithm == Some(IndexAlgorithm::Inplace)
&& index.lock == Some(IndexLock::None)
)));
assert!(ddl.iter().any(|entity| matches!(
entity,
MySQLEntity::View(view)
if view.database.as_deref() == Some("parity_db")
&& view.name.as_ref() == "active_account_labels"
&& view.definition.as_deref()
== Some("SELECT id, label FROM mysql_parity_accounts WHERE login_count > 0")
&& view.algorithm == Some(drizzle::migrations::mysql::ViewAlgorithm::Merge)
&& view.sql_security
== Some(drizzle::migrations::mysql::ViewSqlSecurity::Invoker)
&& view.check_option
== Some(drizzle::migrations::mysql::ViewCheckOption::Cascaded)
&& view.definer.is_none()
&& view.charset.is_none()
&& view.collation.is_none()
)));
}
}