Indexes
Declare database indexes with [Index]: single-column and composite, unique, partial, covering, sorted, and with a portable index method.
Overview
[Index] declares a database index over one or more columns. Put it on a property for a single-column index, or on the class and list the properties for a composite one, exactly as [Unique] works.
using Socigy.OpenSource.DB.Attributes;
[Table("users")]
[Index(nameof(TenantId), nameof(Email), Unique = true)]
public partial class User
{
[PrimaryKey, Default(DbDefaults.Guid.Random)]
public Guid Id { get; set; }
public Guid TenantId { get; set; }
[Index]
public string Email { get; set; }
public string? Status { get; set; }
}CREATE INDEX IF NOT EXISTS "IX_users_email" ON "users" ("email");
CREATE UNIQUE INDEX IF NOT EXISTS "UX_users_tenant_id_email" ON "users" ("tenant_id", "email");The attribute may be applied more than once, so a column can carry several indexes:
[Index]
[Index(Where = "status <> 'deleted'", Name = "ix_users_live_email")]
public string Email { get; set; }An index is not a constraint. Use [Unique] when you want the database to enforce uniqueness as part of the table definition, and [Index(Unique = true)] when you want a unique index you can also make partial or sort.
Index names
Left unnamed, an index gets a deterministic name built from the table and its key columns: IX_ for a plain index, UX_ for a unique one.
[Index] // IX_users_email
[Index(Unique = true)] // UX_users_email
[Index(Where = "archived = false")] // IX_users_email_3f8a21c4Two indexes over the same columns that differ only in their options get a short suffix derived from those options, so their names cannot collide. A name longer than the database engine's identifier limit is shortened deterministically rather than being silently truncated by the server, which would merge two distinct indexes into one.
Set Name to choose the name yourself:
[Index(nameof(TenantId), nameof(Email), Name = "ix_tenant_email")]Options
| Option | Type | Effect |
|---|---|---|
Name |
string |
Index name. Derived from the table and key columns when unset. |
Unique |
bool |
Enforces uniqueness across the key columns. |
Method |
string |
What the index is for, as a DbIndexMethods constant. |
Where |
string |
Restricts the index to the rows matching a raw SQL predicate. |
Include |
string[] |
Non-key columns stored in the index (covering index). |
Descending |
bool |
Sorts every key column descending. |
Nulls |
string |
Where NULLs sort, as a DbIndexNulls constant, for every key column. |
DescendingColumns |
string[] |
The key columns that sort descending. |
NullsFirstColumns |
string[] |
The key columns that sort NULLs first. |
NullsLastColumns |
string[] |
The key columns that sort NULLs last. |
Unique indexes
[Table("users")]
[Index(nameof(TenantId), nameof(Email), Unique = true)]
public partial class User { /* ... */ }CREATE UNIQUE INDEX IF NOT EXISTS "UX_users_tenant_id_email" ON "users" ("tenant_id", "email");Partial indexes
Where restricts the index to the rows that match it, which keeps the index small when most rows are irrelevant to the queries using it. A unique partial index enforces uniqueness only among the matching rows, which is the usual way to say "at most one active row per user":
[Table("subscriptions")]
[Index(nameof(UserId), Unique = true, Where = "status = 'active'")]
public partial class Subscription { /* ... */ }CREATE UNIQUE INDEX IF NOT EXISTS "UX_subscriptions_user_id_9b41e07f"
ON "subscriptions" ("user_id") WHERE status = 'active';The predicate is raw SQL written against database column names, and is passed through verbatim. See Portability.
Covering indexes
Include stores extra columns in the index without making them part of the key, so a query reading only those columns is answered from the index alone:
[Table("orders")]
[Index(nameof(CustomerId), Include = new[] { nameof(Status), nameof(Total) })]
public partial class Order { /* ... */ }CREATE INDEX IF NOT EXISTS "IX_orders_customer_id_7c25ab90"
ON "orders" ("customer_id") INCLUDE ("status", "total");Sort order
An index scanned in the same direction as your ORDER BY avoids a sort step. Set the order for the whole index with Descending and Nulls, or per column with the array forms:
// Every key column descending
[Index(nameof(CreatedAt), Descending = true, Nulls = DbIndexNulls.Last)]
// Only CreatedAt descending, TenantId stays ascending
[Index(nameof(TenantId), nameof(CreatedAt), DescendingColumns = new[] { nameof(CreatedAt) })]CREATE INDEX IF NOT EXISTS "IX_orders_created_at_1d4f9e33"
ON "orders" ("created_at" DESC NULLS LAST);The scalar options apply to every key column; the array options override them for the columns they name.
Index methods
Method says what the index is for rather than naming a specific database's access method, so the same model works on every engine. Each engine maps the intent to its own equivalent:
| Constant | Use for | PostgreSQL |
|---|---|---|
DbIndexMethods.Default |
Equality, ranges, sorting. The default. | btree |
DbIndexMethods.Hash |
Equality only. Smaller, but useless for ranges and sorting. | hash |
DbIndexMethods.FullText |
Text search. | gin |
DbIndexMethods.Spatial |
Geometric containment and proximity. | gist |
DbIndexMethods.Contains |
Containment over arrays and JSON documents. | gin |
DbIndexMethods.BlockRange |
Very large tables already ordered by the indexed column, such as an append-only timestamp. | brin |
[Index(Method = DbIndexMethods.Contains)]
public string Tags { get; set; }CREATE INDEX IF NOT EXISTS "IX_documents_tags_5a70c1de"
ON "documents" USING gin ("tags");Portability
The attribute is engine-neutral, with two deliberate exceptions:
Whereis raw SQL.RawMethodtakes the engine's own access method name ("spgist") and overridesMethod.
A model using either is tied to one database engine. Everything else is described in portable terms and translated per engine.
When an engine cannot express an option at all, what happens depends on whether the option affects performance or meaning:
| Option | On an engine that lacks it |
|---|---|
Method, Include, sort order |
Dropped, with a warning from the CLI. The index returns the same rows, less efficiently. |
Where on a non-unique index |
Dropped, with a warning. The index covers more rows than asked. |
Where on a unique index |
Error. Indexing every row would enforce uniqueness over rows the filter deliberately excludes. |
Unique |
Error. A silently non-unique index would stop the database enforcing what the model declares. |
Migrations
Indexes take part in migrations like any other schema element. Adding one to a model generates a CREATE INDEX; removing it generates a DROP INDEX. Because no database can alter an index in place, changing any option regenerates it as a drop followed by a create.
Statement order within a migration follows the dependencies: an index is created after the table and columns it covers exist, and dropped before them.
CREATE INDEX CONCURRENTLY is not generated, because a migration's DDL and its bookkeeping row are applied in a single transaction and a concurrent build cannot run inside one. To build an index concurrently, do it outside the tool and add the [Index] afterwards, where the generated CREATE INDEX IF NOT EXISTS becomes a no-op.Dropping an index raises a safety warning when the migration is generated, because rebuilding it later costs as much as building it did the first time. Rolling back a migration that dropped an index rebuilds it from scratch.
Scaffolding
Database-first scaffolding reads existing indexes and emits [Index] attributes for them, so scaffolding a database and then generating a migration does not propose dropping the indexes it already has. Index methods come back as portable DbIndexMethods constants; an access method with no portable equivalent is recovered as RawMethod.
Indexes that implement a primary key or a UNIQUE constraint are left out, since those are already modelled as constraints. Expression indexes (ON t ((lower(email)))) have no attribute form; they are reported and skipped, so you can re-create them by hand.
Diagnostics
A column named by a [Index] that the table does not have is a compile-time error (SCGDB026). Using nameof gets this from the compiler as well; the diagnostic covers string literals, which the compiler cannot check.
[Index("Emial")] // SCGDB026: [Index] references an unknown property