Skip to content

Support partial indexes with a WHERE clause#1154

Open
grepsedawk wants to merge 1 commit into
luckyframework:mainfrom
grepsedawk:issue-1023-partial-index
Open

Support partial indexes with a WHERE clause#1154
grepsedawk wants to merge 1 commit into
luckyframework:mainfrom
grepsedawk:issue-1023-partial-index

Conversation

@grepsedawk

@grepsedawk grepsedawk commented May 30, 2026

Copy link
Copy Markdown
Contributor

Adds a where: option to create_index and add_index so migrations can create PostgreSQL partial indexes — including conditional unique indexes (CREATE UNIQUE INDEX ... WHERE ...), the original ask.

Fixes #1023

API

where: accepts a raw SQL predicate String or an Avram::Queryable:

# raw SQL — self-contained, cleanest for comparisons
create_index :servers, [:enabled, :kind], unique: true, where: "enabled = true"
# => CREATE UNIQUE INDEX servers_enabled_kind_index ON servers USING btree ("enabled", "kind") WHERE enabled = true;

# query object — values inlined as literals
add_index :enabled, where: ServerQuery.new.enabled(true)

DDL predicates can't use bind parameters, so a query is rendered with its values inlined (new QueryBuilder#to_prepared_where_sql). Only the query's where conditions are used (select/order/limit are ignored); a query with no conditions raises. The raw-String form avoids coupling a migration to an app query class; the query form is offered for ergonomics.

Implementation

  • QueryBuilder#to_prepared_where_sql returns the WHERE conditions as a bare, inlined predicate; wheres_sql now builds on the same where_predicate_sql!, so there's a single source of truth.
  • CreateIndexStatement emits WHERE <predicate> after the column list (composes with unique, concurrently, custom name, all index types).
  • create_index / add_index normalize the String | Avram::Queryable option through one shared helper.

Tests

Specs cover both forms through create_index and add_index, the QueryBuilder/Queryable predicate extraction (boolean inlining, OR conjunctions, order/limit ignored, raises-on-empty), and the conditional-unique SQL.

🤖 PR Body Generated with Claude Code

@jwoertink jwoertink left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for taking this on! This will be a neat feature to get added in.

I do feel like some of this code is a bit unnecessary though. Since you're allowing both raw strings and a query object into the where, you don't know if a WHERE statement is being passed in or not, so you're having to move a ton of stuff around just to account for that. Then we now have these new bulky named methods that feel a bit out of place... Also, it sort of breaks a bit of the principle of Avram to try and catch errors at compile-time with the raw strings being front and center like that...

What if we did this... The where argument takes the query object and that's it. Then a second arg called where_raw takes in a raw string. Now the method knows when you're doing one over the other which could either call where.to_prepared_sql or WHERE #{where_raw}. With that, all of the changes to queryable and query_builder would just go away. That makes the primary target something that's compile-time safety, and we keep the escape hatch available while also making it very clear it's a bit more "dangerous". What are your thoughts on that?

Comment thread src/avram/query_builder.cr Outdated
predicate = clone.where_predicate_sql!
if predicate.nil?
raise Avram::InvalidQueryError.new("Cannot build a partial index predicate: the query has no `where` conditions")
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The raised error here points specifically towards an index, but this is now a public method on Builder which someone could run anywhere.

Add where: and where_raw: options to create_index and add_index so
migrations can create PostgreSQL partial indexes, including conditional
unique indexes (CREATE UNIQUE INDEX ... WHERE ...). Resolves issue luckyframework#1023.

where: takes an Avram::Queryable for the compile-time-safe path; its
WHERE conditions are rendered with values inlined as literals, since
DDL cannot use bind parameters. where_raw: is the labeled escape hatch
for a raw SQL predicate String. Passing both raises.

To support the query form, QueryBuilder gains to_prepared_where_sql,
which returns the WHERE conditions as a bare, inlined predicate.
wheres_sql is refactored onto a shared where_predicate_sql so the
predicate has a single source of truth.
@grepsedawk grepsedawk force-pushed the issue-1023-partial-index branch from 0b967bc to 227bfd1 Compare June 3, 2026 06:36
@grepsedawk

Copy link
Copy Markdown
Contributor Author

Went with the split: where: takes a query, where_raw: takes a raw string, and passing both raises. The union and its case dispatch are gone.

One caveat on the "changes to queryable and query_builder just go away" part. They shrink, but don't fully disappear. to_prepared_sql returns the whole SELECT ... WHERE ..., so converting a query into an index predicate still needs a bare, literal-inlined WHERE extractor. That's to_prepared_where_sql, a small refactor of the existing wheres_sql. Pushed to the branch.

@jwoertink

Copy link
Copy Markdown
Member

Ah, ok. Yeah, that makes sense then.

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.

Support creating INDEX with a WHERE

2 participants