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/.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 }} diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index 865130dc68..b92fde648a 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, WithSerde, + entity::column::oxide_range, util::escape_rust_keyword, }; #[derive(Clone, Debug)] @@ -56,6 +57,18 @@ impl Entity { .collect() } + 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, with_serde)) + .collect() + } + pub fn get_column_defs(&self) -> Vec { self.columns .clone() @@ -276,6 +289,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..86404564d1 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}; @@ -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(), @@ -107,6 +105,59 @@ 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"`. + /// + /// `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 { + 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 | 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", + 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> }, + } + } + pub fn get_col_type_attrs(&self) -> Option { let col_type = match &self.col_type { ColumnType::Float => Some("Float".to_owned()), @@ -132,7 +183,16 @@ 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 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) { return None; } @@ -323,6 +383,63 @@ 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..41a2e3401d 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, with_serde); + let if_eq_needed = entity.get_oxide_eq_needed(); let primary_keys: Vec = entity .primary_keys @@ -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,8 +232,12 @@ impl EntityWriter { #[cfg(test)] mod tests { - use crate::{Column, Entity, EntityWriter}; - use sea_query::{ColumnType, RcOrArc}; + use crate::{Column, ColumnOption, DateTimeCrate, Entity, EntityWriter, WithSerde}; + 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 +292,146 @@ 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, &WithSerde::Both) + .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, &WithSerde::Both) + .to_string(), + "Option < sqlx :: postgres :: types :: PgRange < time :: OffsetDateTime > >" + ); + } + + #[test] + fn range_columns_are_optional_when_deserializing() { + let col = range_column("r", "numrange"); + assert!( + col.get_oxide_rs_type(&ColumnOption::default(), &WithSerde::Deserialize) + .to_string() + .starts_with("Option <"), + "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 >" + ); + } + + #[test] + fn other_custom_columns_are_untouched() { + let opt = ColumnOption::default(); + assert_eq!( + range_column("t", "tsvector") + .get_oxide_rs_type(&opt, &WithSerde::None) + .to_string(), + "String" + ); + } + + #[test] + 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!( + col.get_oxide_col_type_attrs(&WithSerde::Deserialize) + .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"); + } + + /// 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" + ); + } }