Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# pg_search changelog

## Unreleased

* Support composite primary keys in search scopes (yaojie)

## 2.4.0 - 2026-09-07

* Drop support for Active Record 7.2
Expand Down
27 changes: 27 additions & 0 deletions doc/plans/2026-10-14-composite-primary-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Composite primary keys in search scopes

Builds on #537 (causztic), which this branch merges onto the Arel-based scope
construction. The merge commit itself supports searching a composite-key model
by its own columns and searching through an association to a composite-key model.

## Contract

`pg_search_scope` works on a model with a composite primary key: ranking joins,
default ordering within rank, and `associated_against` match every key column.
Single-key SQL is unchanged except for internal aliases that callers cannot
reference. Multisearch stays single-key: its `searchable_id` column holds one
value, and Rails already rejects the polymorphic association for a composite key.

- [x] **Spec fixtures:** simplify the merged composite-key spec to ordinary
domain models, meaningful columns, conventional associations, and examples
that describe their behavior.
- [x] **Associations from a composite-key model:** join and group the association
subquery on every primary-key column, covering aggregated and singular
associations.

## Verification and delivery

Show each new example failing before its fix. Run the focused specs, the full
suite, and Standard for each commit. Review the branch with independent
reviewers before publishing it as a Casecommons branch and pull request that
credits #537.
20 changes: 10 additions & 10 deletions lib/pg_search/configuration/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ def arel_table = @model.reflect_on_association(@name).klass.arel_table
#
# Arguments
#
# +primary_key+:: An Arel expression or trusted SQL String identifying the
# model's primary-key column, not a record's key value.
# +primary_keys+:: Arel attributes identifying every model primary-key
# column, not a record key value.
#
# Returns a composable outer join to the search subquery.
def to_arel(primary_key)
primary_key = Arel.sql(primary_key) if primary_key.is_a?(String)
subquery = relation(primary_key).arel.as(subselect_alias)
on_condition = Arel::Nodes::On.new(subquery[:id].eq(primary_key))
Arel::Nodes::OuterJoin.new(subquery, on_condition)
def to_arel(*primary_keys)
join_keys = primary_keys.each.with_index(1).to_h { |attribute, index| ["id_#{index}", attribute] }
subquery = relation(join_keys).arel.as(subselect_alias)
on_condition = Arel::Nodes::And.new(join_keys.map { |key_alias, attribute| subquery[key_alias].eq(attribute) })
Arel::Nodes::OuterJoin.new(subquery, Arel::Nodes::On.new(on_condition))
end

def subselect_alias
Expand All @@ -42,12 +42,12 @@ def subselect_alias

private

def relation(primary_key)
def relation(join_keys)
result = @model.unscoped.joins(@name).select(
primary_key.as("id"),
*join_keys.map { |key_alias, attribute| attribute.as(key_alias) },
*selects
)
result = result.group(primary_key) unless singular_association?
result = result.group(*join_keys.values) unless singular_association?
result
end

Expand Down
20 changes: 12 additions & 8 deletions lib/pg_search/scope_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def increment_counter
def subquery
model
.unscoped
.select(model.arel_table[model.primary_key].as("pg_search_id"))
.select(*rank_keys.map { |key_alias, attribute| attribute.as(key_alias) })
.select(rank.as("rank"))
.joins(subquery_join)
.where(conditions)
Expand All @@ -99,16 +99,12 @@ def order_within_rank
if config.order_within_rank
Arel.sql(config.order_within_rank)
else
model.arel_table[model.primary_key].asc
rank_keys.values.map(&:asc)
end
end

def primary_key
Arel::Attributes::Attribute.new(model.arel_table, model.primary_key)
end

def subquery_join
config.associations.map { |association| association.to_arel(primary_key) }
config.associations.map { |association| association.to_arel(*rank_keys.values) }
end

FEATURE_CLASSES = { # standard:disable Lint/UselessConstantScoping
Expand Down Expand Up @@ -145,7 +141,7 @@ def rank
def rank_join(rank_subquery)
model.arel_table
.join(rank_subquery)
.on(model.arel_table[model.primary_key].eq(rank_subquery[:pg_search_id]))
.on(Arel::Nodes::And.new(rank_keys.map { |key_alias, attribute| attribute.eq(rank_subquery[key_alias]) }))
.join_sources
end

Expand All @@ -156,5 +152,13 @@ def include_table_aliasing_for_rank(scope)
new_scope.instance_eval { extend PgSearchRankTableAliasing }
end
end

# Maps each rank-subquery key alias to the model primary-key column it joins.
def rank_keys
attributes = Array(model.primary_key).map { |column| Arel::Attributes::Attribute.new(model.arel_table, column) }
return {"pg_search_id" => attributes.first} unless model.composite_primary_key?

attributes.each.with_index(1).to_h { |attribute, index| ["pg_search_id_#{index}", attribute] }
end
end
end
80 changes: 80 additions & 0 deletions spec/integration/composite_primary_key_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

require "spec_helper"

describe "a model with a composite primary key" do
with_model :Author do
table do |t|
t.string :name
end
end

with_model :Book do
table primary_key: [:author_id, :number] do |t|
t.integer :author_id
t.integer :number
t.string :title
end

model do
include PgSearch::Model

belongs_to :author
has_many :reviews, foreign_key: [:book_author_id, :book_number]

pg_search_scope :search_title, against: :title
pg_search_scope :search_author_name, associated_against: {author: :name}
pg_search_scope :search_reviews, associated_against: {reviews: :body}
end
end

with_model :Review do
table do |t|
t.integer :book_author_id
t.integer :book_number
t.string :body
end

model do
include PgSearch::Model

belongs_to :book, foreign_key: [:book_author_id, :book_number]

pg_search_scope :search_book_title, associated_against: {book: :title}
end
end

let(:author) { Author.create!(name: "Tolkien") }

it "joins the rank on every key column and orders ties by the key" do
second = Book.create!(author:, number: 2, title: "Ruby")
first = Book.create!(author:, number: 1, title: "Ruby")
Book.create!(author:, number: 3, title: "Python")

expect(Book.search_title("Ruby")).to eq [first, second]
end

it "joins an associated record on every key column" do
ruby_review = Review.create!(book: Book.create!(author:, number: 1, title: "Ruby"))
Review.create!(book: Book.create!(author:, number: 2, title: "Python"))

expect(Review.search_book_title("Ruby")).to eq [ruby_review]
end

it "joins its own singular association on every key column" do
first = Book.create!(author:, number: 1)
second = Book.create!(author:, number: 2)
Book.create!(author: Author.create!(name: "Lewis"), number: 1)

expect(Book.search_author_name("Tolkien")).to eq [first, second]
end

it "aggregates its own collection association by every key column" do
reviewed = Book.create!(author:, number: 1)
reviewed.reviews.create!(body: "gripping")
reviewed.reviews.create!(body: "plot")
Book.create!(author:, number: 2).reviews.create!(body: "gripping")

expect(Book.search_reviews("gripping plot")).to eq [reviewed]
end
end
Loading