From aaee09829c973f92b5cc10ad77b6e62998b7c86b Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Sun, 23 Aug 2026 12:04:10 +0100 Subject: [PATCH 1/5] fix(codegen): map postgres ranges to PgRange for oxide format sea-schema surfaces range columns as ColumnType::Custom, which write_rs_type renders as String. That is correct for the standard format, where get_col_type_attrs pairs it with select_as = "text", but the oxide format decodes rows straight into the model struct with sqlx::FromRow, so a numrange column produced a struct that compiled and then failed at runtime with "Rust type Option is not compatible with SQL type NUMRANGE". Render the six range types as sqlx's PgRange instead, following the element types sqlx implements and honouring --date-time-crate for the temporal ones. Two constraints shape the output. PgRange implements neither Serialize nor Deserialize, so range fields carry #[serde(skip)]; skipping a field requires Default to deserialize, so they are always Option regardless of nullability. BigDecimal implements only PartialEq, so an entity holding a numrange cannot derive Eq, which get_oxide_eq_needed now accounts for the way get_eq_needed already does for floats. Co-Authored-By: Claude Opus 5 --- sea-orm-codegen/src/entity/base_entity.rs | 28 ++++++- sea-orm-codegen/src/entity/column.rs | 81 ++++++++++++++++++ sea-orm-codegen/src/entity/writer/oxide.rs | 95 +++++++++++++++++++++- 3 files changed, 199 insertions(+), 5 deletions(-) diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index 865130dc68..172614a224 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -5,7 +5,8 @@ use quote::quote; use sea_query::ColumnType; use crate::{ - Column, ColumnOption, ConjunctRelation, PrimaryKey, Relation, util::escape_rust_keyword, + Column, ColumnOption, ConjunctRelation, PrimaryKey, Relation, entity::column::oxide_range, + util::escape_rust_keyword, }; #[derive(Clone, Debug)] @@ -56,6 +57,14 @@ impl Entity { .collect() } + pub fn get_oxide_column_rs_types(&self, opt: &ColumnOption) -> Vec { + self.columns + .clone() + .into_iter() + .map(|col| col.get_oxide_rs_type(opt)) + .collect() + } + pub fn get_column_defs(&self) -> Vec { self.columns .clone() @@ -276,6 +285,23 @@ impl Entity { .map_or(quote! {, Eq}, |_| quote! {}) } + /// As `get_eq_needed`, but also rules out the range element types that are + /// not `Eq`. The oxide format renders ranges as `PgRange` rather than + /// `String`, so a model struct can carry a field that only implements + /// `PartialEq`. + pub fn get_oxide_eq_needed(&self) -> TokenStream { + let has_non_eq_range = self + .columns + .iter() + .filter_map(|column| oxide_range(&column.col_type)) + .any(|range| !range.element_is_eq()); + + match has_non_eq_range { + true => quote! {}, + false => self.get_eq_needed(), + } + } + pub fn get_column_serde_attributes( &self, serde_skip_deserializing_primary_key: bool, diff --git a/sea-orm-codegen/src/entity/column.rs b/sea-orm-codegen/src/entity/column.rs index ff571df144..5cba323992 100644 --- a/sea-orm-codegen/src/entity/column.rs +++ b/sea-orm-codegen/src/entity/column.rs @@ -107,6 +107,24 @@ impl Column { } } + /// The oxide format decodes rows straight into the model struct with + /// `sqlx::FromRow`, so every field has to name a type sqlx can decode from + /// that column's Postgres type. `get_rs_type` renders ranges as `String`, + /// which is only correct for the standard format, where + /// `get_col_type_attrs` pairs it with `select_as = "text"`. + /// + /// Range fields are always optional. `PgRange` has no serde support, so + /// `get_oxide_col_type_attrs` skips these fields, and skipping a field + /// requires it to implement `Default` to deserialize — which `Option` + /// provides and `PgRange` does not. + pub fn get_oxide_rs_type(&self, opt: &ColumnOption) -> TokenStream { + let Some(range) = oxide_range(&self.col_type) else { + return self.get_rs_type(opt); + }; + let element: TokenStream = range.element_rs_type(opt).parse().unwrap(); + quote! { Option> } + } + pub fn get_col_type_attrs(&self) -> Option { let col_type = match &self.col_type { ColumnType::Float => Some("Float".to_owned()), @@ -133,6 +151,11 @@ impl Column { } pub fn get_oxide_col_type_attrs(&self) -> Option { + if oxide_range(&self.col_type).is_some() { + // sqlx's PgRange implements neither Serialize nor Deserialize. + return quote! { #[serde(skip)] }.into(); + } + if !matches!(self.col_type, ColumnType::TimestampWithTimeZone) { return None; } @@ -323,6 +346,64 @@ impl From<&ColumnDef> for Column { } } + +/// A Postgres range type. sea-schema surfaces these as `ColumnType::Custom`, +/// since sea-query has no range variant of its own. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum OxideRange { + Int4, + Int8, + Num, + Date, + Ts, + TsTz, +} + +impl OxideRange { + /// The type sqlx decodes the range's bounds into. + fn element_rs_type(self, opt: &ColumnOption) -> String { + match self { + Self::Int4 => "i32".to_owned(), + Self::Int8 => "i64".to_owned(), + Self::Num => "sqlx::types::BigDecimal".to_owned(), + Self::Date => match opt.date_time_crate { + DateTimeCrate::Chrono => "chrono::NaiveDate".to_owned(), + DateTimeCrate::Time => "time::Date".to_owned(), + }, + Self::Ts => match opt.date_time_crate { + DateTimeCrate::Chrono => "chrono::NaiveDateTime".to_owned(), + DateTimeCrate::Time => "time::PrimitiveDateTime".to_owned(), + }, + Self::TsTz => match opt.date_time_crate { + DateTimeCrate::Chrono => "chrono::DateTime".to_owned(), + DateTimeCrate::Time => "time::OffsetDateTime".to_owned(), + }, + } + } + + /// Whether the element type implements `Eq`, and so whether a model struct + /// holding this range can derive it. `BigDecimal` implements only + /// `PartialEq`; every other element type here is `Eq`. + pub fn element_is_eq(self) -> bool { + !matches!(self, Self::Num) + } +} + +pub fn oxide_range(col_type: &ColumnType) -> Option { + let ColumnType::Custom(iden) = col_type else { + return None; + }; + match iden.to_string().as_str() { + "int4range" => Some(OxideRange::Int4), + "int8range" => Some(OxideRange::Int8), + "numrange" => Some(OxideRange::Num), + "daterange" => Some(OxideRange::Date), + "tsrange" => Some(OxideRange::Ts), + "tstzrange" => Some(OxideRange::TsTz), + _ => None, + } +} + #[cfg(test)] mod tests { use crate::{Column, ColumnOption, DateTimeCrate}; diff --git a/sea-orm-codegen/src/entity/writer/oxide.rs b/sea-orm-codegen/src/entity/writer/oxide.rs index 310db0e7f9..0442b18995 100644 --- a/sea-orm-codegen/src/entity/writer/oxide.rs +++ b/sea-orm-codegen/src/entity/writer/oxide.rs @@ -83,8 +83,8 @@ impl EntityWriter { .parse() .unwrap(); let column_names_snake_case = entity.get_column_names_snake_case(); - let column_rs_types = entity.get_column_rs_types(column_option); - let if_eq_needed = entity.get_eq_needed(); + let column_rs_types = entity.get_oxide_column_rs_types(column_option); + let if_eq_needed = entity.get_oxide_eq_needed(); let primary_keys: Vec = entity .primary_keys @@ -225,8 +225,12 @@ impl EntityWriter { #[cfg(test)] mod tests { - use crate::{Column, Entity, EntityWriter}; - use sea_query::{ColumnType, RcOrArc}; + use crate::{Column, ColumnOption, DateTimeCrate, Entity, EntityWriter}; + use sea_query::{Alias, ColumnType, IntoIden, RcOrArc}; + + fn range_column(name: &str, range: &str) -> Column { + column(name, ColumnType::Custom(Alias::new(range).into_iden())) + } fn column(name: &str, col_type: ColumnType) -> Column { Column { @@ -281,4 +285,87 @@ mod tests { ]); assert!(EntityWriter::gen_import_uuid(&entity).is_empty()); } + + #[test] + fn range_columns_are_rendered_as_pg_range() { + let opt = ColumnOption::default(); + for (range, element) in [ + ("int4range", "i32"), + ("int8range", "i64"), + ("numrange", "sqlx :: types :: BigDecimal"), + ("daterange", "chrono :: NaiveDate"), + ("tsrange", "chrono :: NaiveDateTime"), + ("tstzrange", "chrono :: DateTime < chrono :: Utc >"), + ] { + assert_eq!( + range_column("r", range).get_oxide_rs_type(&opt).to_string(), + format!("Option < sqlx :: postgres :: types :: PgRange < {element} >>"), + "unexpected type for {range}" + ); + } + } + + #[test] + fn temporal_range_columns_follow_the_date_time_crate() { + let opt = ColumnOption { + date_time_crate: DateTimeCrate::Time, + ..Default::default() + }; + assert_eq!( + range_column("r", "tstzrange") + .get_oxide_rs_type(&opt) + .to_string(), + "Option < sqlx :: postgres :: types :: PgRange < time :: OffsetDateTime >>" + ); + } + + #[test] + fn range_columns_are_optional_even_when_not_null() { + let mut col = range_column("r", "numrange"); + col.not_null = true; + assert!( + col.get_oxide_rs_type(&ColumnOption::default()) + .to_string() + .starts_with("Option <"), + "PgRange has no Default, so a skipped field has to be optional" + ); + } + + #[test] + fn other_custom_columns_are_untouched() { + let opt = ColumnOption::default(); + assert_eq!( + range_column("t", "tsvector").get_oxide_rs_type(&opt).to_string(), + "String" + ); + } + + #[test] + fn range_columns_are_skipped_by_serde() { + assert_eq!( + range_column("r", "numrange") + .get_oxide_col_type_attrs() + .expect("expected a serde attribute") + .to_string(), + "# [serde (skip)]" + ); + } + + #[test] + fn numrange_suppresses_the_eq_derive() { + let entity = entity(vec![ + column("id", ColumnType::BigInteger), + range_column("r", "numrange"), + ]); + assert!(entity.get_oxide_eq_needed().is_empty()); + } + + #[test] + fn ranges_with_eq_elements_keep_the_eq_derive() { + let entity = entity(vec![ + column("id", ColumnType::BigInteger), + range_column("r", "int8range"), + ]); + assert_eq!(entity.get_oxide_eq_needed().to_string(), ", Eq"); + } } From 03763b9efc6d09ecaeaddbc9d305ce3d752df526 Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Mon, 24 Aug 2026 09:02:12 +0100 Subject: [PATCH 2/5] fix(codegen): handle ranges without serde derives Only emit serde range attributes when the generated model derives serde, preserving database nullability otherwise. Add a focused PR workflow for building and testing codegen. --- .github/workflows/codegen.yml | 24 ++++++++ sea-orm-codegen/src/entity/base_entity.rs | 12 ++-- sea-orm-codegen/src/entity/column.rs | 29 +++++---- sea-orm-codegen/src/entity/writer/oxide.rs | 69 +++++++++++++++------- 4 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/codegen.yml diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml new file mode 100644 index 0000000000..644cff0574 --- /dev/null +++ b/.github/workflows/codegen.yml @@ -0,0 +1,24 @@ +name: Codegen + +on: + pull_request: + paths: + - "sea-orm-codegen/**" + - "sea-orm-macros/**" + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/codegen.yml" + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and test codegen + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + - run: cargo build -p sea-orm-codegen + - run: cargo test -p sea-orm-codegen diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index 172614a224..b92fde648a 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -5,8 +5,8 @@ use quote::quote; use sea_query::ColumnType; use crate::{ - Column, ColumnOption, ConjunctRelation, PrimaryKey, Relation, entity::column::oxide_range, - util::escape_rust_keyword, + Column, ColumnOption, ConjunctRelation, PrimaryKey, Relation, WithSerde, + entity::column::oxide_range, util::escape_rust_keyword, }; #[derive(Clone, Debug)] @@ -57,11 +57,15 @@ impl Entity { .collect() } - pub fn get_oxide_column_rs_types(&self, opt: &ColumnOption) -> Vec { + pub fn get_oxide_column_rs_types( + &self, + opt: &ColumnOption, + with_serde: &WithSerde, + ) -> Vec { self.columns .clone() .into_iter() - .map(|col| col.get_oxide_rs_type(opt)) + .map(|col| col.get_oxide_rs_type(opt, with_serde)) .collect() } diff --git a/sea-orm-codegen/src/entity/column.rs b/sea-orm-codegen/src/entity/column.rs index 5cba323992..a2daa41840 100644 --- a/sea-orm-codegen/src/entity/column.rs +++ b/sea-orm-codegen/src/entity/column.rs @@ -1,4 +1,4 @@ -use crate::{util::escape_rust_keyword, BigIntegerType, DateTimeCrate}; +use crate::{BigIntegerType, DateTimeCrate, WithSerde, util::escape_rust_keyword}; use heck::{ToSnakeCase, ToUpperCamelCase}; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; @@ -113,16 +113,22 @@ impl Column { /// which is only correct for the standard format, where /// `get_col_type_attrs` pairs it with `select_as = "text"`. /// - /// Range fields are always optional. `PgRange` has no serde support, so - /// `get_oxide_col_type_attrs` skips these fields, and skipping a field - /// requires it to implement `Default` to deserialize — which `Option` - /// provides and `PgRange` does not. - pub fn get_oxide_rs_type(&self, opt: &ColumnOption) -> TokenStream { + /// `PgRange` has no serde support. When the generated model derives + /// `Deserialize`, range fields are skipped and must implement `Default`, + /// so they are emitted as `Option`. Otherwise their nullability follows + /// the database column as usual. + pub fn get_oxide_rs_type(&self, opt: &ColumnOption, with_serde: &WithSerde) -> TokenStream { let Some(range) = oxide_range(&self.col_type) else { return self.get_rs_type(opt); }; let element: TokenStream = range.element_rs_type(opt).parse().unwrap(); - quote! { Option> } + let range_type = quote! { sqlx::postgres::types::PgRange<#element> }; + match (self.not_null, with_serde) { + (_, WithSerde::Deserialize | WithSerde::Both) | (false, _) => { + quote! { Option<#range_type> } + } + (true, WithSerde::None | WithSerde::Serialize) => range_type, + } } pub fn get_col_type_attrs(&self) -> Option { @@ -150,10 +156,14 @@ impl Column { col_type.map(|ty| quote! { column_type = #ty }) } - pub fn get_oxide_col_type_attrs(&self) -> Option { + pub fn get_oxide_col_type_attrs(&self, with_serde: &WithSerde) -> Option { if oxide_range(&self.col_type).is_some() { // sqlx's PgRange implements neither Serialize nor Deserialize. - return quote! { #[serde(skip)] }.into(); + return match with_serde { + WithSerde::None => None, + WithSerde::Serialize => Some(quote! { #[serde(skip_serializing)] }), + WithSerde::Deserialize | WithSerde::Both => Some(quote! { #[serde(skip)] }), + }; } if !matches!(self.col_type, ColumnType::TimestampWithTimeZone) { @@ -346,7 +356,6 @@ impl From<&ColumnDef> for Column { } } - /// A Postgres range type. sea-schema surfaces these as `ColumnType::Custom`, /// since sea-query has no range variant of its own. #[derive(Debug, Copy, Clone, PartialEq, Eq)] diff --git a/sea-orm-codegen/src/entity/writer/oxide.rs b/sea-orm-codegen/src/entity/writer/oxide.rs index 0442b18995..e197ecf823 100644 --- a/sea-orm-codegen/src/entity/writer/oxide.rs +++ b/sea-orm-codegen/src/entity/writer/oxide.rs @@ -83,7 +83,7 @@ impl EntityWriter { .parse() .unwrap(); let column_names_snake_case = entity.get_column_names_snake_case(); - let column_rs_types = entity.get_oxide_column_rs_types(column_option); + let column_rs_types = entity.get_oxide_column_rs_types(column_option, with_serde); let if_eq_needed = entity.get_oxide_eq_needed(); let primary_keys: Vec = entity @@ -98,7 +98,7 @@ impl EntityWriter { .map(|col| { let mut attrs: Punctuated<_, Comma> = Punctuated::new(); let is_primary_key = primary_keys.contains(&col.name); - if let Some(ts) = col.get_oxide_col_type_attrs() { + if let Some(ts) = col.get_oxide_col_type_attrs(with_serde) { attrs.extend([ts]); }; @@ -112,11 +112,18 @@ impl EntityWriter { } ts = quote! { #ts }; } - let serde_attribute = col.get_serde_attribute( - is_primary_key, - serde_skip_deserializing_primary_key, - serde_skip_hidden_column, - ); + let serde_attribute = if crate::entity::column::oxide_range(&col.col_type).is_some() + { + // The range-specific attribute already skips unsupported + // serde directions, so do not emit a second serde attribute. + quote! {} + } else { + col.get_serde_attribute( + is_primary_key, + serde_skip_deserializing_primary_key, + serde_skip_hidden_column, + ) + }; ts = quote! { #ts #serde_attribute @@ -225,7 +232,7 @@ impl EntityWriter { #[cfg(test)] mod tests { - use crate::{Column, ColumnOption, DateTimeCrate, Entity, EntityWriter}; + use crate::{Column, ColumnOption, DateTimeCrate, Entity, EntityWriter, WithSerde}; use sea_query::{Alias, ColumnType, IntoIden, RcOrArc}; fn range_column(name: &str, range: &str) -> Column { @@ -298,8 +305,10 @@ mod tests { ("tstzrange", "chrono :: DateTime < chrono :: Utc >"), ] { assert_eq!( - range_column("r", range).get_oxide_rs_type(&opt).to_string(), - format!("Option < sqlx :: postgres :: types :: PgRange < {element} >>"), + range_column("r", range) + .get_oxide_rs_type(&opt, &WithSerde::Both) + .to_string(), + format!("Option < sqlx :: postgres :: types :: PgRange < {element} > >"), "unexpected type for {range}" ); } @@ -313,21 +322,30 @@ mod tests { }; assert_eq!( range_column("r", "tstzrange") - .get_oxide_rs_type(&opt) + .get_oxide_rs_type(&opt, &WithSerde::Both) .to_string(), - "Option < sqlx :: postgres :: types :: PgRange < time :: OffsetDateTime >>" + "Option < sqlx :: postgres :: types :: PgRange < time :: OffsetDateTime > >" ); } #[test] - fn range_columns_are_optional_even_when_not_null() { - let mut col = range_column("r", "numrange"); - col.not_null = true; + fn range_columns_are_optional_when_deserializing() { + let col = range_column("r", "numrange"); assert!( - col.get_oxide_rs_type(&ColumnOption::default()) + col.get_oxide_rs_type(&ColumnOption::default(), &WithSerde::Deserialize) .to_string() .starts_with("Option <"), - "PgRange has no Default, so a skipped field has to be optional" + "a skipped field must implement Default" + ); + } + + #[test] + fn non_null_range_columns_preserve_nullability_without_deserialization() { + let col = range_column("r", "numrange"); + assert_eq!( + col.get_oxide_rs_type(&ColumnOption::default(), &WithSerde::None) + .to_string(), + "sqlx :: postgres :: types :: PgRange < sqlx :: types :: BigDecimal >" ); } @@ -335,16 +353,25 @@ mod tests { fn other_custom_columns_are_untouched() { let opt = ColumnOption::default(); assert_eq!( - range_column("t", "tsvector").get_oxide_rs_type(&opt).to_string(), + range_column("t", "tsvector") + .get_oxide_rs_type(&opt, &WithSerde::None) + .to_string(), "String" ); } #[test] - fn range_columns_are_skipped_by_serde() { + fn range_columns_are_skipped_only_when_serde_is_derived() { + let col = range_column("r", "numrange"); + assert!(col.get_oxide_col_type_attrs(&WithSerde::None).is_none()); + assert_eq!( + col.get_oxide_col_type_attrs(&WithSerde::Serialize) + .expect("expected a serde attribute") + .to_string(), + "# [serde (skip_serializing)]" + ); assert_eq!( - range_column("r", "numrange") - .get_oxide_col_type_attrs() + col.get_oxide_col_type_attrs(&WithSerde::Deserialize) .expect("expected a serde attribute") .to_string(), "# [serde (skip)]" From 9aba19ea0703b6ee611265c6ac07457463470f4c Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Mon, 24 Aug 2026 09:07:51 +0100 Subject: [PATCH 3/5] ci: remove inherited test matrix --- .github/workflows/rust.yml | 520 ------------------------------------- 1 file changed, 520 deletions(-) delete mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9f391104e7..0000000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,520 +0,0 @@ -# GitHub Actions with Conditional Job Running Based on Commit Message -# -# -------------------------------------------------------------------------------- -# -# Following jobs will always run -# -# - `clippy` -# - `rustfmt` -# - `taplo` -# - `test` -# - `examples` -# -# Following jobs will be run when no keywords were found in commit message) -# -# - `sqlite` -# - `mysql` -# - `mariadb` -# - `postgres` -# -# Following jobs will be run if keywords `[issues]` were found in commit message -# -# - Jobs that will always run -# - `issues` -# -# Following jobs will be run if keywords `[cli]` were found in commit message -# -# - Jobs that will always run -# - `cli` -# -# Following jobs will be run if keywords `[sqlite]` were found in commit message -# -# - Jobs that will always run -# - `compile` -# - `sqlite` -# -# Following jobs will be run if keywords `[mysql]` were found in commit message -# -# - Jobs that will always run -# - `compile` -# - `mysql` -# - `mariadb` -# -# Following jobs will be run if keywords `[postgres]` were found in commit message -# -# - Jobs that will always run -# - `compile` -# - `postgres` - -name: tests - -on: - pull_request: - paths-ignore: - - "**.md" - - ".github/ISSUE_TEMPLATE/**" - push: - paths-ignore: - - "**.md" - - ".github/ISSUE_TEMPLATE/**" - branches: - - master - - 1.*.x - - 0.*.x - - pr/**/ci - - ci-* - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUSTC_WRAPPER: sccache - SCCACHE_GHA_ENABLED: true - RUSTFLAGS: "-C debuginfo=0" - CARGO_INCREMENTAL: 0 - -jobs: - init: - name: Init - runs-on: ubuntu-latest - outputs: - run-sqlite: ${{ contains(steps.git-log.outputs.message, '[sqlite]') }} - run-mysql: ${{ contains(steps.git-log.outputs.message, '[mysql]') }} - run-postgres: ${{ contains(steps.git-log.outputs.message, '[postgres]') }} - run-cli: ${{ contains(steps.git-log.outputs.message, '[cli]') }} - run-issues: ${{ contains(steps.git-log.outputs.message, '[issues]') }} - run-partial: >- - ${{ - contains(steps.git-log.outputs.message, '[sqlite]') || - contains(steps.git-log.outputs.message, '[mysql]') || - contains(steps.git-log.outputs.message, '[postgres]') || - contains(steps.git-log.outputs.message, '[cli]') || - contains(steps.git-log.outputs.message, '[issues]') - }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - id: git-log - run: echo "message=$(git log --no-merges -1 --oneline)" >> $GITHUB_OUTPUT - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo clippy --all -- -D warnings - - run: cargo clippy --all --features runtime-tokio-native-tls,sqlx-all -- -D warnings - - run: cargo clippy --manifest-path sea-orm-cli/Cargo.toml -- -D warnings - - run: cargo clippy --manifest-path sea-orm-migration/Cargo.toml -- -D warnings - - rustfmt: - name: Rustfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - with: - components: rustfmt - - run: cargo fmt --all -- --check - - run: cargo fmt --manifest-path sea-orm-cli/Cargo.toml --all -- --check - - run: cargo fmt --manifest-path sea-orm-migration/Cargo.toml --all -- --check - - taplo: - name: Taplo - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo install --locked taplo-cli - - run: taplo fmt --check - - compile: - name: Compile (${{ matrix.label }}) - needs: init - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - label: build - kind: build - - label: tokio native-tls mysql - kind: test - features: sqlx-mysql,runtime-tokio-native-tls - - label: tokio rustls mysql - kind: test - features: sqlx-mysql,runtime-tokio-rustls - - label: tokio native-tls postgres - kind: test - features: sqlx-postgres,runtime-tokio-native-tls - - label: tokio rustls postgres - kind: test - features: sqlx-postgres,runtime-tokio-rustls - - label: tokio sqlite - kind: test - features: sqlx-sqlite,runtime-tokio - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-compile-${{ matrix.label }}-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - if: matrix.kind == 'build' - run: | - cargo build --no-default-features - cargo build --no-default-features --features seaography - cargo build --features rbac,schema-sync - - if: matrix.kind == 'test' - run: cargo test --test '*' --features tests-features,${{ matrix.features }} --no-run - - test: - name: Unit Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo test --workspace --no-run - - run: cargo test --workspace - - run: cargo test --lib --features rbac - - run: cargo test --lib --features entity-registry -- registry - - run: cargo test --manifest-path sea-orm-cli/Cargo.toml --no-run - - run: cargo test --manifest-path sea-orm-cli/Cargo.toml - - cli: - name: CLI - needs: init - if: ${{ (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-cli == 'true') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo install --path sea-orm-cli --debug - - examples: - name: Examples - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - path: - [ - actix_example, - axum_example, - basic, - graphql_example, - jsonrpsee_example, - loco_example, - loco_seaography, - loco_starter, - poem_example, - proxy_gluesql_example, - quickstart, - react_admin, - rocket_example, - rocket_okapi_example, - salvo_example, - seaography_example, - tonic_example, - ] - steps: - - uses: actions/checkout@v4 - - if: ${{ contains(matrix.path, 'tonic_example') }} - uses: arduino/setup-protoc@v3 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-examples-${{ matrix.path }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - working-directory: ./examples/ - run: | - find ${{ matrix.path }} -type f -name 'Cargo.toml' -print0 | xargs -t -0 -I {} cargo fmt --manifest-path {} -- --check - find ${{ matrix.path }} -type f -name 'Cargo.toml' -print0 | xargs -t -0 -I {} cargo update --manifest-path {} - find ${{ matrix.path }} -type f -name 'Cargo.toml' -print0 | xargs -t -0 -I {} cargo test --manifest-path {} - ${{'! '}}${{ '[ -d "' }}${{ matrix.path }}${{ '/api" ]' }} || find ${{ matrix.path }}/api -type f -name 'Cargo.toml' -print0 | xargs -t -0 -I {} cargo test --manifest-path {} - - if: matrix.path == 'quickstart' - working-directory: ./examples/${{ matrix.path }} - run: cargo run - - issues-matrix: - name: Issues Matrix - needs: init - if: ${{ (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-issues == 'true') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - id: set-matrix - run: echo "path_matrix=$(find issues -type f -name 'Cargo.toml' -printf '%P\0' | jq -Rc '[ split("\u0000") | .[] | "issues/\(.)" ]')" >> $GITHUB_OUTPUT - outputs: - path_matrix: ${{ steps.set-matrix.outputs.path_matrix }} - - issues: - name: Issues - needs: - - init - - issues-matrix - if: ${{ (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-issues == 'true') }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - path: ${{ fromJson(needs.issues-matrix.outputs.path_matrix) }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-issue-${{ matrix.path }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo build --manifest-path ${{ matrix.path }} - - run: cargo test --manifest-path ${{ matrix.path }} - - sqlite: - name: SQLite - needs: - - init - - compile - if: >- - ${{ - needs.init.outputs.run-partial == 'false' || - (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-sqlite == 'true') - }} - runs-on: ubuntu-latest - env: - DATABASE_URL: "sqlite::memory:" - strategy: - fail-fast: false - matrix: - runtime: [tokio] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-sqlite-tests-sqlite-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo test --test '*' --features tests-features,sqlx-sqlite,runtime-${{ matrix.runtime }} --no-run - - run: cargo test --test '*' --features tests-features,sqlx-sqlite,runtime-${{ matrix.runtime }} - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-sqlite,runtime-${{ matrix.runtime }} --no-run - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-sqlite,runtime-${{ matrix.runtime }} - - rusqlite: - name: rusqlite - needs: - - init - - compile - if: >- - ${{ - needs.init.outputs.run-partial == 'false' || - (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-sqlite == 'true') - }} - runs-on: ubuntu-latest - env: - DATABASE_URL: "sqlite::memory:" - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-sqlite-tests-rusqlite-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - working-directory: ./sea-orm-sync - run: cargo test --test '*' --features tests-features,rusqlite - - working-directory: ./sea-orm-sync/examples/quickstart - run: cargo run - - mysql: - name: MySQL - needs: - - init - - compile - if: >- - ${{ - needs.init.outputs.run-partial == 'false' || - (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-mysql == 'true') - }} - runs-on: ubuntu-latest - env: - DATABASE_URL: "mysql://root:@localhost" - strategy: - fail-fast: false - matrix: - version: [lts, 5.7] - runtime: [tokio] - tls: [native-tls] - services: - mysql: - image: mysql:${{ matrix.version }} - env: - MYSQL_HOST: 127.0.0.1 - MYSQL_DB: mysql - MYSQL_USER: sea - MYSQL_PASSWORD: sea - MYSQL_ALLOW_EMPTY_PASSWORD: yes - ports: - - "3306:3306" - options: >- - --health-cmd="mysqladmin ping" - --health-interval=10s - --health-timeout=5s - --health-retries=3 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-mysql-tests-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo test --test '*' --features tests-features,sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} --no-run - - run: cargo test --test '*' --features tests-features,sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} --no-run - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} - - mariadb: - name: MariaDB - needs: - - init - - compile - if: >- - ${{ - needs.init.outputs.run-partial == 'false' || - (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-mysql == 'true') - }} - runs-on: ubuntu-latest - env: - DATABASE_URL: "mysql://root:@localhost" - strategy: - fail-fast: false - matrix: - version: [lts] - runtime: [tokio] - tls: [native-tls] - services: - mariadb: - image: mariadb:${{ matrix.version }} - env: - MARIADB_HOST: 127.0.0.1 - MARIADB_DB: mysql - MARIADB_USER: sea - MARIADB_PASSWORD: sea - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: yes - ports: - - "3306:3306" - options: >- - --health-cmd="healthcheck.sh - --connect - --innodb_initialized" - --health-interval=10s - --health-timeout=5s - --health-retries=3 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-mariadb-tests-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo test --test '*' --features tests-features,sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} --no-run - - run: cargo test --test '*' --features tests-features,sqlx-mysql,runtime-${{ matrix.runtime }}-${{ matrix.tls }} - - postgres: - name: Postgres - needs: - - init - - compile - if: >- - ${{ - needs.init.outputs.run-partial == 'false' || - (needs.init.outputs.run-partial == 'true' && needs.init.outputs.run-postgres == 'true') - }} - runs-on: ubuntu-latest - env: - DATABASE_URL: "postgres://root:root@localhost" - strategy: - fail-fast: false - matrix: - version: [14, 16] - runtime: [tokio] - tls: [native-tls] - services: - postgres: - image: postgres:${{ matrix.version }} - env: - POSTGRES_HOST: 127.0.0.1 - POSTGRES_USER: root - POSTGRES_PASSWORD: root - ports: - - "5432:5432" - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-postgres-tests-${{ matrix.runtime }}-${{ matrix.tls }}-${{ hashFiles('**/Cargo.toml') }} - - uses: mozilla-actions/sccache-action@v0.0.9 - - run: cargo test --test '*' --features tests-features,sqlx-postgres,runtime-${{ matrix.runtime }}-${{ matrix.tls }} --no-run - - run: cargo test --test '*' --features tests-features,sqlx-postgres,runtime-${{ matrix.runtime }}-${{ matrix.tls }} - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-postgres,runtime-${{ matrix.runtime }}-${{ matrix.tls }} --no-run - - run: cargo test --manifest-path sea-orm-migration/Cargo.toml --test '*' --features sqlx-postgres,runtime-${{ matrix.runtime }}-${{ matrix.tls }} From 710dc15c08bb76a034ff383adfc6972f19d5163f Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Mon, 24 Aug 2026 09:17:44 +0100 Subject: [PATCH 4/5] fix(codegen): preserve standard type mappings Keep direct SQLx and chrono types scoped to oxide generation so compact and expanded output continues to use SeaORM prelude aliases. --- sea-orm-codegen/src/entity/column.rs | 61 ++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/sea-orm-codegen/src/entity/column.rs b/sea-orm-codegen/src/entity/column.rs index a2daa41840..165b02a067 100644 --- a/sea-orm-codegen/src/entity/column.rs +++ b/sea-orm-codegen/src/entity/column.rs @@ -56,27 +56,25 @@ impl Column { ColumnType::BigUnsigned => "u64".to_owned(), ColumnType::Float => "f32".to_owned(), ColumnType::Double => "f64".to_owned(), - ColumnType::Json | ColumnType::JsonBinary => { - "sqlx::types::Json".to_owned() - } + ColumnType::Json | ColumnType::JsonBinary => "Json".to_owned(), ColumnType::Date => match opt.date_time_crate { - DateTimeCrate::Chrono => "chrono::NaiveDate".to_owned(), + DateTimeCrate::Chrono => "Date".to_owned(), DateTimeCrate::Time => "TimeDate".to_owned(), }, ColumnType::Time => match opt.date_time_crate { - DateTimeCrate::Chrono => "chrono::NaiveTime".to_owned(), + DateTimeCrate::Chrono => "Time".to_owned(), DateTimeCrate::Time => "TimeTime".to_owned(), }, ColumnType::DateTime => match opt.date_time_crate { - DateTimeCrate::Chrono => "chrono::NaiveDateTime".to_owned(), + DateTimeCrate::Chrono => "DateTime".to_owned(), DateTimeCrate::Time => "TimeDateTime".to_owned(), }, ColumnType::Timestamp => match opt.date_time_crate { - DateTimeCrate::Chrono => "chrono::DateTime".to_owned(), + DateTimeCrate::Chrono => "DateTimeUtc".to_owned(), DateTimeCrate::Time => "TimeDateTime".to_owned(), }, ColumnType::TimestampWithTimeZone => match opt.date_time_crate { - DateTimeCrate::Chrono => "chrono::DateTime".to_owned(), + DateTimeCrate::Chrono => "DateTimeWithTimeZone".to_owned(), DateTimeCrate::Time => "TimeDateTimeWithTimeZone".to_owned(), }, ColumnType::Decimal(_) | ColumnType::Money(_) => "Decimal".to_owned(), @@ -118,16 +116,45 @@ impl Column { /// so they are emitted as `Option`. Otherwise their nullability follows /// the database column as usual. pub fn get_oxide_rs_type(&self, opt: &ColumnOption, with_serde: &WithSerde) -> TokenStream { - let Some(range) = oxide_range(&self.col_type) else { - return self.get_rs_type(opt); - }; - let element: TokenStream = range.element_rs_type(opt).parse().unwrap(); - let range_type = quote! { sqlx::postgres::types::PgRange<#element> }; - match (self.not_null, with_serde) { - (_, WithSerde::Deserialize | WithSerde::Both) | (false, _) => { - quote! { Option<#range_type> } + if let Some(range) = oxide_range(&self.col_type) { + let element: TokenStream = range.element_rs_type(opt).parse().unwrap(); + let range_type = quote! { sqlx::postgres::types::PgRange<#element> }; + return match (self.not_null, with_serde) { + (_, WithSerde::Deserialize | WithSerde::Both) | (false, _) => { + quote! { Option<#range_type> } + } + (true, WithSerde::None | WithSerde::Serialize) => range_type, + }; + } + + let oxide_type = match &self.col_type { + ColumnType::Json | ColumnType::JsonBinary => "sqlx::types::Json", + ColumnType::Date if opt.date_time_crate == DateTimeCrate::Chrono => "chrono::NaiveDate", + ColumnType::Time if opt.date_time_crate == DateTimeCrate::Chrono => "chrono::NaiveTime", + ColumnType::DateTime if opt.date_time_crate == DateTimeCrate::Chrono => { + "chrono::NaiveDateTime" + } + ColumnType::Timestamp | ColumnType::TimestampWithTimeZone + if opt.date_time_crate == DateTimeCrate::Chrono => + { + "chrono::DateTime" } - (true, WithSerde::None | WithSerde::Serialize) => range_type, + ColumnType::Date if opt.date_time_crate == DateTimeCrate::Time => "time::Date", + ColumnType::Time if opt.date_time_crate == DateTimeCrate::Time => "time::Time", + ColumnType::DateTime | ColumnType::Timestamp + if opt.date_time_crate == DateTimeCrate::Time => + { + "time::PrimitiveDateTime" + } + ColumnType::TimestampWithTimeZone if opt.date_time_crate == DateTimeCrate::Time => { + "time::OffsetDateTime" + } + _ => return self.get_rs_type(opt), + }; + let ident: TokenStream = oxide_type.parse().unwrap(); + match self.not_null { + true => quote! { #ident }, + false => quote! { Option<#ident> }, } } From 30fa0713d58c6543d23a7a8ea4692340d52db64f Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Mon, 24 Aug 2026 09:52:59 +0100 Subject: [PATCH 5/5] fix(codegen): map naive timestamps to a naive chrono type The oxide chrono arm collapsed Timestamp onto TimestampWithTimeZone, so a `timestamp without time zone` column was rendered as DateTime. sqlx decodes TIMESTAMP only into NaiveDateTime and rejects the aware type at runtime, with nothing to catch it at compile time. The `time` arms were already grouping Timestamp with DateTime; this makes the chrono arms agree. Co-Authored-By: Claude Opus 5 --- sea-orm-codegen/src/entity/column.rs | 8 ++--- sea-orm-codegen/src/entity/writer/oxide.rs | 39 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/sea-orm-codegen/src/entity/column.rs b/sea-orm-codegen/src/entity/column.rs index 165b02a067..86404564d1 100644 --- a/sea-orm-codegen/src/entity/column.rs +++ b/sea-orm-codegen/src/entity/column.rs @@ -131,12 +131,12 @@ impl Column { ColumnType::Json | ColumnType::JsonBinary => "sqlx::types::Json", ColumnType::Date if opt.date_time_crate == DateTimeCrate::Chrono => "chrono::NaiveDate", ColumnType::Time if opt.date_time_crate == DateTimeCrate::Chrono => "chrono::NaiveTime", - ColumnType::DateTime if opt.date_time_crate == DateTimeCrate::Chrono => { - "chrono::NaiveDateTime" - } - ColumnType::Timestamp | ColumnType::TimestampWithTimeZone + ColumnType::DateTime | ColumnType::Timestamp if opt.date_time_crate == DateTimeCrate::Chrono => { + "chrono::NaiveDateTime" + } + ColumnType::TimestampWithTimeZone if opt.date_time_crate == DateTimeCrate::Chrono => { "chrono::DateTime" } ColumnType::Date if opt.date_time_crate == DateTimeCrate::Time => "time::Date", diff --git a/sea-orm-codegen/src/entity/writer/oxide.rs b/sea-orm-codegen/src/entity/writer/oxide.rs index e197ecf823..41a2e3401d 100644 --- a/sea-orm-codegen/src/entity/writer/oxide.rs +++ b/sea-orm-codegen/src/entity/writer/oxide.rs @@ -395,4 +395,43 @@ mod tests { ]); assert_eq!(entity.get_oxide_eq_needed().to_string(), ", Eq"); } + + /// sqlx decodes `TIMESTAMP` only into a naive type and `TIMESTAMPTZ` only + /// into an aware one, so the two must not collapse onto the same type. + #[test] + fn naive_and_aware_timestamps_map_to_distinct_types() { + let chrono = ColumnOption::default(); + for col_type in [ColumnType::DateTime, ColumnType::Timestamp] { + assert_eq!( + column("t", col_type.clone()) + .get_oxide_rs_type(&chrono, &WithSerde::None) + .to_string(), + "chrono :: NaiveDateTime", + "unexpected type for {col_type:?}" + ); + } + assert_eq!( + column("t", ColumnType::TimestampWithTimeZone) + .get_oxide_rs_type(&chrono, &WithSerde::None) + .to_string(), + "chrono :: DateTime < chrono :: Utc >" + ); + + let time = ColumnOption { + date_time_crate: DateTimeCrate::Time, + ..Default::default() + }; + assert_eq!( + column("t", ColumnType::Timestamp) + .get_oxide_rs_type(&time, &WithSerde::None) + .to_string(), + "time :: PrimitiveDateTime" + ); + assert_eq!( + column("t", ColumnType::TimestampWithTimeZone) + .get_oxide_rs_type(&time, &WithSerde::None) + .to_string(), + "time :: OffsetDateTime" + ); + } }