Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
50 changes: 47 additions & 3 deletions lib/pg_search/configuration/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def table_name
@model.reflect_on_association(@name).table_name
end

def join(primary_key)
"LEFT OUTER JOIN (#{relation(primary_key).to_sql}) #{subselect_alias} ON #{subselect_alias}.id = #{primary_key}"
def join(primary_key, &block)
"LEFT OUTER JOIN (#{relation(primary_key, &block).to_sql}) #{subselect_alias} ON #{subselect_alias}.id = #{primary_key}"
end

def subselect_alias
Expand Down Expand Up @@ -49,15 +49,59 @@ def selects_for_multiple_association
end.join(", ")
end

def relation(primary_key)
def relation(primary_key, &block)
result = @model.unscoped.joins(@name).select("#{primary_key} AS id, #{selects}")
result = result.group(primary_key) unless singular_association?

# Apply optional tenant scoping block to associated relation
# Example: { |rel| rel.where(tenant_id: current_tenant.id) }
if block_given?
begin
original_where_count = count_where_conditions(result)
result = block.call(result)
new_where_count = count_where_conditions(result)

# Verify tenant scoping actually added filtering to association, if a block was added we should infact see extra clauses chained
if new_where_count <= original_where_count
raise SecurityError, "Association tenant scoping must add WHERE conditions for #{@name}"
end
rescue SecurityError
raise # Re-raise security errors
rescue => e
# Never allow association queries without tenant isolation when explicit block is passed down
raise SecurityError, "Association tenant scoping failed for #{@name}: #{e.message}"
end
end

result
end

def singular_association?
%i[has_one belongs_to].include?(@model.reflect_on_association(@name).macro)
end

def count_where_conditions(scope)
# Handle test mocks and edge cases where where_clause might not be available
return 0 unless scope.respond_to?(:where_clause)

where_clause = scope.where_clause
return 0 if where_clause.nil?

ast = where_clause.ast
return 0 if ast.nil?

case ast
when Arel::Nodes::And
# AND node has multiple conditions
ast.children.count
when Arel::Nodes::Or
# OR node has multiple conditions
ast.children.count
else
# Single condition (Equality, etc.)
1
end
end
end
end
end
134 changes: 123 additions & 11 deletions lib/pg_search/scope_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,70 @@ def initialize(config)
end

def apply(scope, &block)
if block_given?
# New inline approach for tenant scoping
apply_with_tenant_scoping(scope, &block)
else
# Original subquery approach for backward compatibility
apply_original(scope)
end
end

private

def apply_with_tenant_scoping(scope, &block)
# Add association joins inline (not in subquery)
scope = scope.joins(Arel.sql(subquery_join(&block))) if config.associations.any?

# Only select all columns if no columns were explicitly selected
if !scope.respond_to?(:select_values) || scope.select_values.empty?
scope = scope.select("#{model.quoted_table_name}.*")
end
# If columns were explicitly selected, preserve them as-is

# Wrap search conditions in Arel Grouping to ensure proper parentheses
grouped_conditions = Arel::Nodes::Grouping.new(conditions)
scope = scope.where(grouped_conditions)

# Apply tenant scoping block for isolation LAST
# Example: { |rel| rel.where(tenant_id: current_tenant.id) }
begin
original_where_count = count_where_conditions(scope)
scope = block.call(scope)
new_where_count = count_where_conditions(scope)

# Verify tenant scoping actually added filtering to association, if a block was added we should infact see extra clauses chained
if new_where_count <= original_where_count
raise SecurityError, "Tenant scoping block must add WHERE conditions for security"
end
rescue SecurityError
raise # bubble up
rescue => e
# Never allow association queries without tenant isolation when explicit block is passed down
raise SecurityError, "Tenant scoping failed: #{e.message}. Query blocked for security."
end

# Order by primary key only - rank ordering will be added by with_pg_search_rank
scope = scope.order(Arel.sql("#{order_within_rank}"))

# Inject rank and order expressions into the scope for the inline module to use
rank_expression = rank
order_expression = order_within_rank

scope.define_singleton_method(:pg_search_rank_expression) { rank_expression }
scope.define_singleton_method(:pg_search_order_within_rank) { order_expression }

# Extend with modules for compatibility
scope.extend(WithPgSearchRankInline)
scope.extend(WithPgSearchHighlight[feature_for(:tsearch)])
end

def apply_original(scope)
scope = include_table_aliasing_for_rank(scope)
rank_table_alias = scope.pg_search_rank_table_alias(include_counter: true)

scope
.joins(rank_join(rank_table_alias, &block))
.joins(rank_join(rank_table_alias))
.order(Arel.sql("#{rank_table_alias}.rank DESC, #{order_within_rank}"))
.extend(WithPgSearchRank)
.extend(WithPgSearchHighlight[feature_for(:tsearch)])
Expand Down Expand Up @@ -48,10 +107,31 @@ def highlight
end

module WithPgSearchRank
def with_pg_search_rank
# Check if we're using inline approach (has pg_search_rank already selected)
if select_values.any? && select_values.any? { |v| v.to_s.include?('pg_search_rank') }
# Inline approach - rank is already added
self
else
# Original subquery approach - need to add rank from subquery table
scope = self
scope = scope.select("#{table_name}.*") unless scope.select_values.any?
scope.select("#{pg_search_rank_table_alias}.rank AS pg_search_rank")
end
end
end

module WithPgSearchRankInline
def with_pg_search_rank
scope = self
scope = scope.select("#{table_name}.*") unless scope.select_values.any?
scope.select("#{pg_search_rank_table_alias}.rank AS pg_search_rank")

# Add inline rank calculation using the injected expression
scope = scope.select("(#{pg_search_rank_expression}) AS pg_search_rank")

# Replace the order clause to include rank ordering
scope = scope.reorder(Arel.sql("pg_search_rank DESC, #{pg_search_order_within_rank}"))

scope
end
end

Expand Down Expand Up @@ -79,17 +159,26 @@ def increment_counter

delegate :connection, :quoted_table_name, to: :model

def subquery
relation = model
.unscoped
def subquery(&block)
# Start with base model
relation = model.unscoped

# If a block is given, wrap the base table in a subselect to force early filtering
# This prevents PostgreSQL from doing full table scans on large tables
if block_given?
filtered_relation = block.call(model.unscoped)
# Use from() to replace the FROM clause with a filtered subquery
relation = relation.from("(#{filtered_relation.to_sql}) AS #{model.table_name}")
end

# Then add selects, joins, and search conditions
relation
.select("#{primary_key} AS pg_search_id")
.select("#{rank} AS rank")
.joins(subquery_join)
.joins(subquery_join(&block))
.where(conditions)
.limit(nil)
.offset(nil)

block_given? ? yield(relation) : relation
end

def conditions
Expand Down Expand Up @@ -128,10 +217,10 @@ def primary_key
"#{quoted_table_name}.#{connection.quote_column_name(model.primary_key)}"
end

def subquery_join
def subquery_join(&block)
if config.associations.any?
config.associations.map do |association|
association.join(primary_key)
association.join(primary_key, &block)
end.join(" ")
end
end
Expand Down Expand Up @@ -176,5 +265,28 @@ def include_table_aliasing_for_rank(scope)
new_scope.instance_eval { extend PgSearchRankTableAliasing }
end
end

def count_where_conditions(scope)
# Handle test mocks and edge cases where where_clause might not be available
return 0 unless scope.respond_to?(:where_clause)

where_clause = scope.where_clause
return 0 if where_clause.nil?

ast = where_clause.ast
return 0 if ast.nil?

case ast
when Arel::Nodes::And
# AND node has multiple conditions
ast.children.count
when Arel::Nodes::Or
# OR node has multiple conditions
ast.children.count
else
# Single condition (Equality, etc.)
1
end
end
end
end
Loading