From 8a8a60bd0285ec170a35cba94d8d09ef9b3163b2 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Fri, 3 Jul 2026 11:33:21 +0100 Subject: [PATCH] Fix sync-variant futures_util handling in mock test; regenerate sea-orm-sync The mock.rs test module imported `futures_util::TryStreamExt` unconditionally, but the sync variant has no futures_util dependency (it uses `StreamShim::try_next` instead, already gated on `feature = "sync"`). A full make-sync regen therefore didn't compile its lib unit tests. Gate the import on `not(feature = "sync")` to match the StreamShim pattern. Also regenerate sea-orm-sync to catch up with recently-merged changes (#2940 docs, #3106 sync warning, #3108 try_getable_array, non_exhaustive). --- sea-orm-sync/src/database/mock.rs | 2 + sea-orm-sync/src/error.rs | 37 ++++++++- sea-orm-sync/src/executor/delete.rs | 1 + sea-orm-sync/src/executor/update.rs | 1 + sea-orm-sync/src/schema/builder.rs | 77 ++++++++++++++++--- .../tests/common/features/value_type.rs | 13 ++++ sea-orm-sync/tests/value_type_tests.rs | 14 +++- src/database/mock.rs | 2 + 8 files changed, 132 insertions(+), 15 deletions(-) diff --git a/sea-orm-sync/src/database/mock.rs b/sea-orm-sync/src/database/mock.rs index 67cbe529c2..5c5f094787 100644 --- a/sea-orm-sync/src/database/mock.rs +++ b/sea-orm-sync/src/database/mock.rs @@ -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; diff --git a/sea-orm-sync/src/error.rs b/sea-orm-sync/src/error.rs index d4c666ccb1..b053d47918 100644 --- a/sea-orm-sync/src/error.rs +++ b/sea-orm-sync/src/error.rs @@ -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}")] @@ -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 { @@ -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 { #[cfg(any( feature = "sqlx-mysql", diff --git a/sea-orm-sync/src/executor/delete.rs b/sea-orm-sync/src/executor/delete.rs index 59cf1b6063..69c6a8ca09 100644 --- a/sea-orm-sync/src/executor/delete.rs +++ b/sea-orm-sync/src/executor/delete.rs @@ -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, diff --git a/sea-orm-sync/src/executor/update.rs b/sea-orm-sync/src/executor/update.rs index 8a68f44e68..0c8837d336 100644 --- a/sea-orm-sync/src/executor/update.rs +++ b/sea-orm-sync/src/executor/update.rs @@ -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, diff --git a/sea-orm-sync/src/schema/builder.rs b/sea-orm-sync/src/schema/builder.rs index 2c7a34638e..9769998f66 100644 --- a/sea-orm-sync/src/schema/builder.rs +++ b/sea-orm-sync/src/schema/builder.rs @@ -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(self, db: &C) -> Result<(), DbErr> @@ -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) {} + assert_send(&self.sync(db)); + } + fn sorted_tables(&self) -> Vec { let mut sorter = TopologicalSort::::new(); @@ -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 \"") @@ -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 { @@ -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(); diff --git a/sea-orm-sync/tests/common/features/value_type.rs b/sea-orm-sync/tests/common/features/value_type.rs index a58dbc283b..fc1bbbd22d 100644 --- a/sea-orm-sync/tests/common/features/value_type.rs +++ b/sea-orm-sync/tests/common/features/value_type.rs @@ -72,6 +72,19 @@ where #[derive(Clone, Debug, PartialEq, Eq, DeriveValueType)] pub struct StringVec(pub Vec); +#[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, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, DeriveValueType)] #[sea_orm(value_type = "String")] pub enum Tag1 { diff --git a/sea-orm-sync/tests/value_type_tests.rs b/sea-orm-sync/tests/value_type_tests.rs index f84b291f41..d84190b632 100644 --- a/sea-orm-sync/tests/value_type_tests.rs +++ b/sea-orm-sync/tests/value_type_tests.rs @@ -9,8 +9,8 @@ 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, }, *, }, @@ -18,7 +18,7 @@ pub use common::{ }; 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}; @@ -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(()) } diff --git a/src/database/mock.rs b/src/database/mock.rs index 10bd3d9ce4..8c6e522df8 100644 --- a/src/database/mock.rs +++ b/src/database/mock.rs @@ -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;