diff --git a/Cargo.toml b/Cargo.toml index 2e96c4ca9..38c177c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,10 @@ runtime-tokio = ["sqlx?/runtime-tokio"] runtime-tokio-native-tls = ["sqlx?/runtime-tokio-native-tls", "runtime-tokio"] runtime-tokio-rustls = ["sqlx?/runtime-tokio-rustls", "runtime-tokio"] rusqlite = [] +cockroachdb = [ + "sqlx-postgres", + "sea-query/backend-cockroach", +] schema-sync = ["sea-schema"] sea-orm-internal = [] seaography = ["sea-orm-macros/seaography"] @@ -212,5 +216,5 @@ with-uuid = ["uuid", "sea-query/with-uuid", "sea-query-sqlx?/with-uuid"] # This allows us to develop using a local version of sea-query [patch.crates-io] -# sea-query = { path = "../sea-query" } +sea-query = { path = "../sea-query" } # sea-query = { git = "https://github.com/SeaQL/sea-query", branch = "master" } diff --git a/README.md b/README.md index 1d7fccb8e..efce870b7 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,55 @@ let user: Option = user::Entity::find() See the [quickstart example](https://github.com/SeaQL/sea-orm/blob/master/sea-orm-sync/examples/quickstart/src/main.rs) for usage. +## CockroachDB Support + +SeaORM supports CockroachDB via the `cockroachdb` feature flag. CockroachDB is wire-compatible with PostgreSQL, so most functionality works seamlessly. + +### Enabling CockroachDB Support + +```toml +# Cargo.toml +[dependencies] +sea-orm = { version = "2.0", features = ["cockroachdb", "runtime-tokio-native-tls"] } +``` + +### Connecting to CockroachDB + +Use a standard PostgreSQL connection string: + +```rust +use sea_orm::DbConn; + +let db: DbConn = sea_orm::Connect::connect("postgres://user:password@host:26257/database?sslmode=require") + .await?; +``` + +Or with CockroachDB specific scheme: + +```rust +let db: DbConn = sea_orm::Connect::connect("cockroachdb://user:password@host:26257/database?sslmode=require") + .await?; +``` + +### Schema Generation + +CockroachDB doesn't support PostgreSQL's `SERIAL` pseudo-type. SeaORM automatically generates `GENERATED BY DEFAULT AS IDENTITY` for auto-increment columns when using CockroachDB: + +```sql +-- PostgreSQL +"id" SERIAL PRIMARY KEY + +-- CockroachDB (SeaORM default) +"id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY +``` + +### Feature Flags + +| Feature | Description | +|---------|-------------| +| `cockroachdb` | Enable CockroachDB support | +| `cockroachdb-use-identity-pk` | Use IDENTITY columns for primary keys | + ## Basics ### Select diff --git a/pr_body.md b/pr_body.md new file mode 100644 index 000000000..acb0f5b64 --- /dev/null +++ b/pr_body.md @@ -0,0 +1,38 @@ +## Summary + +This PR adds CockroachDB support to SeaORM. CockroachDB is wire-compatible with PostgreSQL, so most functionality works with the existing sqlx-postgres driver. + +## Changes + +- Added `cockroachdb` feature flag that enables: + - `DatabaseBackend::Cockroach` variant + - CockroachDB-specific URL scheme handling (`cockroachdb://`) + - Proper schema generation using IDENTITY columns (not SERIAL) + - Full RETURNING support + +## Key Design Decisions + +1. **Wire Compatibility**: CockroachDB uses the same protocol as PostgreSQL, so we alias `CockroachQueryBuilder` to `PostgresQueryBuilder` in sea-query +2. **IDENTITY vs SERIAL**: CockroachDB doesn't support PostgreSQL's SERIAL pseudo-type. The implementation generates `GENERATED BY DEFAULT AS IDENTITY` by default. + +## Feature Flags + +| Feature | Description | +|---------|-------------| +| `cockroachdb` | Enable CockroachDB support | +| `cockroachdb-use-identity-pk` | Use IDENTITY columns for primary keys | + +## Testing + +Added `tests/cockroachdb_tests.rs` with tests for: +- Backend detection with various URL schemes +- Schema generation using IDENTITY columns +- Boolean value conversion + +## Breaking Changes + +None - all changes are gated behind the new `cockroachdb` feature flag. + +--- + +Related sea-query PR: https://github.com/SeaQL/sea-query/pull/329 diff --git a/sea-orm-macros/src/lib.rs b/sea-orm-macros/src/lib.rs index 2ae85e203..faad5e838 100644 --- a/sea-orm-macros/src/lib.rs +++ b/sea-orm-macros/src/lib.rs @@ -944,6 +944,7 @@ pub fn test(_: TokenStream, input: TokenStream) -> TokenStream { feature = "sqlx-mysql", feature = "sqlx-sqlite", feature = "sqlx-postgres", + feature = "cockroachdb", ))] #(#attrs)* fn #name() #ret { diff --git a/src/database/db_connection.rs b/src/database/db_connection.rs index 108111ad1..15933dd70 100644 --- a/src/database/db_connection.rs +++ b/src/database/db_connection.rs @@ -88,6 +88,8 @@ pub enum DatabaseBackend { MySql, /// A PostgreSQL backend Postgres, + /// A CockroachDB backend + Cockroach, /// A SQLite backend Sqlite, } @@ -759,6 +761,11 @@ impl DbBackend { Self::Postgres => { base_url_parsed.scheme() == "postgres" || base_url_parsed.scheme() == "postgresql" } + Self::Cockroach => { + base_url_parsed.scheme() == "cockroachdb" + || base_url_parsed.scheme() == "postgres" + || base_url_parsed.scheme() == "postgresql" + } Self::MySql => base_url_parsed.scheme() == "mysql", Self::Sqlite => base_url_parsed.scheme() == "sqlite", } @@ -776,6 +783,7 @@ impl DbBackend { pub fn support_returning(&self) -> bool { match self { Self::Postgres => true, + Self::Cockroach => true, Self::Sqlite if cfg!(feature = "sqlite-use-returning-for-3_35") => true, Self::MySql if cfg!(feature = "mariadb-use-returning") => true, _ => false, @@ -785,7 +793,7 @@ impl DbBackend { /// A getter for database dependent boolean value pub fn boolean_value(&self, boolean: bool) -> sea_query::Value { match self { - Self::MySql | Self::Postgres | Self::Sqlite => boolean.into(), + Self::MySql | Self::Postgres | Self::Cockroach | Self::Sqlite => boolean.into(), } } @@ -794,6 +802,7 @@ impl DbBackend { match self { DatabaseBackend::MySql => "MySql", DatabaseBackend::Postgres => "Postgres", + DatabaseBackend::Cockroach => "Cockroach", DatabaseBackend::Sqlite => "Sqlite", } } diff --git a/src/database/mod.rs b/src/database/mod.rs index ab0898567..7bf346809 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -176,6 +176,12 @@ impl Database { proxy_func_arc.to_owned(), ); } + DbBackend::Cockroach => { + return crate::ProxyDatabaseConnector::connect( + DbBackend::Cockroach, + proxy_func_arc.to_owned(), + ); + } DbBackend::Sqlite => { return crate::ProxyDatabaseConnector::connect( DbBackend::Sqlite, diff --git a/src/database/statement.rs b/src/database/statement.rs index 6bd96ce75..87caa5204 100644 --- a/src/database/statement.rs +++ b/src/database/statement.rs @@ -2,6 +2,8 @@ use crate::DbBackend; #[cfg(feature = "rbac")] pub use sea_query::audit::{AuditTrait, Error as AuditError, QueryAccessAudit}; use sea_query::{MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder, inject_parameters}; +#[cfg(feature = "cockroachdb")] +use sea_query::CockroachQueryBuilder; pub use sea_query::{Value, Values}; use std::fmt; @@ -71,6 +73,14 @@ impl fmt::Display for Statement { DbBackend::Postgres => { inject_parameters(&self.sql, &values.0, &PostgresQueryBuilder) } + #[cfg(feature = "cockroachdb")] + DbBackend::Cockroach => { + inject_parameters(&self.sql, &values.0, &CockroachQueryBuilder) + } + #[cfg(not(feature = "cockroachdb"))] + DbBackend::Cockroach => { + panic!("CockroachDB feature not enabled") + } DbBackend::Sqlite => { inject_parameters(&self.sql, &values.0, &SqliteQueryBuilder) } @@ -89,6 +99,10 @@ macro_rules! build_any_stmt { match $db_backend { DbBackend::MySql => $stmt.build(MysqlQueryBuilder), DbBackend::Postgres => $stmt.build(PostgresQueryBuilder), + #[cfg(feature = "cockroachdb")] + DbBackend::Cockroach => $stmt.build(CockroachQueryBuilder), + #[cfg(not(feature = "cockroachdb"))] + DbBackend::Cockroach => panic!("CockroachDB feature not enabled"), DbBackend::Sqlite => $stmt.build(SqliteQueryBuilder), } }; @@ -98,6 +112,10 @@ macro_rules! build_postgres_stmt { ($stmt: expr, $db_backend: expr) => { match $db_backend { DbBackend::Postgres => $stmt.to_string(PostgresQueryBuilder), + #[cfg(feature = "cockroachdb")] + DbBackend::Cockroach => $stmt.to_string(CockroachQueryBuilder), + #[cfg(not(feature = "cockroachdb"))] + DbBackend::Cockroach => panic!("CockroachDB feature not enabled"), DbBackend::MySql | DbBackend::Sqlite => unimplemented!(), } }; diff --git a/src/database/tracing_spans.rs b/src/database/tracing_spans.rs index bb6641c9d..4f454cf30 100644 --- a/src/database/tracing_spans.rs +++ b/src/database/tracing_spans.rs @@ -79,6 +79,7 @@ mod inner { pub(crate) fn db_system_name(backend: DbBackend) -> &'static str { match backend { DbBackend::Postgres => "postgresql", + DbBackend::Cockroach => "cockroachdb", DbBackend::MySql => "mysql", DbBackend::Sqlite => "sqlite", } diff --git a/src/executor/paginator.rs b/src/executor/paginator.rs index 0e64feb8d..bdc59e206 100644 --- a/src/executor/paginator.rs +++ b/src/executor/paginator.rs @@ -83,7 +83,9 @@ where None => return Ok(0), }; let num_items = match db_backend { - DbBackend::Postgres => result.try_get::("", "num_items")? as u64, + DbBackend::Postgres | DbBackend::Cockroach => { + result.try_get::("", "num_items")? as u64 + } _ => result.try_get::("", "num_items")? as u64, }; Ok(num_items) diff --git a/src/schema/builder.rs b/src/schema/builder.rs index 725a24b9e..35017b1f7 100644 --- a/src/schema/builder.rs +++ b/src/schema/builder.rs @@ -105,7 +105,7 @@ impl SchemaBuilder { } } #[cfg(feature = "sqlx-postgres")] - DbBackend::Postgres => { + DbBackend::Postgres | DbBackend::Cockroach => { use sea_schema::{postgres::discovery::SchemaDiscovery, probe::SchemaProbe}; let current_schema: String = db diff --git a/src/schema/entity.rs b/src/schema/entity.rs index f20bd4834..a16f90e91 100644 --- a/src/schema/entity.rs +++ b/src/schema/entity.rs @@ -229,7 +229,7 @@ where let variants: Vec = variants.iter().map(|v| v.to_string()).collect(); ColumnType::custom(format!("ENUM('{}')", variants.join("', '"))) } - DbBackend::Postgres => ColumnType::Custom(name.clone()), + DbBackend::Postgres | DbBackend::Cockroach => ColumnType::Custom(name.clone()), DbBackend::Sqlite => orm_column_def.col_type, }, _ => orm_column_def.col_type, diff --git a/tests/cockroachdb_tests.rs b/tests/cockroachdb_tests.rs new file mode 100644 index 000000000..07560cee2 --- /dev/null +++ b/tests/cockroachdb_tests.rs @@ -0,0 +1,171 @@ +#![allow(unused_imports, dead_code)] + +pub mod common; + +pub use sea_orm::{ConnectionTrait, DbBackend, DbErr}; + +// DATABASE_URL=cockroachdb://...:26257/... cargo test --features cockroachdb,sqlx-postgres,runtime-tokio --test cockroachdb_tests +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_backend_detection() -> Result<(), DbErr> { + // Test is_prefix_of for various URL schemes + assert!(DbBackend::Cockroach.is_prefix_of("cockroachdb://localhost:26257/test")); + assert!(DbBackend::Cockroach.is_prefix_of("postgres://localhost:26257/test")); + assert!(DbBackend::Cockroach.is_prefix_of("postgresql://localhost:26257/test")); + assert!(!DbBackend::Cockroach.is_prefix_of("mysql://localhost:3306/test")); + assert!(!DbBackend::Cockroach.is_prefix_of("sqlite://test.db")); + + // Test as_str + assert_eq!(DbBackend::Cockroach.as_str(), "Cockroach"); + + // Test support_returning + assert!(DbBackend::Cockroach.support_returning()); + + Ok(()) +} + +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_statement_building() -> Result<(), DbErr> { + use sea_orm::sea_query::{Query, Alias, MysqlQueryBuilder, PostgresQueryBuilder}; + + // Test that CockroachDB uses Postgres query builder + let select = Query::select() + .column(Alias::new("id")) + .from(Alias::new("test")) + .to_owned(); + + let stmt = DbBackend::Cockroach.build(&select); + assert_eq!(stmt.db_backend, DbBackend::Cockroach); + + // The SQL should be PostgreSQL compatible + let sql = stmt.sql; + assert!(sql.contains("SELECT")); + assert!(sql.contains("FROM")); + + // Verify it uses PostgreSQL-style placeholders, not MySQL-style + let mysql_sql = select.to_string(MysqlQueryBuilder); + let postgres_sql = select.to_string(PostgresQueryBuilder); + + // The CockroachDB output should match PostgreSQL + assert_ne!(sql, mysql_sql, "CockroachDB SQL should not match MySQL style"); + assert_eq!(sql, postgres_sql, "CockroachDB SQL should match PostgreSQL style"); + + Ok(()) +} + +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_schema_statements() -> Result<(), DbErr> { + use sea_orm::sea_query::{Table, ColumnDef, Alias}; + + // Test table create statement builds correctly + let table = Table::create() + .table(Alias::new("test_table")) + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("name")).string()) + .col(ColumnDef::new(Alias::new("active")).boolean()) + .to_owned(); + + let stmt = DbBackend::Cockroach.build(&table); + + // Should use IDENTITY, not SERIAL + assert!( + stmt.sql.contains("GENERATED BY DEFAULT AS IDENTITY"), + "Expected GENERATED BY DEFAULT AS IDENTITY in SQL: {}", + stmt.sql + ); + assert!( + !stmt.sql.contains("SERIAL"), + "SERIAL should not be used for CockroachDB: {}", + stmt.sql + ); + + Ok(()) +} + +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_schema_identity() -> Result<(), DbErr> { + use sea_orm::sea_query::{ColumnDef, Alias, PostgresQueryBuilder, Table}; + + // Create a table with auto-increment primary key + let table = Table::create() + .table(Alias::new("test_table")) + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("name")).string()) + .to_owned(); + + // Build the SQL - should use GENERATED BY DEFAULT AS IDENTITY, not SERIAL + let sql = table.to_string(PostgresQueryBuilder); + + // CockroachDB uses GENERATED BY DEFAULT AS IDENTITY instead of SERIAL + assert!( + sql.contains("GENERATED BY DEFAULT AS IDENTITY"), + "Expected GENERATED BY DEFAULT AS IDENTITY in schema, got: {sql}" + ); + assert!( + !sql.contains("SERIAL"), + "SERIAL should not be used for CockroachDB, got: {sql}" + ); + + Ok(()) +} + +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_boolean_value() -> Result<(), DbErr> { + use sea_orm::sea_query::Value; + + let true_val = DbBackend::Cockroach.boolean_value(true); + let false_val = DbBackend::Cockroach.boolean_value(false); + + // Boolean values should be converted correctly + assert_eq!(true_val, Value::Bool(Some(true))); + assert_eq!(false_val, Value::Bool(Some(false))); + + Ok(()) +} + +#[test] +#[cfg(all(feature = "cockroachdb", feature = "mock"))] +fn test_cockroachdb_mock() { + use sea_orm::MockDatabase; + + let db = MockDatabase::new(DbBackend::Cockroach).into_connection(); + + assert_eq!(db.get_database_backend(), DbBackend::Cockroach); + assert!(db.support_returning()); +} + +#[sea_orm_macros::test] +#[cfg(feature = "cockroachdb")] +async fn test_cockroachdb_insert_building() -> Result<(), DbErr> { + use sea_orm::sea_query::{Query, Alias}; + + // Test INSERT statement + let insert = Query::insert() + .into_table(Alias::new("test_table")) + .columns([Alias::new("name"), Alias::new("value")]) + .values_panic(["test".into(), 42i32.into()]) + .to_owned(); + + let stmt = DbBackend::Cockroach.build(&insert); + assert_eq!(stmt.db_backend, DbBackend::Cockroach); + assert!(stmt.sql.contains("INSERT")); + assert!(stmt.sql.contains("INTO")); + + Ok(()) +}