scoped_search 2.7.1 → 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +14 -23
- data/CHANGELOG.rdoc +193 -0
- data/CONTRIBUTING.rdoc +38 -0
- data/{Gemfile.activerecord4 → Gemfile.activerecord40} +1 -1
- data/{Gemfile.activerecord2 → Gemfile.activerecord41} +2 -2
- data/README.rdoc +15 -20
- data/app/assets/stylesheets/scoped_search.scss +0 -4
- data/lib/scoped_search.rb +2 -27
- data/lib/scoped_search/auto_complete_builder.rb +40 -34
- data/lib/scoped_search/definition.rb +7 -5
- data/lib/scoped_search/query_builder.rb +3 -34
- data/lib/scoped_search/query_language.rb +0 -3
- data/lib/scoped_search/rails_helper.rb +45 -46
- data/lib/scoped_search/railtie.rb +11 -6
- data/lib/scoped_search/version.rb +1 -1
- data/scoped_search.gemspec +8 -7
- data/spec/integration/api_spec.rb +2 -41
- data/spec/integration/auto_complete_spec.rb +5 -5
- data/spec/integration/key_value_querying_spec.rb +9 -9
- data/spec/integration/ordinal_querying_spec.rb +46 -48
- data/spec/integration/relation_querying_spec.rb +30 -30
- data/spec/integration/set_query_spec.rb +7 -7
- data/spec/integration/string_querying_spec.rb +48 -50
- data/spec/lib/matchers.rb +2 -2
- data/spec/spec_helper.rb +8 -0
- data/spec/unit/ast_spec.rb +3 -3
- metadata +26 -22
- data/init.rb +0 -1
@@ -1,17 +1,16 @@
|
|
1
1
|
module ScopedSearch
|
2
2
|
|
3
|
-
|
4
|
-
LOGICAL_INFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_INFIX_OPERATORS
|
5
|
-
LOGICAL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_PREFIX_OPERATORS
|
6
|
-
NULL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::NULL_PREFIX_OPERATORS
|
7
|
-
NULL_PREFIX_COMPLETER = ['has']
|
8
|
-
COMPARISON_OPERATORS = ScopedSearch::QueryLanguage::Parser::COMPARISON_OPERATORS
|
9
|
-
PREFIX_OPERATORS = LOGICAL_PREFIX_OPERATORS + NULL_PREFIX_OPERATORS
|
10
|
-
|
11
3
|
# The AutoCompleteBuilder class builds suggestions to complete query based on
|
12
4
|
# the query language syntax.
|
13
5
|
class AutoCompleteBuilder
|
14
6
|
|
7
|
+
LOGICAL_INFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_INFIX_OPERATORS
|
8
|
+
LOGICAL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::LOGICAL_PREFIX_OPERATORS
|
9
|
+
NULL_PREFIX_OPERATORS = ScopedSearch::QueryLanguage::Parser::NULL_PREFIX_OPERATORS
|
10
|
+
NULL_PREFIX_COMPLETER = ['has']
|
11
|
+
COMPARISON_OPERATORS = ScopedSearch::QueryLanguage::Parser::COMPARISON_OPERATORS
|
12
|
+
PREFIX_OPERATORS = LOGICAL_PREFIX_OPERATORS + NULL_PREFIX_OPERATORS
|
13
|
+
|
15
14
|
attr_reader :ast, :definition, :query, :tokens
|
16
15
|
|
17
16
|
# This method will parse the query string and build suggestion list using the
|
@@ -174,10 +173,14 @@ module ScopedSearch
|
|
174
173
|
quoted_table = field.key_klass.connection.quote_table_name(field.key_klass.table_name)
|
175
174
|
quoted_field = field.key_klass.connection.quote_column_name(field.key_field)
|
176
175
|
field_name = "#{quoted_table}.#{quoted_field}"
|
177
|
-
select_clause = "DISTINCT #{field_name}"
|
178
|
-
opts = value_conditions(field_name, val).merge(:select => select_clause, :limit => 20)
|
179
176
|
|
180
|
-
field.key_klass
|
177
|
+
field.key_klass
|
178
|
+
.where(value_conditions(field_name, val))
|
179
|
+
.select("DISTINCT #{field_name}")
|
180
|
+
.limit(20)
|
181
|
+
.map(&field.key_field)
|
182
|
+
.compact
|
183
|
+
.map { |f| "#{name}.#{f} " }
|
181
184
|
end
|
182
185
|
|
183
186
|
# this method auto-completes values of fields that have a :complete_value marker
|
@@ -197,10 +200,13 @@ module ScopedSearch
|
|
197
200
|
return complete_date_value if field.temporal?
|
198
201
|
return complete_key_value(field, token, val) if field.key_field
|
199
202
|
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
203
|
+
completer_scope(field)
|
204
|
+
.where(value_conditions(field.quoted_field, val))
|
205
|
+
.select("DISTINCT #{field.quoted_field}")
|
206
|
+
.limit(20)
|
207
|
+
.map(&field.field)
|
208
|
+
.compact
|
209
|
+
.map { |v| v.to_s =~ /\s/ ? "\"#{v}\"" : v }
|
204
210
|
end
|
205
211
|
|
206
212
|
def completer_scope(field)
|
@@ -215,7 +221,7 @@ module ScopedSearch
|
|
215
221
|
end
|
216
222
|
# date value completer
|
217
223
|
def complete_date_value
|
218
|
-
options =[]
|
224
|
+
options = []
|
219
225
|
options << '"30 minutes ago"'
|
220
226
|
options << '"1 hour ago"'
|
221
227
|
options << '"2 hours ago"'
|
@@ -233,34 +239,34 @@ module ScopedSearch
|
|
233
239
|
# complete values in a key-value schema
|
234
240
|
def complete_key_value(field, token, val)
|
235
241
|
key_name = token.sub(/^.*\./,"")
|
236
|
-
|
237
|
-
key_klass = field.key_klass.first(key_opts)
|
242
|
+
key_klass = field.key_klass.where(field.key_field => key_name).first
|
238
243
|
raise ScopedSearch::QueryNotSupported, "Field '#{key_name}' not recognized for searching!" if key_klass.nil?
|
239
244
|
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
opts.merge!(key_opts)
|
245
|
+
query = completer_scope(field)
|
246
|
+
|
247
|
+
if field.key_klass != field.klass
|
248
|
+
key = field.key_klass.to_s.gsub(/.*::/,'').underscore.to_sym
|
249
|
+
fk = field.klass.reflections[key].association_foreign_key.to_sym
|
250
|
+
query = query.where(fk => key_klass.id)
|
247
251
|
end
|
248
|
-
|
252
|
+
|
253
|
+
query
|
254
|
+
.where(value_conditions(field, val))
|
255
|
+
.select("DISTINCT #{field.quoted_field}")
|
256
|
+
.limit(20)
|
257
|
+
.map(&field.field)
|
258
|
+
.compact
|
259
|
+
.map { |v| v.to_s =~ /\s/ ? "\"#{v}\"" : v }
|
249
260
|
end
|
250
261
|
|
251
|
-
#
|
252
|
-
def value_conditions(
|
253
|
-
|
262
|
+
# This method returns conditions for selecting completion from partial value
|
263
|
+
def value_conditions(field, val)
|
264
|
+
val.blank? ? nil : "#{field.quoted_field} LIKE '#{val.gsub("'","''")}%'".tr_s('%*', '%')
|
254
265
|
end
|
255
266
|
|
256
267
|
# This method complete infix operators by field type
|
257
268
|
def complete_operator(node)
|
258
269
|
definition.operator_by_field_name(node.value)
|
259
270
|
end
|
260
|
-
|
261
271
|
end
|
262
|
-
|
263
272
|
end
|
264
|
-
|
265
|
-
# Load lib files
|
266
|
-
require 'scoped_search/query_builder'
|
@@ -84,7 +84,11 @@ module ScopedSearch
|
|
84
84
|
if klass.columns_hash.has_key?(field.to_s)
|
85
85
|
klass.columns_hash[field.to_s]
|
86
86
|
else
|
87
|
-
|
87
|
+
if "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}".to_f < 4.1
|
88
|
+
raise ActiveRecord::UnknownAttributeError, "#{klass.inspect} doesn't have column #{field.inspect}."
|
89
|
+
else
|
90
|
+
raise ActiveRecord::UnknownAttributeError.new( klass, field )
|
91
|
+
end
|
88
92
|
end
|
89
93
|
end
|
90
94
|
end
|
@@ -243,8 +247,6 @@ module ScopedSearch
|
|
243
247
|
definition = self
|
244
248
|
if @klass.ancestors.include?(ActiveRecord::Base)
|
245
249
|
case ActiveRecord::VERSION::MAJOR
|
246
|
-
when 2
|
247
|
-
@klass.named_scope(:search_for, lambda { |*args| ScopedSearch::QueryBuilder.build_query(definition, args[0], args[1]) })
|
248
250
|
when 3
|
249
251
|
@klass.scope(:search_for, lambda { |*args|
|
250
252
|
find_options = ScopedSearch::QueryBuilder.build_query(definition, args[0], args[1])
|
@@ -258,7 +260,7 @@ module ScopedSearch
|
|
258
260
|
when 4
|
259
261
|
@klass.scope(:search_for, lambda { |*args|
|
260
262
|
find_options = ScopedSearch::QueryBuilder.build_query(definition, args[0], args[1])
|
261
|
-
search_scope = @klass
|
263
|
+
search_scope = @klass
|
262
264
|
search_scope = search_scope.where(find_options[:conditions]) if find_options[:conditions]
|
263
265
|
search_scope = search_scope.includes(find_options[:include]) if find_options[:include]
|
264
266
|
search_scope = search_scope.references(find_options[:include]) if find_options[:include]
|
@@ -270,7 +272,7 @@ module ScopedSearch
|
|
270
272
|
raise "This ActiveRecord version is currently not supported!"
|
271
273
|
end
|
272
274
|
else
|
273
|
-
raise "Currently, only ActiveRecord
|
275
|
+
raise "Currently, only ActiveRecord 3 or newer is supported!"
|
274
276
|
end
|
275
277
|
end
|
276
278
|
|
@@ -479,20 +479,6 @@ module ScopedSearch
|
|
479
479
|
# The MysqlAdapter makes sure that case sensitive comparisons are used
|
480
480
|
# when using the (not) equals operator, regardless of the field's
|
481
481
|
# collation setting.
|
482
|
-
class MysqlAdapter < ScopedSearch::QueryBuilder
|
483
|
-
|
484
|
-
# Patches the default <tt>sql_operator</tt> method to add
|
485
|
-
# <tt>BINARY</tt> after the equals and not equals operator to force
|
486
|
-
# case-sensitive comparisons.
|
487
|
-
def sql_operator(operator, field)
|
488
|
-
if [:ne, :eq].include?(operator) && field.textual?
|
489
|
-
"#{SQL_OPERATORS[operator]} BINARY"
|
490
|
-
else
|
491
|
-
super(operator, field)
|
492
|
-
end
|
493
|
-
end
|
494
|
-
end
|
495
|
-
|
496
482
|
class Mysql2Adapter < ScopedSearch::QueryBuilder
|
497
483
|
# Patches the default <tt>sql_operator</tt> method to add
|
498
484
|
# <tt>BINARY</tt> after the equals and not equals operator to force
|
@@ -506,6 +492,8 @@ module ScopedSearch
|
|
506
492
|
end
|
507
493
|
end
|
508
494
|
|
495
|
+
MysqlAdapter = Mysql2Adapter
|
496
|
+
|
509
497
|
# The PostgreSQLAdapter make sure that searches are case sensitive when
|
510
498
|
# using the like/unlike operators, by using the PostrgeSQL-specific
|
511
499
|
# <tt>ILIKE operator</tt> instead of <tt>LIKE</tt>.
|
@@ -525,7 +513,7 @@ module ScopedSearch
|
|
525
513
|
end
|
526
514
|
end
|
527
515
|
|
528
|
-
# Switches out the default LIKE operator in the default <tt>sql_operator</tt>
|
516
|
+
# Switches out the default LIKE operator in the default <tt>sql_operator</tt>
|
529
517
|
# method for ILIKE or @@ if full text searching is enabled.
|
530
518
|
def sql_operator(operator, field)
|
531
519
|
raise ScopedSearch::QueryNotSupported, "the operator '#{operator}' is not supported for field type '#{field.type}'" if [:like, :unlike].include?(operator) and !field.textual?
|
@@ -548,25 +536,6 @@ module ScopedSearch
|
|
548
536
|
sql
|
549
537
|
end
|
550
538
|
end
|
551
|
-
|
552
|
-
# The Oracle adapter also requires some tweaks to make the case insensitive LIKE work.
|
553
|
-
class OracleEnhancedAdapter < ScopedSearch::QueryBuilder
|
554
|
-
|
555
|
-
def sql_test(field, operator, value, lhs, &block) # :yields: finder_option_type, value
|
556
|
-
if field.key_field
|
557
|
-
yield(:parameter, lhs.sub(/^.*\./,''))
|
558
|
-
end
|
559
|
-
if field.textual? && [:like, :unlike].include?(operator)
|
560
|
-
yield(:parameter, (value !~ /^\%|\*/ && value !~ /\%|\*$/) ? "%#{value}%" : value.to_s.tr_s('%*', '%'))
|
561
|
-
return "LOWER(#{field.to_sql(operator, &block)}) #{self.sql_operator(operator, field)} LOWER(?)"
|
562
|
-
elsif field.temporal?
|
563
|
-
return datetime_test(field, operator, value, &block)
|
564
|
-
else
|
565
|
-
yield(:parameter, value)
|
566
|
-
return "#{field.to_sql(operator, &block)} #{self.sql_operator(operator, field)} ?"
|
567
|
-
end
|
568
|
-
end
|
569
|
-
end
|
570
539
|
end
|
571
540
|
|
572
541
|
# Include the modules into the corresponding classes
|
@@ -1,4 +1,3 @@
|
|
1
|
-
|
2
1
|
module ScopedSearch
|
3
2
|
module RailsHelper
|
4
3
|
# Creates a link that alternates between ascending and descending.
|
@@ -36,7 +35,7 @@ module ScopedSearch
|
|
36
35
|
|
37
36
|
if selected
|
38
37
|
css_classes = html_options[:class] ? html_options[:class].split(" ") : []
|
39
|
-
if new_sort == ascend
|
38
|
+
if new_sort == ascend
|
40
39
|
options[:as] = "▲ #{options[:as]}"
|
41
40
|
css_classes << "ascending"
|
42
41
|
else
|
@@ -124,52 +123,51 @@ module ScopedSearch
|
|
124
123
|
end
|
125
124
|
|
126
125
|
def auto_complete_field_jquery(method, url, options = {})
|
127
|
-
function = <<-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
} else {
|
145
|
-
if(typeof(self._renderItemData) === "function") {
|
146
|
-
self._renderItemData( ul, item );
|
126
|
+
function = <<-JAVASCRIPT
|
127
|
+
$.widget( "custom.catcomplete", $.ui.autocomplete, {
|
128
|
+
_renderMenu: function( ul, items ) {
|
129
|
+
var self = this,
|
130
|
+
currentCategory = "";
|
131
|
+
$.each( items, function( index, item ) {
|
132
|
+
if ( item.category != undefined && item.category != currentCategory ) {
|
133
|
+
ul.append( "<li class='ui-autocomplete-category'>" + item.category + "</li>" );
|
134
|
+
currentCategory = item.category;
|
135
|
+
}
|
136
|
+
if ( item.error != undefined ) {
|
137
|
+
ul.append( "<li class='ui-autocomplete-error'>" + item.error + "</li>" );
|
138
|
+
}
|
139
|
+
if( item.completed != undefined ) {
|
140
|
+
$( "<li></li>" ).data( "item.autocomplete", item )
|
141
|
+
.append( "<a>" + "<strong class='ui-autocomplete-completed'>" + item.completed + "</strong>" + item.part + "</a>" )
|
142
|
+
.appendTo( ul );
|
147
143
|
} else {
|
148
|
-
self.
|
144
|
+
if(typeof(self._renderItemData) === "function") {
|
145
|
+
self._renderItemData( ul, item );
|
146
|
+
} else {
|
147
|
+
self._renderItem( ul, item );
|
148
|
+
}
|
149
149
|
}
|
150
|
-
}
|
151
|
-
}
|
152
|
-
}
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
EOF
|
172
|
-
|
150
|
+
});
|
151
|
+
}
|
152
|
+
});
|
153
|
+
|
154
|
+
$("##{method}")
|
155
|
+
.catcomplete({
|
156
|
+
source: function( request, response ) { $.getJSON( "#{url}", { #{method}: request.term }, response ); },
|
157
|
+
minLength: #{options[:min_length] || 0},
|
158
|
+
delay: #{options[:delay] || 200},
|
159
|
+
select: function(event, ui) { $( this ).catcomplete( "search" , ui.item.value); },
|
160
|
+
search: function(event, ui) { $(".auto_complete_clear").hide(); },
|
161
|
+
open: function(event, ui) { $(".auto_complete_clear").show(); }
|
162
|
+
});
|
163
|
+
|
164
|
+
$("##{method}").bind( "focus", function( event ) {
|
165
|
+
if( $( this )[0].value == "" ) {
|
166
|
+
$( this ).catcomplete( "search" );
|
167
|
+
}
|
168
|
+
});
|
169
|
+
|
170
|
+
JAVASCRIPT
|
173
171
|
|
174
172
|
javascript_tag(function)
|
175
173
|
end
|
@@ -218,6 +216,7 @@ module ScopedSearch
|
|
218
216
|
text_field_tag(method, val, options) + auto_complete_clear_value_button(method) +
|
219
217
|
auto_complete_field_jquery(method, url, completion_options)
|
220
218
|
end
|
219
|
+
deprecate :auto_complete_field_tag_jquery, :auto_complete_field_tag, :auto_complete_result
|
221
220
|
|
222
221
|
end
|
223
222
|
end
|
@@ -1,8 +1,13 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
require 'scoped_search/engine'
|
2
|
+
|
3
|
+
module ScopedSearch
|
4
|
+
class Railtie < ::Rails::Railtie
|
5
5
|
|
6
|
-
|
7
|
-
|
6
|
+
initializer "scoped_search.setup_rails_helper" do |app|
|
7
|
+
ActiveSupport.on_load :action_controller do
|
8
|
+
require "scoped_search/rails_helper"
|
9
|
+
ActionController::Base.helper(ScopedSearch::RailsHelper)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
8
13
|
end
|
data/scoped_search.gemspec
CHANGED
@@ -12,26 +12,27 @@ Gem::Specification.new do |gem|
|
|
12
12
|
gem.summary = %q{Easily search you ActiveRecord models with a simple query language using a named scope}
|
13
13
|
gem.description = <<-EOS
|
14
14
|
Scoped search makes it easy to search your ActiveRecord-based models.
|
15
|
-
|
15
|
+
|
16
16
|
It will create a named scope :search_for that can be called with a query string. It will build an SQL query using
|
17
17
|
the provided query string and a definition that specifies on what fields to search. Because the functionality is
|
18
18
|
built on named_scope, the result of the search_for call can be used like any other named_scope, so it can be
|
19
19
|
chained with another scope or combined with will_paginate.
|
20
|
-
|
20
|
+
|
21
21
|
Because it uses standard SQL, it does not require any setup, indexers or daemons. This makes scoped_search
|
22
22
|
suitable to quickly add basic search functionality to your application with little hassle. On the other hand,
|
23
23
|
it may not be the best choice if it is going to be used on very large datasets or by a large user base.
|
24
24
|
EOS
|
25
|
-
|
25
|
+
|
26
26
|
gem.files = `git ls-files`.split($/)
|
27
27
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
28
28
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
29
29
|
gem.require_paths = ["lib"]
|
30
30
|
|
31
|
-
gem.
|
32
|
-
gem.
|
33
|
-
gem.add_development_dependency('
|
31
|
+
gem.required_ruby_version = '>= 1.9.3'
|
32
|
+
gem.add_runtime_dependency('activerecord', '>= 3.0.0')
|
33
|
+
gem.add_development_dependency('rspec', '~> 3.0')
|
34
|
+
gem.add_development_dependency('rake')
|
34
35
|
|
35
36
|
gem.rdoc_options << '--title' << gem.name << '--main' << 'README.rdoc' << '--line-numbers' << '--inline-source'
|
36
|
-
gem.extra_rdoc_files = ['README.rdoc']
|
37
|
+
gem.extra_rdoc_files = ['README.rdoc', 'CHANGELOG.rdoc', 'CONTRIBUTING.rdoc', 'LICENSE']
|
37
38
|
end
|
@@ -44,54 +44,15 @@ describe ScopedSearch, "API" do
|
|
44
44
|
@class.should respond_to(:search_for)
|
45
45
|
end
|
46
46
|
|
47
|
-
if ActiveRecord::VERSION::MAJOR ==
|
48
|
-
|
49
|
-
it "should return a ActiveRecord::NamedScope::Scope instance" do
|
50
|
-
@class.search_for('query').class.should eql(ActiveRecord::NamedScope::Scope)
|
51
|
-
end
|
52
|
-
|
53
|
-
elsif ActiveRecord::VERSION::MAJOR == 3
|
54
|
-
|
47
|
+
if ActiveRecord::VERSION::MAJOR == 3
|
55
48
|
it "should return a ActiveRecord::Relation instance" do
|
56
49
|
@class.search_for('query').class.should eql(ActiveRecord::Relation)
|
57
50
|
end
|
58
|
-
|
51
|
+
|
59
52
|
elsif ActiveRecord::VERSION::MAJOR == 4
|
60
|
-
|
61
53
|
it "should return a ActiveRecord::Relation instance" do
|
62
54
|
@class.search_for('query').class.superclass.should eql(ActiveRecord::Relation)
|
63
55
|
end
|
64
|
-
|
65
56
|
end
|
66
57
|
end
|
67
|
-
|
68
|
-
context 'having backwards compatibility' do
|
69
|
-
|
70
|
-
before(:each) do
|
71
|
-
class ::Foo < ActiveRecord::Base
|
72
|
-
belongs_to :bar
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
after(:each) do
|
77
|
-
Object.send :remove_const, :Foo
|
78
|
-
end
|
79
|
-
|
80
|
-
it "should respond to :searchable_on" do
|
81
|
-
Foo.should respond_to(:searchable_on)
|
82
|
-
end
|
83
|
-
|
84
|
-
it "should create a Field instance for every argument" do
|
85
|
-
ScopedSearch::Definition::Field.should_receive(:new).exactly(3).times
|
86
|
-
Foo.searchable_on(:field_1, :field_2, :field_3)
|
87
|
-
end
|
88
|
-
|
89
|
-
it "should create a Field with a valid relation when using the underscore notation" do
|
90
|
-
ScopedSearch::Definition::Field.should_receive(:new).with(
|
91
|
-
instance_of(ScopedSearch::Definition), hash_including(:in => :bar, :on => :related_field))
|
92
|
-
Foo.searchable_on(:bar_related_field)
|
93
|
-
end
|
94
|
-
|
95
|
-
end
|
96
|
-
|
97
58
|
end
|