Skip to content

Declare table-level indexes on entities with #[sea_orm(index(..))] - #3231

Open
LeenHawk wants to merge 3 commits into
SeaQL:masterfrom
LeenHawk:index-attr
Open

LeenHawk wants to merge 3 commits into
SeaQL:masterfrom
LeenHawk:index-attr

Conversation

@LeenHawk

@LeenHawk LeenHawk commented Oct 11, 2026 •

Copy link
Copy Markdown

Closes #3157.

Column-level attributes cannot express non-unique composite indexes, explicit column order, descending columns, or partial indexes. This adds table-level declarations so those indexes can live on the entity and participate in schema sync.

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "post")]
#[sea_orm(
    index("idx_post_feed", on(author_id, created_at(desc))),
    index("uq_post_author_slug", on(author_id, slug), unique),
    index("idx_post_archived", on(archived_at), where = "archived_at IS NOT NULL")
)]
pub struct Model { /* ... */ }

Every index needs an explicit name. on(..) uses Rust field names and respects column_name / rename_all; unknown fields are compile errors. Columns can have (asc) or (desc).

where accepts either a SQL string or a SeaQuery expression implementing IntoCondition, for example where = Expr::col(Column::ArchivedAt).is_not_null() with sea_orm::ExprTrait in scope. Partial indexes are supported by SQLite and Postgres, not MySQL. NULL ordering is left out for now: SeaQuery's index API has no corresponding option.

The derive generates EntityName::table_indexes(). Its default is empty, so existing entities are unaffected. Schema::create_index_from_entity, the schema builder, and entity registry include these indexes.

Schema sync matches table-level indexes by name. It creates missing ones and keeps declared unique indexes; it does not compare definitions, so changing a definition requires a new name. Column-level matching is unchanged.

The macro's consume_meta also skips nested attribute lists, allowing #[sea_orm::model] and the other derives to pass over index(..).

Verified locally on Linux:

  • cargo test --workspace
  • DATABASE_URL="sqlite::memory:" cargo test --test schema_sync_tests --features default,sqlx-sqlite,runtime-tokio-rustls,schema-sync
  • cargo clippy -p sea-orm -p sea-orm-macros --features sqlx-sqlite,runtime-tokio,schema-sync,macros -- -D warnings
  • cargo +nightly fmt --all --check

Tests cover SQL generation for SQLite/Postgres/MySQL, both condition forms, renamed columns, invalid attributes, and repeated SQLite sync on an existing table. I haven't run the database integration tests against Postgres or MySQL locally.

Column-level attributes cannot express non-unique composite indexes, an
explicit column order, descending columns or partial indexes, and indexes
created outside the entity are not managed by schema sync (SeaQL#3157).

- `DeriveEntityModel` accepts table-level
  `#[sea_orm(index(name = "..", columns = "a, b DESC", unique, where = ".."))]`
  and generates `EntityName::table_indexes` (default: none).
- `Schema::create_index_from_entity` includes them; unnamed ones get
  `idx-{table}-{columns}`.
- Schema sync creates declared table-level indexes that are missing and
  never drops a declared unique one, matching them by name, so a partial or
  reordered index is not mistaken for another index on the same columns.
- `consume_meta` now skips `key(..)` lists, which `#[sea_orm::model]` and
  other derives previously failed to parse.
Copilot AI balanced review requested due to automatic review settings October 11, 2026 09:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Huliiiiii

Copy link
Copy Markdown
Member
  1. I prefer syntax like this:
    #[sea_orm(
        index(
            "idx_post_feed",
            on(author_id, created_at(desc, nulls_last))
        ),
        index(
            "uq_post_author_slug",
            on(author_id, slug),
            unique
        ),
        index(
            "idx_post_archived",
            on(archived_at),
            where = "archived_at IS NOT NULL" // or
            where = Expr::col(Column::ArchivedAt).is_null() // sea query expr / impl IntoCondition
        )
    )]
  2. We should require an explicit name for index, as it would improve maintainability and compatibility.

@LeenHawk

Copy link
Copy Markdown
Author

Thanks, the on(..) syntax reads better. Updated in 1297c31: names are now required, columns use on(author_id, created_at(desc)), and where accepts either a SQL string or a SeaQuery expression/condition. I removed the automatic naming path; sync still matches these indexes by name.

One part I left out is nulls_last / nulls_first. SeaQuery 1.0.2's index API only has Asc and Desc, with no NULL ordering option. That would need SeaQuery support first rather than a SQL workaround here.

Workspace tests, the SQLite sync tests, Clippy, and formatting pass locally. Updated the examples and PR description too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema-syncing more complex indexes

3 participants