Upgrade sea-orm to 2.0, update structure and lints

This commit is contained in:
2026-09-04 23:38:02 -06:00
parent 800916c111
commit a5454bda71
26 changed files with 1435 additions and 980 deletions
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "seamantic"
version = "1.0.0"
description = "A library to enhance SeaORM"
repository = "https://git.skrundz.dev/quantumshade/seamantic"
keywords = ["SeaORM"]
categories = ["database"]
include = ["/src"]
publish = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[[example]]
name = "migrations"
path = "examples/migrations/main.rs"
required-features = ["sqlite"]
[dependencies]
sea-orm = { workspace = true }
sea-orm-migration = { workspace = true }
serde = { workspace = true, features = ["derive", "std"], optional = true }
[dev-dependencies]
sea-orm = { workspace = true, features = ["entity-registry", "schema-sync"] }
sea-orm-migration = { workspace = true, features = ["runtime-tokio-rustls"] }
serde_test = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
[features]
default = ["sqlite"]
serde = ["dep:serde"]
sqlite = ["sea-orm-migration/sqlx-sqlite"]
[lints]
workspace = true
+5
View File
@@ -0,0 +1,5 @@
# Seamantic
[![Crates Version](https://img.shields.io/crates/v/seamantic.svg)](https://crates.io/crates/seamantic)
A library to enhance SeaORM
@@ -0,0 +1,46 @@
use seamantic::schema::{sqlite_case_insensitive_string, sqlite_rowid_alias};
use sea_orm_migration::prelude::*;
#[derive(Iden)]
pub(super) enum Objects {
Table,
Id,
Name,
}
#[derive(DeriveMigrationName)]
pub(super) struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
#[expect(
elided_lifetimes_in_paths,
reason = "async_trait causes lifetimes to be strange"
)]
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Objects::Table)
.col(sqlite_rowid_alias(Objects::Id))
.col(sqlite_case_insensitive_string(Objects::Name))
.to_owned(),
)
.await?;
Ok(())
}
#[expect(
elided_lifetimes_in_paths,
reason = "async_trait causes lifetimes to be strange"
)]
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Objects::Table).to_owned())
.await?;
Ok(())
}
}
@@ -0,0 +1,20 @@
//! This example shows how to use `seamantic::migrations!` and how to
//! make a migration with the sqlite schema helpers
use sea_orm::{ConnectOptions, Database};
use sea_orm_migration::MigratorTrait;
seamantic::migrations! {
"seaql_migrations_test";
m_01,
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let options = ConnectOptions::new("sqlite:/tmp/db?mode=memory");
let database = Database::connect(options).await.expect("Database::connect");
Migrator::up(&database, None).await.expect("Migrator::up");
}
// dev-dependencies cannot be optional and `serde_test` is not used
use {serde_test as _, tokio as _};
+53
View File
@@ -0,0 +1,53 @@
//! A library for enhacing `SeaORM`.
#![cfg_attr(docsrs, feature(doc_cfg))]
pub use sea_orm;
pub use sea_orm_migration;
pub mod model;
pub mod orm;
pub mod schema;
/// A macro for defining a Migrator with a custom migration table while
/// avoiding typing repetition.
///
/// This macro will `mod` every migration given.
///
/// ```ignore
/// seamantic::migrations! {
/// "seaql_migrations_auth"; // table name
/// m_000001, //
/// m_000002, // migrations
/// m_000003, //
/// }
/// ```
#[macro_export]
macro_rules! migrations {
($name:literal; $($migration:ident,)*) => {
use $crate::sea_orm::sea_query::IntoIden as _;
$(mod $migration;)*
/// Auto-generated migration manager
#[automatically_derived]
pub struct Migrator;
#[automatically_derived]
#[$crate::sea_orm_migration::async_trait::async_trait]
impl $crate::sea_orm_migration::MigratorTrait for Migrator {
fn migration_table_name() -> $crate::sea_orm::DynIden {
$crate::sea_orm_migration::prelude::Alias::new($name).into_iden()
}
fn migrations() -> Vec<Box<dyn $crate::sea_orm_migration::MigrationTrait>> {
vec![$(Box::new($migration::Migration),)*]
}
}
};
}
// dev-dependencies cannot be optional and `tokio` and `serde_test` are only used
// when the `sqlite` feature is active
#[cfg(test)]
use {serde_test as _, tokio as _};
+125
View File
@@ -0,0 +1,125 @@
//! [Duration] utilities.
use core::time::Duration;
use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation that `SeaORM` uses for durations.
///
/// "u64 unsupported by sqlx-sqlite", so use i64 as the bit representation.
type SeaOrmRepr = i64;
/// The internal represenation that [Duration] uses.
type DurationRepr = u64;
/// Wrapper around [Duration] to store a number of seconds.
///
/// # Warning:
/// Sub-second precision will be lost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Seconds(pub Duration);
impl From<Duration> for Seconds {
#[inline]
fn from(value: Duration) -> Self {
Self(Duration::from_secs(value.as_secs()))
}
}
impl From<Seconds> for Duration {
#[inline]
fn from(value: Seconds) -> Self {
value.0
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ValueType for Seconds {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<SeaOrmRepr as ValueType>::try_from(v)
.map(|i| DurationRepr::from_ne_bytes(i.to_ne_bytes()))
.map(Duration::from_secs)
.map(Self)
}
#[inline]
fn type_name() -> String {
core::any::type_name::<Self>().to_owned()
}
#[inline]
fn array_type() -> ArrayType {
SeaOrmRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
SeaOrmRepr::column_type()
}
}
impl From<Seconds> for Value {
#[inline]
fn from(value: Seconds) -> Self {
value.0.as_secs().into()
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl TryGetable for Seconds {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
SeaOrmRepr::try_get_by(res, index)
.map(|i| DurationRepr::from_ne_bytes(i.to_ne_bytes()))
.map(Duration::from_secs)
.map(Self)
}
}
impl TryFromU64 for Seconds {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
SeaOrmRepr::try_from_u64(n)
.map(|i| DurationRepr::from_ne_bytes(i.to_ne_bytes()))
.map(Duration::from_secs)
.map(Self)
}
}
impl Nullable for Seconds {
#[inline]
fn null() -> Value {
SeaOrmRepr::null()
}
}
#[cfg(test)]
mod tests {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::Seconds;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "seconds")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: u8,
#[sea_orm(primary_key, auto_increment = false)]
seconds: Seconds,
nullable: Option<Seconds>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
}
+208
View File
@@ -0,0 +1,208 @@
//! Typed IDs for use as primary keys.
use core::cmp::Ordering;
use core::fmt;
use core::hash::{Hash, Hasher};
use core::marker::PhantomData;
use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation used by the database.
pub type SeaOrmRepr = i64;
/// An opaque type representing a row ID.
///
/// IDs should be tagged with `#[sea_orm(primary_key, auto_increment = false)]`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
pub struct Id<T> {
/// The ID.
id: SeaOrmRepr,
/// `PhantomData` for `T`.
#[cfg_attr(feature = "serde", serde(skip_serializing, default))]
_phantom: PhantomData<T>,
}
// Manual implementation since `T: Clone` is not required.
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Clone for Id<T> {
#[inline]
fn clone(&self) -> Self {
*self
}
}
// Manual implementation since `T: Copy` is not required.
impl<T> Copy for Id<T> {}
// Manual implementation since `T: PartialEq` is not required.
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialEq for Id<T> {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.id.eq(&other.id)
}
}
// Manual implementation since `T: Eq` is not required.
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Eq for Id<T> {}
// Manual implementation since `T: PartialOrd` is not required.
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> PartialOrd for Id<T> {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
// Manual implementation since `T: Ord` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Ord for Id<T> {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
// Manual implementation since `T: Hash` is not required
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> Hash for Id<T> {
#[inline]
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl<T> Id<T> {
/// Allows the conversion from a raw value to [Id], though the use is discouraged.
#[inline]
#[must_use]
pub const fn from_raw(raw: SeaOrmRepr) -> Self {
Self {
id: raw,
_phantom: PhantomData,
}
}
/// Allows extracting the raw value, though the use is discouraged.
#[inline]
#[must_use]
pub const fn into_raw(self) -> SeaOrmRepr {
self.id
}
}
impl<T> fmt::Debug for Id<T> {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Id")
.field("T", &core::any::type_name::<T>())
.field("id", &self.id)
.finish()
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> ValueType for Id<T> {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<SeaOrmRepr as ValueType>::try_from(v).map(|id| Self {
id,
_phantom: PhantomData,
})
}
#[inline]
fn type_name() -> String {
format!("Id<{}>", core::any::type_name::<T>())
}
#[inline]
fn array_type() -> ArrayType {
SeaOrmRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
SeaOrmRepr::column_type()
}
}
impl<T> From<Id<T>> for Value {
#[inline]
fn from(value: Id<T>) -> Self {
value.id.into()
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl<T> TryGetable for Id<T> {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
SeaOrmRepr::try_get_by(res, index).map(|id| Self {
id,
_phantom: PhantomData,
})
}
}
impl<T> TryFromU64 for Id<T> {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
SeaOrmRepr::try_from_u64(n).map(|id| Self {
id,
_phantom: PhantomData,
})
}
}
impl<T> Nullable for Id<T> {
#[inline]
fn null() -> Value {
SeaOrmRepr::null()
}
}
#[cfg(test)]
mod tests {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::Id;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ids")]
#[expect(clippy::use_self, reason = "derive macros cause Self to be invalid")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>,
nullable: Option<Id<Model>>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
#[test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
fn round_trip() {
let raw = 1234;
let id = Id::<()>::from_raw(raw);
assert_eq!(raw, id.into_raw());
}
#[test]
#[cfg(feature = "serde")]
fn ser_de() {
let id: Id<()> = Id::from_raw(1234);
serde_test::assert_tokens(&id, &[serde_test::Token::I64(1234)]);
}
}
+6
View File
@@ -0,0 +1,6 @@
//! `SeaORM` column types for enforcing data consistency.
pub mod duration;
pub mod id;
pub mod net;
pub mod path;
+193
View File
@@ -0,0 +1,193 @@
//! [`IpAddr`], [`Ipv4Addr`], and [`Ipv6Addr`] utilities.
use core::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation used by the database.
type SeaOrmRepr = String;
/// Wrapper around [`IpAddr`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct IpAddress(pub IpAddr);
/// Wrapper around [`Ipv4Addr`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Ipv4Address(pub Ipv4Addr);
/// Wrapper around [`Ipv6Addr`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
pub struct Ipv6Address(pub Ipv6Addr);
/// Implements `SeaORM` traits for `IP` types.
macro_rules! impl_addr {
($t:ty, $inner:ty, $repr:ty) => {
impl From<$inner> for $t {
#[inline]
fn from(value: $inner) -> Self {
Self(value)
}
}
impl From<$t> for $inner {
#[inline]
fn from(value: $t) -> Self {
value.0
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ValueType for $t {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<SeaOrmRepr as ValueType>::try_from(v)
.and_then(|s| s.parse().map_err(|_| ValueTypeErr))
.map(Self)
}
#[inline]
fn type_name() -> String {
core::any::type_name::<Self>().to_string()
}
#[inline]
fn array_type() -> ArrayType {
SeaOrmRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
SeaOrmRepr::column_type()
}
}
impl From<$t> for Value {
#[inline]
fn from(value: $t) -> Self {
value.0.to_string().into()
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl TryGetable for $t {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
SeaOrmRepr::try_get_by(res, index)
.and_then(|s| s.parse().map_err(|_| TryGetError::Null(Self::type_name())))
.map(Self)
}
}
impl TryFromU64 for $t {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
SeaOrmRepr::try_from_u64(n)
.and_then(|s| {
s.parse()
.map_err(|_| DbErr::ConvertFromU64(core::any::type_name::<Self>()))
})
.map(Self)
}
}
impl Nullable for $t {
#[inline]
fn null() -> Value {
SeaOrmRepr::null()
}
}
};
}
impl_addr!(IpAddress, IpAddr, SeaOrmRepr);
impl_addr!(Ipv4Address, Ipv4Addr, SeaOrmRepr);
impl_addr!(Ipv6Address, Ipv6Addr, SeaOrmRepr);
#[cfg(test)]
mod tests {
mod ipaddress {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::super::IpAddress;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ipaddress")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: u8,
#[sea_orm(primary_key, auto_increment = false)]
addr: IpAddress,
nullable: Option<IpAddress>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
}
mod ipv4address {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::super::Ipv4Address;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ipv4address")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: u8,
#[sea_orm(primary_key, auto_increment = false)]
addr: Ipv4Address,
nullable: Option<Ipv4Address>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
}
mod ipv6address {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::super::Ipv6Address;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "ipv6address")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: u8,
#[sea_orm(primary_key, auto_increment = false)]
addr: Ipv6Address,
nullable: Option<Ipv6Address>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
}
}
+132
View File
@@ -0,0 +1,132 @@
//! [Path] and [`PathBuf`] utilities.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt as _;
#[cfg(windows)]
compile_error!("PathBytes is not supported on Windows");
use sea_orm::sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr};
use sea_orm::{ColIdx, ColumnType, DbErr, QueryResult, TryFromU64, TryGetError, TryGetable, Value};
/// The internal representation used by the database.
type SeaOrmRepr = Vec<u8>;
/// Wrapper around [`PathBuf`] to store paths as bytes in `SeaORM`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[repr(transparent)]
#[expect(clippy::exhaustive_structs, reason = "unlikely to change")]
#[expect(clippy::module_name_repetitions, reason = "Bytes wouldn't make sense")]
pub struct PathBytes(pub PathBuf);
impl From<PathBuf> for PathBytes {
#[inline]
fn from(value: PathBuf) -> Self {
Self(value)
}
}
impl From<PathBytes> for PathBuf {
#[inline]
fn from(value: PathBytes) -> Self {
value.0
}
}
impl AsRef<Path> for PathBytes {
#[inline]
fn as_ref(&self) -> &Path {
&self.0
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ValueType for PathBytes {
#[inline]
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
<SeaOrmRepr as ValueType>::try_from(v)
.map(OsString::from_vec)
.map(PathBuf::from)
.map(Self)
}
#[inline]
fn type_name() -> String {
core::any::type_name::<Self>().to_owned()
}
#[inline]
fn array_type() -> ArrayType {
SeaOrmRepr::array_type()
}
#[inline]
fn column_type() -> ColumnType {
SeaOrmRepr::column_type()
}
}
impl From<PathBytes> for Value {
#[inline]
fn from(value: PathBytes) -> Self {
value.0.into_os_string().into_vec().into()
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl TryGetable for PathBytes {
#[inline]
fn try_get_by<I: ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
SeaOrmRepr::try_get_by(res, index)
.map(OsString::from_vec)
.map(PathBuf::from)
.map(Self)
}
}
impl TryFromU64 for PathBytes {
#[inline]
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
SeaOrmRepr::try_from_u64(n)
.map(OsString::from_vec)
.map(PathBuf::from)
.map(Self)
}
}
impl Nullable for PathBytes {
#[inline]
fn null() -> Value {
SeaOrmRepr::null()
}
}
#[cfg(test)]
mod tests {
use sea_orm::{
ActiveModelBehavior, DeriveEntityModel, DerivePrimaryKey, DeriveRelation, EntityTrait,
EnumIter, PrimaryKeyTrait,
};
use super::PathBytes;
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "paths")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: u8,
#[sea_orm(primary_key, auto_increment = false)]
path: PathBytes,
nullable: Option<PathBytes>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[derive(Debug, EnumIter, DeriveRelation)]
enum Relation {}
}
+4
View File
@@ -0,0 +1,4 @@
//! Helpers for working with `SeaORM`.
mod upsert;
pub use upsert::UpsertTrait;
+66
View File
@@ -0,0 +1,66 @@
//! Provides [`UpsertTrait`] for performing an upsert into a database.
use sea_orm::sea_query::{IntoColumnRef as _, OnConflict};
use sea_orm::{ActiveModelTrait, EntityTrait, Insert, Iterable as _};
/// This trait add a method on [Insert] to allow for upsert behavior.
pub trait UpsertTrait: private::Sealed {
/// Set ON CONFLICT on primary key to update all other columns.
#[must_use]
fn on_conflict_upsert(self) -> Self;
}
/// Returns an [Iterator] over the `PrimaryKey` columns of an [`ActiveModelTrait`].
fn primary_key_iter<A: ActiveModelTrait>()
-> impl Iterator<Item = <A::Entity as EntityTrait>::PrimaryKey> {
<A::Entity as EntityTrait>::PrimaryKey::iter()
}
/// Returns an [Iterator] over the [`EntityTrait`] columns of an [`ActiveModelTrait`].
fn column_iter<A: ActiveModelTrait>() -> impl Iterator<Item = <A::Entity as EntityTrait>::Column> {
<<A as ActiveModelTrait>::Entity as EntityTrait>::Column::iter()
}
impl<A: ActiveModelTrait> private::Sealed for Insert<A> {}
impl<A: ActiveModelTrait> UpsertTrait for Insert<A> {
#[inline]
fn on_conflict_upsert(self) -> Self {
self.on_conflict(
OnConflict::columns(primary_key_iter::<A>())
.update_columns(column_iter::<A>().filter(|col| {
!primary_key_iter::<A>().any(|pk| pk.into_column_ref() == col.into_column_ref())
}))
.to_owned(),
)
}
}
/// Private module.
mod private {
/// Sealed trait to prevent implementations of `UpsertTrait`.
#[expect(unnameable_types, reason = "sealed trait")]
pub trait Sealed {}
}
#[cfg(test)]
mod tests {
use super::{UpsertTrait, private::Sealed};
#[test]
fn seal() {
struct S;
impl Sealed for S {}
impl UpsertTrait for S {
fn on_conflict_upsert(self) -> Self {
self
}
}
fn is_sealed<T: Sealed>() {}
fn is_upsert<T: UpsertTrait>() {}
is_sealed::<S>();
is_upsert::<S>();
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Helpers for defining `SeaORM` schemas.
#[cfg(feature = "sqlite")]
mod sqlite;
#[cfg(feature = "sqlite")]
#[cfg_attr(docsrs, doc(cfg(feature = "sqlite")))]
pub use sqlite::*;
+505
View File
@@ -0,0 +1,505 @@
//! Helper functions for declaring `SQLite` schemas.
use sea_orm_migration::schema::{integer_null, string};
use sea_orm_migration::sea_query::{ColumnDef, IntoIden};
/// Sets the column to be an alias for SQLite's rowid.
///
/// Required conditions:
/// - This column must *not* be `auto_increment`
/// - There cannot be other `primary_key` columns in the table
///
/// When using `DeriveEntityModel`, the type must be `i64` (or equivalent)
/// and should be tagged with:
///
/// > `#[sea_orm(primary_key, auto_increment = false)]`.
///
/// When using the new entity format:
///
/// use sea_orm::entity::prelude::*;
/// use seamantic::model::id::Id;
///
/// #[sea_orm::model]
/// #[derive(Debug, Clone, DeriveEntityModel)]
/// #[sea_orm(table_name = "rowid_test")]
/// pub struct Model {
/// #[sea_orm(primary_key, auto_increment = false)]
/// id: Id<Model>,
/// }
/// impl ActiveModelBehavior for ActiveModel {}
#[inline]
pub fn sqlite_rowid_alias<T: IntoIden>(name: T) -> ColumnDef {
integer_null(name).primary_key().take()
}
/// Set the column to be a case insensitive string.
///
/// When using the new entity format:
///
/// use sea_orm::entity::prelude::*;
/// use seamantic::model::id::Id;
///
/// #[sea_orm::model]
/// #[derive(Debug, Clone, DeriveEntityModel)]
/// #[sea_orm(table_name = "nocase_test")]
/// pub struct Model {
/// #[sea_orm(primary_key, auto_increment = false)]
/// id: i64,
/// #[sea_orm(extra = "COLLATE NOCASE")]
/// nocase: String,
/// }
/// impl ActiveModelBehavior for ActiveModel {}
#[inline]
pub fn sqlite_case_insensitive_string<T: IntoIden>(name: T) -> ColumnDef {
string(name).extra("COLLATE NOCASE").take()
}
#[cfg(test)]
mod entity_tests {
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
/// Create and initialize a new in-memory SQLite database.
///
/// # Panics
/// If the database driver fails to connect, or if the schema registry fails
/// to sync the database schema.
async fn new_initialized_memory_db() -> DatabaseConnection {
let options = ConnectOptions::new("sqlite::memory:");
let db = Database::connect(options)
.await
.expect("Database::connect()");
db.get_schema_registry("seamantic::schema::sqlite::*")
.sync(&db)
.await
.expect("db.get_schema_registry().sync()");
db
}
#[expect(
clippy::unused_async_trait_impl,
reason = "sea_orm::model generates unpolled futures"
)]
#[expect(
clippy::same_name_method,
reason = "sea_orm::model generates the same method names"
)]
#[expect(
clippy::allow_attributes,
reason = "under different conditions sea_orm::model generates different code"
)]
#[allow(
clippy::future_not_send,
reason = "sea_orm::model generates un-send futures"
)]
mod rowid_test {
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::entity::prelude::*;
use crate::model::id::{Id, SeaOrmRepr};
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "rowid_test")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "numbers are not ambiguous")]
async fn sqlite_rowid_alias() {
let db = super::new_initialized_memory_db().await;
// Starts at 1 and increments
for i in 1..=3 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Delete the top number and re-add
for i in 3..=3 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
_ = model.delete(&db).await.expect("delete");
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Jump to 100 and increment
for i in 100..=103 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Continue to increment
for i in 104..=104 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Jump to SeaOrmRepr::MAX and increment
for i in SeaOrmRepr::MAX..=SeaOrmRepr::MAX {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Next ones are random around the center
for _ in 0..3 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert!(model.id.into_raw() > 0);
}
// Zero ID is valid
for i in 0..=0 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Negative ID is valid
for i in SeaOrmRepr::MIN..=(SeaOrmRepr::MIN + 3) {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
}
}
#[expect(
clippy::unused_async_trait_impl,
reason = "sea_orm::model generates unpolled futures"
)]
#[expect(
clippy::same_name_method,
reason = "sea_orm::model generates the same method names"
)]
#[expect(
clippy::allow_attributes,
reason = "under different conditions sea_orm::model generates different code"
)]
#[allow(
clippy::future_not_send,
reason = "sea_orm::model generates un-send futures"
)]
mod nocase_test {
use sea_orm::ActiveValue::Set;
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "nocase_test")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: i64,
case: String,
#[sea_orm(extra = "COLLATE NOCASE")]
nocase: String,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
#[cfg(not(miri))]
#[tokio::test]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn sqlite_case_insensitive_string() {
let db = super::new_initialized_memory_db().await;
let i = 50;
// Insert a lowercase string
{
let model = ActiveModel {
id: Set(i),
case: Set("abcd".to_owned()),
nocase: Set("abcd".to_owned()),
};
drop(model.insert(&db).await.expect("insert"));
}
// Query by uppercase string
{
Entity::find()
.filter(Column::Case.eq("ABCD"))
.one(&db)
.await
.expect("find by case insensitive string")
.ok_or(())
.expect_err("find by case insensitive string");
let model = Entity::find()
.filter(Column::Nocase.eq("ABCD"))
.one(&db)
.await
.expect("find by case insensitive string")
.expect("find by case insensitive string");
assert_eq!(model.id, i);
// The string should be read back as-is
assert_eq!(model.nocase, "abcd");
}
}
}
}
#[cfg(test)]
mod tests {
use sea_orm::ActiveValue::{NotSet, Set};
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectOptions, Database};
use sea_orm_migration::async_trait::async_trait;
use sea_orm_migration::prelude::*;
use crate::model::id::{Id, SeaOrmRepr};
use super::{sqlite_case_insensitive_string, sqlite_rowid_alias};
#[expect(clippy::missing_panics_doc, reason = "unit test")]
async fn new_memory_db() -> DatabaseConnection {
let options = ConnectOptions::new("sqlite::memory:");
Database::connect(options).await.expect("Database::connect")
}
#[cfg(not(miri))]
#[tokio::test]
#[expect(unused_qualifications, reason = "derive macro qualifies everything")]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::default_numeric_fallback, reason = "numbers are not ambiguous")]
#[expect(clippy::redundant_test_prefix, reason = "name collision")]
async fn test_sqlite_rowid_alias() {
struct Migrator;
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(Migration)]
}
}
#[derive(Iden)]
pub(super) enum TestTable {
Table,
Id,
}
#[derive(DeriveMigrationName)]
struct Migration;
#[async_trait]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl MigrationTrait for Migration {
#[expect(
elided_lifetimes_in_paths,
reason = "async_trait causes lifetimes to be strange"
)]
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(TestTable::Table)
.col(sqlite_rowid_alias(TestTable::Id))
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "test_table")]
#[expect(clippy::use_self, reason = "derive macros cause Self to be invalid")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: Id<Model>,
}
#[derive(Debug, EnumIter)]
enum Relation {}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl RelationTrait for Relation {
#[expect(clippy::panic, reason = "no relation")]
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
let db = new_memory_db().await;
Migrator::up(&db, None).await.expect("up");
// Starts at 1 and increments
for i in 1..=3 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Delete the top number and re-add
for i in 3..=3 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
_ = model.delete(&db).await.expect("delete");
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Jump to 100 and increment
for i in 100..=103 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Continue to increment
for i in 104..=104 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Jump to SeaOrmRepr::MAX and increment
for i in SeaOrmRepr::MAX..=SeaOrmRepr::MAX {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Next ones are random around the center
for _ in 0..3 {
let model = ActiveModel { id: NotSet };
let model = model.insert(&db).await.expect("insert");
assert!(model.id.into_raw() > 0);
}
// Zero ID is valid
for i in 0..=0 {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
// Negative ID is valid
for i in SeaOrmRepr::MIN..=(SeaOrmRepr::MIN + 3) {
let model = ActiveModel {
id: Set(Id::from_raw(i)),
};
let model = model.insert(&db).await.expect("insert");
assert_eq!(model.id.into_raw(), i);
}
}
#[cfg(not(miri))]
#[tokio::test]
#[expect(unused_qualifications, reason = "derive macro qualifies everything")]
#[expect(clippy::missing_panics_doc, reason = "unit test")]
#[expect(clippy::redundant_test_prefix, reason = "name collision")]
async fn test_sqlite_case_insensitive_string() {
struct Migrator;
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(Migration)]
}
}
#[derive(Iden)]
pub(super) enum TestTable {
Table,
Id,
CiStr,
}
#[derive(DeriveMigrationName)]
struct Migration;
#[async_trait]
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl MigrationTrait for Migration {
#[expect(
elided_lifetimes_in_paths,
reason = "async_trait causes lifetimes to be strange"
)]
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(TestTable::Table)
.col(sqlite_rowid_alias(TestTable::Id))
.col(sqlite_case_insensitive_string(TestTable::CiStr))
.to_owned(),
)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, DeriveEntityModel)]
#[sea_orm(table_name = "test_table")]
struct Model {
#[sea_orm(primary_key, auto_increment = false)]
id: i64,
ci_str: String,
}
#[derive(Debug, EnumIter)]
enum Relation {}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl RelationTrait for Relation {
#[expect(clippy::panic, reason = "no relation")]
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl ActiveModelBehavior for ActiveModel {}
let db = new_memory_db().await;
Migrator::up(&db, None).await.expect("up");
let i = 50;
// Insert a lowercase string
{
let model = ActiveModel {
id: Set(i),
ci_str: Set("abcd".to_owned()),
};
drop(model.insert(&db).await.expect("insert"));
}
// Query by uppercase string
{
let model = Entity::find()
.filter(Column::CiStr.eq("ABCD"))
.one(&db)
.await
.expect("find by case insensitive string")
.expect("find by case insensitive string");
assert_eq!(model.id, i);
// The string should be read back as-is
assert_eq!(model.ci_str, "abcd");
}
}
}