Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sea-orm-sync/src/database/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ mod tests {
DbBackend, DbErr, IntoMockRow, MockDatabase, Statement, Transaction, TransactionError,
TransactionTrait, entity::*, error::*, tests_cfg::*,
};
// In the sync variant `StreamShim` provides `try_next`; `futures_util` isn't a dependency there.
#[cfg(not(feature = "sync"))]
use futures_util::TryStreamExt;
use pretty_assertions::assert_eq;

Expand Down
37 changes: 35 additions & 2 deletions sea-orm-sync/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use thiserror::Error;

/// An error from unsuccessful database operations
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum DbErr {
/// This error can happen when the connection pool is fully-utilized
#[error("Failed to acquire connection from pool: {0}")]
Expand Down Expand Up @@ -201,7 +202,13 @@ where
DbErr::Json(s.to_string())
}

/// An error from unsuccessful SQL query
/// A portable, backend-agnostic classification of the most common SQL
/// constraint violations, produced by [`DbErr::sql_err`].
///
/// Only unique-key and foreign-key violations are recognized. For any other
/// failure, or for backend-specific detail (SQLSTATE, driver error codes,
/// check constraints, and so on), inspect the underlying driver error instead —
/// see [`DbErr::sql_err`] for the pattern.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SqlErr {
Expand All @@ -215,7 +222,33 @@ pub enum SqlErr {

#[allow(dead_code)]
impl DbErr {
/// Convert generic DbErr by sqlx to SqlErr, return none if the error is not any type of SqlErr
/// Classify this error as a portable [`SqlErr`] — a unique-key or
/// foreign-key constraint violation — returning `None` if it is neither, or
/// if it did not originate from a database driver.
///
/// Only the two most common constraint violations are recognized, across
/// MySQL, Postgres and SQLite. For anything else (SQLSTATE codes, check
/// constraints, other driver-specific detail) match on the underlying
/// `RuntimeErr::SqlxError` and inspect the driver error yourself:
///
/// ```
/// # #[cfg(feature = "sqlx-postgres")]
/// # fn example(err: sea_orm::DbErr) {
/// use sea_orm::{DbErr, RuntimeErr};
/// use std::ops::Deref;
///
/// if let Some(sql_err) = err.sql_err() {
/// // Portable across MySQL / Postgres / SQLite.
/// eprintln!("constraint violation: {sql_err}");
/// } else if let DbErr::Query(RuntimeErr::SqlxError(e)) | DbErr::Exec(RuntimeErr::SqlxError(e)) =
/// &err
/// && let sea_orm::sqlx::Error::Database(db_err) = e.deref()
/// {
/// // Backend-specific: inspect the raw driver error.
/// eprintln!("SQLSTATE: {:?}", db_err.code());
/// }
/// # }
/// ```
pub fn sql_err(&self) -> Option<SqlErr> {
#[cfg(any(
feature = "sqlx-mysql",
Expand Down
1 change: 1 addition & 0 deletions sea-orm-sync/src/executor/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct Deleter {

/// Result of a `DELETE`: how many rows were removed.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct DeleteResult {
/// Number of rows removed by the statement.
pub rows_affected: u64,
Expand Down
1 change: 1 addition & 0 deletions sea-orm-sync/src/executor/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct Updater {
/// Result of an `UPDATE` that doesn't return rows: how many rows were
/// modified.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct UpdateResult {
/// Number of rows touched by the statement.
pub rows_affected: u64,
Expand Down
77 changes: 67 additions & 10 deletions sea-orm-sync/src/schema/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,17 @@ impl SchemaBuilder {
self.entities.push(entity);
}

/// Synchronize the schema with database, will create missing tables, columns, unique keys, and foreign keys.
/// This operation is addition only, will not drop any table / columns.
/// Synchronize the schema with the database: creates any missing tables, columns,
/// unique keys, and foreign keys.
///
/// Non-destructive by design. Sync only adds — it never drops or alters existing tables
/// or columns. If a column already exists but its type or constraints differ from the
/// entity, sync leaves it untouched and logs a warning; apply such changes with a
/// migration. Destructive operations (ALTER / DROP) are intentionally out of scope and
/// would be a separate, explicitly-named API.
///
/// Unstable: schema sync is experimental and exempt from semver — its behaviour and
/// signature may change in a minor (2.x) release.
#[cfg(feature = "schema-sync")]
#[cfg_attr(docsrs, doc(cfg(feature = "schema-sync")))]
pub fn sync<C>(self, db: &C) -> Result<(), DbErr>
Expand Down Expand Up @@ -257,6 +266,17 @@ impl SchemaBuilder {
Ok(())
}

// Regression guard for #3100: `sync()` must return a `Send` future, otherwise it
// cannot be used from `tokio::spawn` / most runtimes. Compiled (never called)
// whenever `schema-sync` + a backend is on, so a future dep bump that reintroduces a
// `!Send` value across an await fails the build here.
#[allow(dead_code)]
#[cfg(all(feature = "schema-sync", feature = "sqlx-sqlite"))]
fn _assert_sync_future_is_send(self, db: &crate::DatabaseConnection) {
fn assert_send<T: Send>(_: &T) {}
assert_send(&self.sync(db));
}

fn sorted_tables(&self) -> Vec<TableName> {
let mut sorter = TopologicalSort::<TableName>::new();

Expand Down Expand Up @@ -397,14 +417,11 @@ impl EntitySchemaInfo {
let existing_table = existing.find_table(self.schema_name.as_deref(), &table_name);
if let Some(existing_table) = existing_table {
for column_def in self.table.get_columns() {
let mut column_exists = false;
for existing_column in existing_table.get_columns() {
if column_def.get_column_name() == existing_column.get_column_name() {
column_exists = true;
break;
}
}
if !column_exists {
let existing_column = existing_table
.get_columns()
.iter()
.find(|c| c.get_column_name() == column_def.get_column_name());
let Some(existing_column) = existing_column else {
let mut renamed_from = "";
if let Some(comment) = &column_def.get_column_spec().comment
&& let Some((_, suffix)) = comment.rsplit_once("renamed_from \"")
Expand All @@ -428,6 +445,28 @@ impl EntitySchemaInfo {
),
)?;
}
continue;
};
// The column already exists. Sync is non-destructive and will not alter it,
// but warn on a type divergence so the change isn't silently ignored (#3106).
if let (Some(desired), Some(current)) = (
column_def.get_column_type(),
existing_column.get_column_type(),
) {
let backend = db.get_database_backend();
let desired_sql = render_column_type(backend, desired);
let current_sql = render_column_type(backend, current);
if desired_sql != current_sql {
tracing::warn!(
"schema sync: column `{}`.`{}` is `{}` in the database but the entity \
defines `{}`; sync is non-destructive and will not alter it (apply the \
change with a migration)",
table_name.1.to_string(),
column_def.get_column_name(),
current_sql,
desired_sql,
);
}
}
}
if db.get_database_backend() != DbBackend::Sqlite {
Expand Down Expand Up @@ -599,6 +638,24 @@ fn get_table_name(table_ref: Option<&TableRef>) -> TableName {
}
}

/// Render a `ColumnType` to its backend-specific SQL type string. Used to compare an
/// entity's column type against the live database (equal renderings mean no divergence,
/// so e.g. SQLite `Integer`/`BigInteger` — both `integer` — don't false-alarm).
// Always compiled, like `EntitySchemaInfo::sync` which calls it.
#[allow(dead_code)]
fn render_column_type(backend: DbBackend, col_type: &sea_query::ColumnType) -> String {
use sea_query::backend::TableBuilder;
let mut sql = String::new();
match backend {
DbBackend::MySql => sea_query::MysqlQueryBuilder.prepare_column_type(col_type, &mut sql),
DbBackend::Postgres => {
sea_query::PostgresQueryBuilder.prepare_column_type(col_type, &mut sql)
}
DbBackend::Sqlite => sea_query::SqliteQueryBuilder.prepare_column_type(col_type, &mut sql),
}
sql
}

fn compare_foreign_key(a: &ForeignKeyCreateStatement, b: &ForeignKeyCreateStatement) -> bool {
let a = a.get_foreign_key();
let b = b.get_foreign_key();
Expand Down
13 changes: 13 additions & 0 deletions sea-orm-sync/tests/common/features/value_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ where
#[derive(Clone, Debug, PartialEq, Eq, DeriveValueType)]
pub struct StringVec(pub Vec<String>);

#[derive(Clone, Debug, PartialEq, Eq, DeriveValueType)]
#[sea_orm(try_getable_array)]
pub struct GoodId(pub i32);

#[cfg(feature = "postgres-array")]
use sea_orm::FromQueryResult;

#[cfg(feature = "postgres-array")]
#[derive(Debug, FromQueryResult)]
pub struct GoodIdArray {
pub ids: Vec<GoodId>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, DeriveValueType)]
#[sea_orm(value_type = "String")]
pub enum Tag1 {
Expand Down
14 changes: 11 additions & 3 deletions sea-orm-sync/tests/value_type_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ pub use common::{
TestContext,
features::{
value_type::{
MyInteger, StringVec, Tag1, Tag2, Tag3, Tag4, Tag5, value_type_general, value_type_pg,
value_type_pk,
GoodId, GoodIdArray, MyInteger, StringVec, Tag1, Tag2, Tag3, Tag4, Tag5,
value_type_general, value_type_pg, value_type_pk,
},
*,
},
setup::*,
};
use pretty_assertions::assert_eq;
use sea_orm::{
DatabaseConnection, DbBackend, QuerySelect,
DatabaseConnection, DbBackend, FromQueryResult, QuerySelect, Statement,
entity::{prelude::*, *},
};
use sea_query::{ArrayType, ColumnType, PostgresQueryBuilder, Value, ValueType, ValueTypeErr};
Expand Down Expand Up @@ -98,6 +98,14 @@ pub fn insert_value_postgres(db: &DatabaseConnection) -> Result<(), DbErr> {
let value: u32 = row.try_get("", "number").unwrap();
assert_eq!(value, 48u32);

let good_ids = GoodIdArray::find_by_statement(Statement::from_string(
DbBackend::Postgres,
"SELECT ARRAY[1, 2, 3]::int4[] AS ids",
))
.one(db)?
.unwrap();
assert_eq!(good_ids.ids, vec![GoodId(1), GoodId(2), GoodId(3)]);

Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions src/database/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ mod tests {
DbBackend, DbErr, IntoMockRow, MockDatabase, Statement, Transaction, TransactionError,
TransactionTrait, entity::*, error::*, tests_cfg::*,
};
// In the sync variant `StreamShim` provides `try_next`; `futures_util` isn't a dependency there.
#[cfg(not(feature = "sync"))]
use futures_util::TryStreamExt;
use pretty_assertions::assert_eq;

Expand Down
Loading