Skip to content

Commit 9d21d7a

Browse files
mshibuyaclaude
andcommitted
Search by an attribute path instead of a table-qualified string
searchable_columns built "table.column" strings, which is why the Mongoid adapter had to take them apart again in parse_collection_name and guess whether the prefix named a collection or an embedded path -- the clearest case of a field deciding how a query gets built. Return a Criteria::Path wherever the configuration can be read as one, and let each adapter resolve it: ActiveRecord qualifies with the associated table and reads that half back out for the relation's references, Mongoid keeps grouping conditions by collection and still reaches embedded documents by field path. Configurations naming a table or a model class stay strings and are handed through, since their prefix is not an association name: searchable 'teams.name' searchable({leagues: :name}) searchable(League => :name) searchable_columns remains the option adapters read, so overriding it with strings keeps working. Field specs now assert the path, which is what the field owes; what each of them compiles to is pinned in the ActiveRecord adapter spec, where the previous strings are reproduced exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 54c5ef8 commit 9d21d7a

6 files changed

Lines changed: 110 additions & 37 deletions

File tree

lib/rails_admin/abstract_model.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ def sort_expression(order)
111111
order.to_s
112112
end
113113

114+
# Render a search target as the column reference the store understands.
115+
# Overridden where reaching an associated attribute takes more than the
116+
# dotted path itself.
117+
def search_column(target)
118+
target.to_s
119+
end
120+
114121
def to_s
115122
model.to_s
116123
end

lib/rails_admin/adapters/active_record.rb

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,36 @@ def attribute_enum_values(name)
154154
def sort_expression(order)
155155
return order.to_s unless order.is_a?(RailsAdmin::Criteria::Path)
156156

157-
"#{sort_table_name(order)}.#{quote_column_name(order.attribute)}"
157+
"#{quoted_path_table_name(order)}.#{quote_column_name(order.attribute)}"
158+
end
159+
160+
# Unlike sorting, search statements are assembled unquoted, and the table
161+
# half is read back out to build the relation's references.
162+
def search_column(target)
163+
return target.to_s unless target.is_a?(RailsAdmin::Criteria::Path)
164+
165+
"#{path_table_name(target)}.#{target.attribute}"
158166
end
159167

160168
private
161169

162-
# The table the path's attribute lives on. Only a single association hop
170+
# The model the path's attribute lives on. Only a single association hop
163171
# is resolvable, which is all the field configuration can express.
164-
def sort_table_name(path)
165-
return quoted_table_name if path.root?
172+
def path_abstract_model(path)
173+
return self if path.root?
166174

167175
association = associations.detect { |a| a.name == path.associations.first }
168-
raise ArgumentError.new("Unknown association in sort path: #{path}") unless association
176+
raise ArgumentError.new("Unknown association in path: #{path}") unless association
177+
178+
RailsAdmin::AbstractModel.new(association.klass)
179+
end
180+
181+
def path_table_name(path)
182+
path_abstract_model(path).table_name
183+
end
169184

170-
RailsAdmin::AbstractModel.new(association.klass).quoted_table_name
185+
def quoted_path_table_name(path)
186+
path_abstract_model(path).quoted_table_name
171187
end
172188

173189
def primary_key_scope(scope, id)
@@ -202,21 +218,23 @@ def sort_scope(scope, options)
202218
end
203219

204220
class WhereBuilder
205-
def initialize(scope)
221+
def initialize(scope, abstract_model)
206222
@statements = []
207223
@values = []
208224
@tables = []
209225
@scope = scope
226+
@abstract_model = abstract_model
210227
end
211228

212229
def add(field, value, operator)
213230
field.searchable_columns.flatten.each do |column_infos|
214-
statement, value1, value2 = StatementBuilder.new(column_infos[:column], column_infos[:type], value, operator, @scope.connection.adapter_name).to_statement
231+
column = @abstract_model.search_column(column_infos[:column])
232+
statement, value1, value2 = StatementBuilder.new(column, column_infos[:type], value, operator, @scope.connection.adapter_name).to_statement
215233
@statements << statement if statement.present?
216234
@values << value1 unless value1.nil?
217235
@values << value2 unless value2.nil?
218-
table, column = column_infos[:column].split('.')
219-
@tables.push(table) if column
236+
table, qualified = column.split('.')
237+
@tables.push(table) if qualified
220238
end
221239
end
222240

@@ -231,7 +249,7 @@ def query_scope(scope, query, fields = config.list.fields.select(&:queryable?))
231249
if config.list.search_by
232250
scope.send(config.list.search_by, query)
233251
else
234-
wb = WhereBuilder.new(scope)
252+
wb = WhereBuilder.new(scope, self)
235253
fields.each do |field|
236254
value = parse_field_value(field, query)
237255
wb.add(field, value, field.search_operator)
@@ -246,7 +264,7 @@ def query_scope(scope, query, fields = config.list.fields.select(&:queryable?))
246264
def filter_scope(scope, filters, fields = config.list.fields.select(&:filterable?))
247265
filters.each_pair do |field_name, filters_dump|
248266
filters_dump.each_value do |filter_dump|
249-
wb = WhereBuilder.new(scope)
267+
wb = WhereBuilder.new(scope, self)
250268
field = fields.detect { |f| f.name.to_s == field_name }
251269
value = parse_field_value(field, filter_dump[:v])
252270

lib/rails_admin/adapters/mongoid.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,26 @@ def filter_scope(scope, filters, fields = config.list.fields.select(&:filterable
175175
scope.where(statements.any? ? {'$and' => statements} : {})
176176
end
177177

178-
def parse_collection_name(column)
178+
# Where a search target lives, as [collection, field] -- the collection is
179+
# only used to group conditions and to tell apart what can be asked of this
180+
# collection directly from what needs a second lookup.
181+
def parse_collection_name(target)
182+
return parse_collection_name_from_string(target.to_s) unless target.is_a?(RailsAdmin::Criteria::Path)
183+
184+
return [table_name, target.attribute.to_s] if target.root?
185+
186+
association = associations.detect { |a| a.name == target.associations.first }
187+
if association.try(:embeds?)
188+
# Embedded documents are reached by their field path, not by collection.
189+
[table_name, target.to_s]
190+
elsif association
191+
[RailsAdmin::AbstractModel.new(association.klass).table_name, target.attribute.to_s]
192+
else
193+
[target.associations.first.to_s, target.attribute.to_s]
194+
end
195+
end
196+
197+
def parse_collection_name_from_string(column)
179198
collection_name, column_name = column.split('.')
180199
if associations.detect { |a| a.name == collection_name.to_sym }.try(:embeds?)
181200
[table_name, column]

lib/rails_admin/config/fields/base.rb

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,36 +129,39 @@ def filter_options
129129
false
130130
end
131131

132-
# list of columns I should search for that field [{ column: 'table_name.column', type: field.type }, {..}]
132+
# What to search for this field: [{column: <target>, type: <field type>}, ..]
133+
#
134+
# A target is a Criteria::Path wherever the configuration can be read as
135+
# one, so that the adapter decides how to reach the attribute. The
136+
# table-qualified forms cannot be read that way -- their prefix names a
137+
# table, not an association -- so they stay strings and are handed to the
138+
# store as they are. Overriding this option with strings keeps working.
133139
register_instance_option :searchable_columns do
134140
@searchable_columns ||=
135141
case searchable
136142
when true
137-
[{column: "#{abstract_model.table_name}.#{name}", type: type}]
143+
[{column: RailsAdmin::Criteria::Path[name], type: type}]
138144
when false
139145
[]
140146
when :all # valid only for associations
141-
table_name = associated_model_config.abstract_model.table_name
142-
associated_model_config.list.fields.collect { |f| {column: "#{table_name}.#{f.name}", type: f.type} }
147+
associated_model_config.list.fields.collect do |f|
148+
{column: RailsAdmin::Criteria::Path[name, f.name], type: f.type}
149+
end
143150
else
144151
[searchable].flatten.collect do |f|
145-
if f.is_a?(String) && f.include?('.') # table_name.column
146-
table_name, column = f.split '.'
147-
type = nil
148-
elsif f.is_a?(Hash) # <Model|table_name> => <attribute|column>
152+
if f.is_a?(String) && f.include?('.') # table_name.column, passed through
153+
{column: f, type: :string}
154+
elsif f.is_a?(Hash) # <Model|table_name> => <attribute|column>, passed through
149155
am = AbstractModel.new(f.keys.first) if f.keys.first.is_a?(Class)
150156
table_name = am&.table_name || f.keys.first
151-
column = f.values.first
152157
property = am&.properties&.detect { |p| p.name == f.values.first.to_sym }
153-
type = property&.type
154-
else # <attribute|column>
158+
{column: "#{table_name}.#{f.values.first}", type: (property&.type || :string)}
159+
else # <attribute>
155160
am = (association? ? associated_model_config.abstract_model : abstract_model)
156-
table_name = am.table_name
157-
column = f
158161
property = am.properties.detect { |p| p.name == f.to_sym }
159-
type = property&.type
162+
path = association? ? RailsAdmin::Criteria::Path[name, f] : RailsAdmin::Criteria::Path[f]
163+
{column: path, type: (property&.type || :string)}
160164
end
161-
{column: "#{table_name}.#{column}", type: (type || :string)}
162165
end
163166
end
164167
end

spec/rails_admin/adapters/active_record_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,23 @@ class PlayerWithDefaultScope < Player
174174
end
175175
end
176176

177+
describe '#search_column' do
178+
let(:abstract_model) { RailsAdmin::AbstractModel.new('Team') }
179+
180+
# Search statements are assembled unquoted, unlike sort expressions.
181+
it 'qualifies a local attribute with its own table' do
182+
expect(abstract_model.search_column(RailsAdmin::Criteria::Path[:name])).to eq 'teams.name'
183+
end
184+
185+
it 'qualifies an associated attribute with the associated table' do
186+
expect(abstract_model.search_column(RailsAdmin::Criteria::Path[:division, :name])).to eq 'divisions.name'
187+
end
188+
189+
it 'hands table-qualified strings through untouched' do
190+
expect(abstract_model.search_column('leagues.name')).to eq 'leagues.name'
191+
end
192+
end
193+
177194
describe '#query_scope' do
178195
let(:abstract_model) { RailsAdmin::AbstractModel.new('Team') }
179196
let!(:teams) do

spec/rails_admin/config/fields/base_spec.rb

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,15 @@ class CommentReversed < Tableless
348348
end
349349
end
350350

351+
# Targets that can be read as an attribute reached through associations come
352+
# back as a Criteria::Path, leaving the adapter to decide what that means for
353+
# its store. Table-qualified configurations stay strings; see the adapter specs
354+
# for what each of these compiles to.
351355
describe '#searchable_columns' do
352356
describe 'for belongs_to fields' do
353357
it 'finds label method on the opposite side for belongs_to associations by default' do
354-
expect(RailsAdmin.config(Team).fields.detect { |f| f.name == :division }.searchable_columns.collect { |c| c[:column] }).to eq(['divisions.name', 'teams.division_id'])
358+
expect(RailsAdmin.config(Team).fields.detect { |f| f.name == :division }.searchable_columns.collect { |c| c[:column] }).
359+
to eq([RailsAdmin::Criteria::Path[:division, :name], 'teams.division_id'])
355360
end
356361

357362
it 'searches on opposite table for belongs_to' do
@@ -360,7 +365,8 @@ class CommentReversed < Tableless
360365
searchable :custom_id
361366
end
362367
end
363-
expect(RailsAdmin.config(Team).fields.detect { |f| f.name == :division }.searchable_columns.collect { |c| c[:column] }).to eq(['divisions.custom_id'])
368+
expect(RailsAdmin.config(Team).fields.detect { |f| f.name == :division }.searchable_columns.collect { |c| c[:column] }).
369+
to eq([RailsAdmin::Criteria::Path[:division, :custom_id]])
364370
end
365371

366372
it 'searches on asked table with model name' do
@@ -383,9 +389,9 @@ class CommentReversed < Tableless
383389
end
384390

385391
describe 'for basic type fields' do
386-
it 'uses base table and find correct column type' do
387-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :text_field }.searchable_columns).to eq([{column: 'field_tests.text_field', type: :text}])
388-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :integer_field }.searchable_columns).to eq([{column: 'field_tests.integer_field', type: :integer}])
392+
it 'uses the attribute itself and finds the correct column type' do
393+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :text_field }.searchable_columns).to eq([{column: RailsAdmin::Criteria::Path[:text_field], type: :text}])
394+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :integer_field }.searchable_columns).to eq([{column: RailsAdmin::Criteria::Path[:integer_field], type: :integer}])
389395
end
390396

391397
it 'is customizable to another field on the same table' do
@@ -394,7 +400,7 @@ class CommentReversed < Tableless
394400
searchable :date_field
395401
end
396402
end
397-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :time_field }.searchable_columns).to eq([{column: 'field_tests.date_field', type: :date}])
403+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :time_field }.searchable_columns).to eq([{column: RailsAdmin::Criteria::Path[:date_field], type: :date}])
398404
end
399405

400406
it 'is customizable to another field on another table with :table_name' do
@@ -418,15 +424,18 @@ class CommentReversed < Tableless
418424

419425
describe 'for mapped fields' do
420426
it 'of paperclip should find the underlying column on the base table' do
421-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :paperclip_asset }.searchable_columns.collect { |c| c[:column] }).to eq(['field_tests.paperclip_asset_file_name'])
427+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :paperclip_asset }.searchable_columns.collect { |c| c[:column] }).
428+
to eq([RailsAdmin::Criteria::Path[:paperclip_asset_file_name]])
422429
end
423430

424431
it 'of dragonfly should find the underlying column on the base table' do
425-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :dragonfly_asset }.searchable_columns.collect { |c| c[:column] }).to eq(['field_tests.dragonfly_asset_name'])
432+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :dragonfly_asset }.searchable_columns.collect { |c| c[:column] }).
433+
to eq([RailsAdmin::Criteria::Path[:dragonfly_asset_name]])
426434
end
427435

428436
it 'of carrierwave should find the underlying column on the base table' do
429-
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :carrierwave_asset }.searchable_columns.collect { |c| c[:column] }).to eq(['field_tests.carrierwave_asset'])
437+
expect(RailsAdmin.config(FieldTest).fields.detect { |f| f.name == :carrierwave_asset }.searchable_columns.collect { |c| c[:column] }).
438+
to eq([RailsAdmin::Criteria::Path[:carrierwave_asset]])
430439
end
431440
end
432441
end

0 commit comments

Comments
 (0)