Repository: SeaQL/sea-query (2025-07-28T01:20:45Z)

Issue:
`CHECK` constraint with a name
## Motivation

Having a schema with defined constraint checks, I'd like to see a meaningful error message.

```rust
.check(Expr::col(Products::Price).gt(0))
```

Without it, all we'll get is:

> ⛔️ new row for relation "products" violates check constraint "products_check".

## Proposed Solutions

Extend the API in a way it could introduce names for constraints.

```sql
CREATE TABLE products (
    product_no integer,
    name text,
    price numeric CONSTRAINT positive_price CHECK (price > 0)
--                           ^^^^^^^^^^^^^^
);
```

Matching error messages against those `"positive_price"` names allows translating them for end users.


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 622d87260..4cd9901d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,8 @@ assert_eq!(std::mem::size_of::<Value>(), 32);
 
 ### Breaking Changes
 
+* Replace `ColumnSpec::Check(Expr)` with `ColumnSpec::Check(Check)` to support named check constraints
+
 * Removed inherent `SimpleExpr` methods that duplicate `ExprTrait`. If you encounter the following error, please add `use sea_query::ExprTrait` in scope https://github.com/SeaQL/sea-query/pull/890
 ```rust
 error[E0599]: no method named `like` found for enum `sea_query::Expr` in the current scope
@@ -138,8 +140,6 @@ impl Iden for Glyph {
   If you had custom implementations in your own code, some may no longer compile
   and may need to be deleted.
 
-### Bug Fixes
-
 ### Upgrades
 
 * Upgraded to Rust Edition 2024 https://github.com/SeaQL/sea-query/pull/885
diff --git a/src/backend/table_builder.rs b/src/backend/table_builder.rs
index 9388455f2..c6fbf0880 100644
--- a/src/backend/table_builder.rs
+++ b/src/backend/table_builder.rs
@@ -194,9 +194,14 @@ pub trait TableBuilder:
     }
 
     /// Translate the check constraint into SQL statement
-    fn prepare_check_constraint(&self, check: &Expr, sql: &mut dyn SqlWriter) {
+    fn prepare_check_constraint(&self, check: &Check, sql: &mut dyn SqlWriter) {
+        if let Some(name) = &check.name {
+            write!(sql, "CONSTRAINT ",).unwrap();
+            self.prepare_iden(name, sql);
+            write!(sql, " ",).unwrap();
+        }
         write!(sql, "CHECK (").unwrap();
-        QueryBuilder::prepare_simple_expr(self, check, sql);
+        QueryBuilder::prepare_simple_expr(self, &check.expr, sql);
         write!(sql, ")").unwrap();
     }
 
diff --git a/src/table/column.rs b/src/table/column.rs
index 8eca28f17..cfe9b2589 100644
--- a/src/table/column.rs
+++ b/src/table/column.rs
@@ -1,6 +1,6 @@
 use std::fmt;
 
-use crate::{expr::*, types::*};
+use crate::{expr::*, table::Check, types::*};
 
 /// Specification of a table column
 #[derive(Debug, Clone)]
@@ -179,7 +179,7 @@ pub enum ColumnSpec {
     AutoIncrement,
     UniqueKey,
     PrimaryKey,
-    Check(Expr),
+    Check(Check),
     Generated { expr: Expr, stored: bool },
     Extra(String),
     Comment(String),
@@ -687,6 +687,7 @@ impl ColumnDef {
     ///
     /// ```
     /// use sea_query::{tests_cfg::*, *};
+    ///
     /// assert_eq!(
     ///     Table::create()
     ///         .table(Glyph::Table)
@@ -699,12 +700,25 @@ impl ColumnDef {
     ///         .to_string(MysqlQueryBuilder),
     ///     r#"CREATE TABLE `glyph` ( `id` int NOT NULL CHECK (`id` > 10) )"#,
     /// );
+    ///
+    /// assert_eq!(
+    ///     Table::create()
+    ///         .table(Glyph::Table)
+    ///         .col(
+    ///             ColumnDef::new(Glyph::Id)
+    ///                 .integer()
+    ///                 .not_null()
+    ///                 .check(("positive_id", Expr::col(Glyph::Id).gt(10)))
+    ///         )
+    ///         .to_string(MysqlQueryBuilder),
+    ///     r#"CREATE TABLE `glyph` ( `id` int NOT NULL CONSTRAINT `positive_id` CHECK (`id` > 10) )"#,
+    /// );
     /// ```
-    pub fn check<T>(&mut self, value: T) -> &mut Self
+    pub fn check<T>(&mut self, check: T) -> &mut Self
     where
-        T: Into<Expr>,
+        T: Into<Check>,
     {
-        self.spec.push(ColumnSpec::Check(value.into()));
+        self.spec.push(ColumnSpec::Check(check.into()));
         self
     }
 
diff --git a/src/table/constraint.rs b/src/table/constraint.rs
new file mode 100644
index 000000000..52e8ee4db
--- /dev/null
+++ b/src/table/constraint.rs
@@ -0,0 +1,49 @@
+use crate::{DynIden, Expr};
+
+#[derive(Debug, Clone)]
+pub struct Check {
+    pub(crate) name: Option<DynIden>,
+    pub(crate) expr: Expr,
+}
+
+impl Check {
+    pub fn named<N, E>(name: N, expr: E) -> Self
+    where
+        N: Into<DynIden>,
+        E: Into<Expr>,
+    {
+        Self {
+            name: Some(name.into()),
+            expr: expr.into(),
+        }
+    }
+
+    pub fn unnamed<E>(expr: E) -> Self
+    where
+        E: Into<Expr>,
+    {
+        Self {
+            name: None,
+            expr: expr.into(),
+        }
+    }
+}
+
+impl<E> From<E> for Check
+where
+    E: Into<Expr>,
+{
+    fn from(expr: E) -> Self {
+        Self::unnamed(expr)
+    }
+}
+
+impl<I, E> From<(I, E)> for Check
+where
+    I: Into<DynIden>,
+    E: Into<Expr>,
+{
+    fn from((name, expr): (I, E)) -> Self {
+        Self::named(name, expr)
+    }
+}
diff --git a/src/table/create.rs b/src/table/create.rs
index 6bd50738e..53c6ebe95 100644
--- a/src/table/create.rs
+++ b/src/table/create.rs
@@ -1,8 +1,8 @@
 use inherent::inherent;
 
 use crate::{
-    ColumnDef, Expr, IntoColumnDef, SchemaStatementBuilder, backend::SchemaBuilder, foreign_key::*,
-    index::*, types::*,
+    ColumnDef, IntoColumnDef, SchemaStatementBuilder, backend::SchemaBuilder, foreign_key::*,
+    index::*, table::constraint::Check, types::*,
 };
 
 /// Create a table
@@ -88,7 +88,7 @@ pub struct TableCreateStatement {
     pub(crate) indexes: Vec<IndexCreateStatement>,
     pub(crate) foreign_keys: Vec<ForeignKeyCreateStatement>,
     pub(crate) if_not_exists: bool,
-    pub(crate) check: Vec<Expr>,
+    pub(crate) check: Vec<Check>,
     pub(crate) comment: Option<String>,
     pub(crate) extra: Option<String>,
     pub(crate) temporary: bool,
@@ -146,8 +146,11 @@ impl TableCreateStatement {
         self
     }
 
-    pub fn check(&mut self, value: Expr) -> &mut Self {
-        self.check.push(value);
+    pub fn check<T>(&mut self, value: T) -> &mut Self
+    where
+        T: Into<Check>,
+    {
+        self.check.push(value.into());
         self
     }
 
diff --git a/src/table/mod.rs b/src/table/mod.rs
index fd30eeea8..1afafc20f 100644
--- a/src/table/mod.rs
+++ b/src/table/mod.rs
@@ -12,6 +12,7 @@ use crate::SchemaBuilder;
 
 mod alter;
 mod column;
+mod constraint;
 mod create;
 mod drop;
 mod rename;
@@ -19,6 +20,7 @@ mod truncate;
 
 pub use alter::*;
 pub use column::*;
+pub use constraint::*;
 pub use create::*;
 pub use drop::*;
 pub use rename::*;
diff --git a/src/types.rs b/src/types.rs
index 5ac4d7e33..a183ae5f0 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -118,6 +118,12 @@ impl std::fmt::Display for DynIden {
     }
 }
 
+impl From<&'static str> for DynIden {
+    fn from(s: &'static str) -> Self {
+        Self(Cow::Borrowed(s))
+    }
+}
+
 pub trait IntoIden {
     fn into_iden(self) -> DynIden;
 }


Test patch:
diff --git a/tests/mysql/table.rs b/tests/mysql/table.rs
index bb37efbb9..fb7e27cb9 100644
--- a/tests/mysql/table.rs
+++ b/tests/mysql/table.rs
@@ -430,6 +430,24 @@ fn create_with_check_constraint() {
     );
 }
 
+#[test]
+fn create_with_named_check_constraint() {
+    assert_eq!(
+        Table::create()
+            .table(Glyph::Table)
+            .col(
+                ColumnDef::new(Glyph::Id)
+                    .integer()
+                    .not_null()
+                    .check(("positive_id", Expr::col(Glyph::Id).gt(10)))
+            )
+            .check(("id_range", Expr::col(Glyph::Id).lt(20)))
+            .check(Expr::col(Glyph::Id).ne(15))
+            .to_string(MysqlQueryBuilder),
+        r"CREATE TABLE `glyph` ( `id` int NOT NULL CONSTRAINT `positive_id` CHECK (`id` > 10), CONSTRAINT `id_range` CHECK (`id` < 20), CHECK (`id` <> 15) )",
+    );
+}
+
 #[test]
 fn alter_with_check_constraint() {
     assert_eq!(
@@ -446,3 +464,20 @@ fn alter_with_check_constraint() {
         r"ALTER TABLE `glyph` ADD COLUMN `aspect` int NOT NULL DEFAULT 101 CHECK (`aspect` > 100)",
     );
 }
+
+#[test]
+fn alter_with_named_check_constraint() {
+    assert_eq!(
+        Table::alter()
+            .table(Glyph::Table)
+            .add_column(
+                ColumnDef::new(Glyph::Aspect)
+                    .integer()
+                    .not_null()
+                    .default(101)
+                    .check(("positive_aspect", Expr::col(Glyph::Aspect).gt(100)))
+            )
+            .to_string(MysqlQueryBuilder),
+        r#"ALTER TABLE `glyph` ADD COLUMN `aspect` int NOT NULL DEFAULT 101 CONSTRAINT `positive_aspect` CHECK (`aspect` > 100)"#,
+    );
+}
diff --git a/tests/postgres/table.rs b/tests/postgres/table.rs
index cd75d2cb7..25228f048 100644
--- a/tests/postgres/table.rs
+++ b/tests/postgres/table.rs
@@ -559,6 +559,24 @@ fn create_with_check_constraint() {
     );
 }
 
+#[test]
+fn create_with_named_check_constraint() {
+    assert_eq!(
+        Table::create()
+            .table(Glyph::Table)
+            .col(
+                ColumnDef::new(Glyph::Id)
+                    .integer()
+                    .not_null()
+                    .check(("positive_id", Expr::col(Glyph::Id).gt(10)))
+            )
+            .check(("id_range", Expr::col(Glyph::Id).lt(20)))
+            .check(Expr::col(Glyph::Id).ne(15))
+            .to_string(PostgresQueryBuilder),
+        r#"CREATE TABLE "glyph" ( "id" integer NOT NULL CONSTRAINT "positive_id" CHECK ("id" > 10), CONSTRAINT "id_range" CHECK ("id" < 20), CHECK ("id" <> 15) )"#,
+    );
+}
+
 #[test]
 fn alter_with_check_constraint() {
     assert_eq!(
@@ -576,6 +594,23 @@ fn alter_with_check_constraint() {
     );
 }
 
+#[test]
+fn alter_with_named_check_constraint() {
+    assert_eq!(
+        Table::alter()
+            .table(Glyph::Table)
+            .add_column(
+                ColumnDef::new(Glyph::Aspect)
+                    .integer()
+                    .not_null()
+                    .default(101)
+                    .check(("positive_aspect", Expr::col(Glyph::Aspect).gt(100)))
+            )
+            .to_string(PostgresQueryBuilder),
+        r#"ALTER TABLE "glyph" ADD COLUMN "aspect" integer NOT NULL DEFAULT 101 CONSTRAINT "positive_aspect" CHECK ("aspect" > 100)"#,
+    );
+}
+
 #[test]
 fn create_16() {
     assert_eq!(
diff --git a/tests/sqlite/table.rs b/tests/sqlite/table.rs
index 659ca3123..0da2b7e0e 100644
--- a/tests/sqlite/table.rs
+++ b/tests/sqlite/table.rs
@@ -464,6 +464,24 @@ fn create_with_check_constraint() {
     );
 }
 
+#[test]
+fn create_with_named_check_constraint() {
+    assert_eq!(
+        Table::create()
+            .table(Glyph::Table)
+            .col(
+                ColumnDef::new(Glyph::Id)
+                    .integer()
+                    .not_null()
+                    .check(("positive_id", Expr::col(Glyph::Id).gt(10)))
+            )
+            .check(("id_range", Expr::col(Glyph::Id).lt(20)))
+            .check(Expr::col(Glyph::Id).ne(15))
+            .to_string(SqliteQueryBuilder),
+        r#"CREATE TABLE "glyph" ( "id" integer NOT NULL CONSTRAINT "positive_id" CHECK ("id" > 10), CONSTRAINT "id_range" CHECK ("id" < 20), CHECK ("id" <> 15) )"#,
+    );
+}
+
 #[test]
 fn alter_with_check_constraint() {
     assert_eq!(
@@ -480,3 +498,20 @@ fn alter_with_check_constraint() {
         r#"ALTER TABLE "glyph" ADD COLUMN "aspect" integer NOT NULL DEFAULT 101 CHECK ("aspect" > 100)"#,
     );
 }
+
+#[test]
+fn alter_with_named_check_constraint() {
+    assert_eq!(
+        Table::alter()
+            .table(Glyph::Table)
+            .add_column(
+                ColumnDef::new(Glyph::Aspect)
+                    .integer()
+                    .not_null()
+                    .default(101)
+                    .check(("positive_aspect", Expr::col(Glyph::Aspect).gt(100)))
+            )
+            .to_string(SqliteQueryBuilder),
+        r#"ALTER TABLE "glyph" ADD COLUMN "aspect" integer NOT NULL DEFAULT 101 CONSTRAINT "positive_aspect" CHECK ("aspect" > 100)"#,
+    );
+}