rokaki 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/spec.yml +1 -1
- data/CHANGELOG.md +17 -0
- data/Gemfile.lock +1 -1
- data/README.legacy.md +533 -0
- data/README.md +25 -444
- data/docs/adapters.md +35 -1
- data/docs/configuration.md +22 -1
- data/docs/index.md +10 -6
- data/docs/usage.md +126 -5
- data/lib/rokaki/filter_model/nested_like_filters.rb +21 -16
- data/lib/rokaki/filter_model.rb +114 -12
- data/lib/rokaki/version.rb +1 -1
- data/rokaki.gemspec +1 -1
- metadata +3 -2
data/docs/usage.md
CHANGED
|
@@ -11,7 +11,7 @@ This page shows how to use Rokaki to define filters and apply them to ActiveReco
|
|
|
11
11
|
Add the gem to your Gemfile and bundle:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
|
-
gem "rokaki", "~> 0.
|
|
14
|
+
gem "rokaki", "~> 0.15"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```bash
|
|
@@ -31,8 +31,9 @@ class ArticleQuery
|
|
|
31
31
|
include Rokaki::FilterModel
|
|
32
32
|
belongs_to :author
|
|
33
33
|
|
|
34
|
-
# Choose model
|
|
35
|
-
|
|
34
|
+
# Choose model; adapter is auto-detected from the model's connection.
|
|
35
|
+
# If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
|
|
36
|
+
filter_model :article
|
|
36
37
|
|
|
37
38
|
# Map a single query key (:q) to multiple LIKE targets
|
|
38
39
|
define_query_key :q
|
|
@@ -112,8 +113,9 @@ Rokaki also supports a block-form DSL that is equivalent to the argument-based f
|
|
|
112
113
|
class ArticleQuery
|
|
113
114
|
include Rokaki::FilterModel
|
|
114
115
|
|
|
115
|
-
# Choose model
|
|
116
|
-
|
|
116
|
+
# Choose model; adapter is auto-detected from the model's connection.
|
|
117
|
+
# If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
|
|
118
|
+
filter_model :article
|
|
117
119
|
|
|
118
120
|
# Declare a single query key used by all LIKE/equality filters below
|
|
119
121
|
define_query_key :q
|
|
@@ -186,3 +188,122 @@ f.__author__location__city # => 'London'
|
|
|
186
188
|
Tips:
|
|
187
189
|
- `filter_key_prefix` and `filter_key_infix` control the generated accessor names.
|
|
188
190
|
- Inside the block, `nested :association` affects all `filters` declared within it.
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
## Backend auto-detection
|
|
194
|
+
|
|
195
|
+
By default, Rokaki auto-detects which database adapter to use from your model’s ActiveRecord connection. This means you usually don’t need to pass `db:` explicitly.
|
|
196
|
+
|
|
197
|
+
- Single-adapter apps: No configuration needed — Rokaki infers the adapter from the model connection.
|
|
198
|
+
- Multi-adapter apps: If more than one adapter is detected in the process, Rokaki raises a clear error asking you to declare which backend to use.
|
|
199
|
+
- Explicit override: You can always specify `db:` on `filter_model` or call `filter_db` later.
|
|
200
|
+
|
|
201
|
+
Examples:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class ArticleQuery
|
|
205
|
+
include Rokaki::FilterModel
|
|
206
|
+
|
|
207
|
+
# Adapter auto-detected (recommended default)
|
|
208
|
+
filter_model :article
|
|
209
|
+
define_query_key :q
|
|
210
|
+
|
|
211
|
+
filter_map do
|
|
212
|
+
like title: :circumfix
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Explicit selection/override:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
class ArticleQuery
|
|
221
|
+
include Rokaki::FilterModel
|
|
222
|
+
|
|
223
|
+
# Option A: choose upfront
|
|
224
|
+
filter_model :article, db: :postgres
|
|
225
|
+
|
|
226
|
+
# Option B: or set it later
|
|
227
|
+
# filter_model :article
|
|
228
|
+
# filter_db :sqlite
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Ambiguity behavior (apps with multiple adapters):
|
|
233
|
+
|
|
234
|
+
- If Rokaki sees multiple adapters in use and you haven’t specified one, it raises:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
Rokaki::Error: Multiple database adapters detected (...). Please declare which backend to use via db: or filter_db.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- If it cannot detect any adapter at all, it raises:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
Rokaki::Error: Unable to auto-detect database adapter. Ensure your model is connected or pass db: explicitly.
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Dynamic runtime listener (no code changes needed)
|
|
247
|
+
|
|
248
|
+
You can construct a Rokaki filter class at runtime from a payload (e.g., JSON → Hash) and use it immediately — no prior class is required. Rokaki will compile the tiny class on the fly and generate the methods once.
|
|
249
|
+
|
|
250
|
+
### FilterModel example
|
|
251
|
+
```ruby
|
|
252
|
+
# Example payload (e.g., parsed JSON)
|
|
253
|
+
payload = {
|
|
254
|
+
model: :article,
|
|
255
|
+
db: :postgres, # optional; or :mysql, :sqlserver, :oracle, :sqlite
|
|
256
|
+
query_key: :q, # the key in params with search term(s)
|
|
257
|
+
like: { # like mappings (deeply nested allowed)
|
|
258
|
+
title: :circumfix,
|
|
259
|
+
author: { first_name: :prefix }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# Build an anonymous class at runtime and use it immediately
|
|
264
|
+
listener = Class.new do
|
|
265
|
+
include Rokaki::FilterModel
|
|
266
|
+
|
|
267
|
+
filter_model payload[:model], db: payload[:db]
|
|
268
|
+
define_query_key payload[:query_key]
|
|
269
|
+
|
|
270
|
+
filter_map do
|
|
271
|
+
like payload[:like]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
attr_accessor :filters
|
|
275
|
+
def initialize(filters: {})
|
|
276
|
+
@filters = filters
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
results = listener.new(filters: { q: ["Ada", "Turing"] }).results
|
|
281
|
+
# => ActiveRecord::Relation
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Filterable example (no SQL)
|
|
285
|
+
```ruby
|
|
286
|
+
mapper = Class.new do
|
|
287
|
+
include Rokaki::Filterable
|
|
288
|
+
filter_key_prefix :__
|
|
289
|
+
|
|
290
|
+
filter_map do
|
|
291
|
+
filters :date, author: [:first_name, :last_name]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
attr_reader :filters
|
|
295
|
+
def initialize(filters: {})
|
|
296
|
+
@filters = filters
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
m = mapper.new(filters: { date: '2025-01-01', author: { first_name: 'Ada', last_name: 'Lovelace' } })
|
|
301
|
+
m.__date # => '2025-01-01'
|
|
302
|
+
m.__author__first_name # => 'Ada'
|
|
303
|
+
m.__author__last_name # => 'Lovelace'
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Notes:
|
|
307
|
+
- This approach is production‑ready and requires no core changes to Rokaki.
|
|
308
|
+
- You can cache the generated class by a digest of the payload to avoid recompiling.
|
|
309
|
+
- For maximum safety, validate/allow‑list models/columns coming from untrusted payloads.
|
|
@@ -222,12 +222,25 @@ module Rokaki
|
|
|
222
222
|
leaf: leaf
|
|
223
223
|
)
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
# Compose filter_query based on adapter; for generic adapters use generic_like to support array values
|
|
226
|
+
if db == :postgres || db == :mysql
|
|
227
|
+
if join_map.empty?
|
|
228
|
+
filter_query = "@model.#{query}"
|
|
229
|
+
elsif join_map.is_a?(Array)
|
|
230
|
+
filter_query = "@model.joins(*#{join_map}).#{query}"
|
|
231
|
+
else
|
|
232
|
+
filter_query = "@model.joins(**#{join_map}).#{query}"
|
|
233
|
+
end
|
|
229
234
|
else
|
|
230
|
-
|
|
235
|
+
# Generic (e.g., SQLite)
|
|
236
|
+
if join_map.empty?
|
|
237
|
+
rel_expr = "@model"
|
|
238
|
+
elsif join_map.is_a?(Array)
|
|
239
|
+
rel_expr = "@model.joins(*#{join_map})"
|
|
240
|
+
else
|
|
241
|
+
rel_expr = "@model.joins(**#{join_map})"
|
|
242
|
+
end
|
|
243
|
+
filter_query = "generic_like(#{rel_expr}, \"#{key_leaf}\", \"#{type.to_s.upcase}\", #{filter_name}, :#{search_mode})"
|
|
231
244
|
end
|
|
232
245
|
end
|
|
233
246
|
|
|
@@ -247,17 +260,9 @@ module Rokaki
|
|
|
247
260
|
elsif db == :mysql
|
|
248
261
|
query = "where(\"#{key_leaf} #{type.to_s.upcase} :query\", "
|
|
249
262
|
query += "query: prepare_regex_terms(#{filter}, :#{search_mode}))"
|
|
250
|
-
else # :
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
query += "query: \"%\#{#{filter}}%\")"
|
|
254
|
-
elsif search_mode == :prefix
|
|
255
|
-
query += "query: \"%\#{#{filter}}\")"
|
|
256
|
-
elsif search_mode == :suffix
|
|
257
|
-
query += "query: \"\#{#{filter}}%\")"
|
|
258
|
-
else
|
|
259
|
-
query += "query: \"%\#{#{filter}}%\")"
|
|
260
|
-
end
|
|
263
|
+
else # generic adapters (e.g., SQLite): delegate to generic_like to support array values via OR
|
|
264
|
+
# We return a marker here; the caller (build_query) will assemble the full expression with joins
|
|
265
|
+
query = nil
|
|
261
266
|
end
|
|
262
267
|
|
|
263
268
|
query
|
data/lib/rokaki/filter_model.rb
CHANGED
|
@@ -90,6 +90,24 @@ module Rokaki
|
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Compose a generic LIKE relation supporting arrays of terms (OR chained)
|
|
94
|
+
# Used for adapters without special handling (e.g., SQLite)
|
|
95
|
+
def generic_like(model, column, type, value, mode)
|
|
96
|
+
terms = prepare_terms(value, mode)
|
|
97
|
+
return model.none if terms.nil?
|
|
98
|
+
if terms.is_a?(Array)
|
|
99
|
+
return model.none if terms.empty?
|
|
100
|
+
rel = model.where("#{column} #{type} :q0", q0: terms[0])
|
|
101
|
+
terms[1..-1]&.each_with_index do |t, i|
|
|
102
|
+
rel = rel.or(model.where("#{column} #{type} :q#{i + 1}", "q#{i + 1}".to_sym => t))
|
|
103
|
+
end
|
|
104
|
+
rel
|
|
105
|
+
else
|
|
106
|
+
# prepare_terms returns arrays for scalar input, so this branch is rarely used
|
|
107
|
+
model.where("#{column} #{type} :q", q: terms)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
93
111
|
def prepare_regex_terms(param, mode)
|
|
94
112
|
if Array === param
|
|
95
113
|
param_map = param.map { |term| ".*#{term}.*" } if mode == :circumfix
|
|
@@ -141,6 +159,89 @@ module Rokaki
|
|
|
141
159
|
end
|
|
142
160
|
end
|
|
143
161
|
|
|
162
|
+
# Map AR adapter names to internal symbols
|
|
163
|
+
def map_adapter_name(name)
|
|
164
|
+
n = name.to_s.downcase
|
|
165
|
+
case n
|
|
166
|
+
when 'postgresql', 'postgres', 'postgis'
|
|
167
|
+
:postgres
|
|
168
|
+
when 'mysql2', 'mysql'
|
|
169
|
+
:mysql
|
|
170
|
+
when 'sqlite3', 'sqlite'
|
|
171
|
+
:sqlite
|
|
172
|
+
when 'sqlserver'
|
|
173
|
+
:sqlserver
|
|
174
|
+
when 'oracle_enhanced', 'oracle'
|
|
175
|
+
:oracle
|
|
176
|
+
else
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Try to detect adapter from a model's connection
|
|
182
|
+
def detect_adapter_from_model(model)
|
|
183
|
+
return nil unless model
|
|
184
|
+
begin
|
|
185
|
+
adapter = model.connection_db_config&.adapter
|
|
186
|
+
return map_adapter_name(adapter) if adapter
|
|
187
|
+
rescue StandardError
|
|
188
|
+
# fall through
|
|
189
|
+
end
|
|
190
|
+
begin
|
|
191
|
+
adapter = model.connection&.adapter_name
|
|
192
|
+
return map_adapter_name(adapter)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Scan known AR models to see how many adapters are in use
|
|
199
|
+
def adapters_in_use
|
|
200
|
+
adapters = []
|
|
201
|
+
begin
|
|
202
|
+
bases = [::ActiveRecord::Base] + (::ActiveRecord::Base.descendants rescue [])
|
|
203
|
+
bases.uniq.each do |k|
|
|
204
|
+
next unless k.respond_to?(:connection_db_config)
|
|
205
|
+
begin
|
|
206
|
+
a = k.connection_db_config&.adapter
|
|
207
|
+
adapters << a if a
|
|
208
|
+
rescue StandardError
|
|
209
|
+
# ignore not connected models
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError
|
|
213
|
+
# ignore
|
|
214
|
+
end
|
|
215
|
+
adapters.compact.map { |a| map_adapter_name(a) }.compact.uniq
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Determine @_filter_db or raise if ambiguous in multi-adapter apps
|
|
219
|
+
def resolve_filter_db!(model: @model, explicit: nil)
|
|
220
|
+
if explicit
|
|
221
|
+
@_filter_db = explicit
|
|
222
|
+
return @_filter_db
|
|
223
|
+
end
|
|
224
|
+
# Prefer model-specific detection
|
|
225
|
+
detected = detect_adapter_from_model(model)
|
|
226
|
+
return (@_filter_db = detected) if detected
|
|
227
|
+
|
|
228
|
+
# Fallback to a single global adapter if unambiguous
|
|
229
|
+
used = adapters_in_use
|
|
230
|
+
if used.size == 1
|
|
231
|
+
@_filter_db = used.first
|
|
232
|
+
elsif used.size > 1
|
|
233
|
+
raise ::Rokaki::Error, "Multiple database adapters detected (#{used.join(', ')}). Please declare which backend to use via db: or filter_db."
|
|
234
|
+
else
|
|
235
|
+
# As a last resort, try ActiveRecord::Base connection
|
|
236
|
+
begin
|
|
237
|
+
base_detected = map_adapter_name(::ActiveRecord::Base.connection_db_config&.adapter)
|
|
238
|
+
return (@_filter_db = base_detected) if base_detected
|
|
239
|
+
rescue StandardError
|
|
240
|
+
end
|
|
241
|
+
raise ::Rokaki::Error, "Unable to auto-detect database adapter. Ensure your model is connected or pass db: explicitly."
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
144
245
|
# Merge two nested like/ilike mappings
|
|
145
246
|
def deep_merge_like(a, b)
|
|
146
247
|
return b if a.nil? || a == {}
|
|
@@ -178,7 +279,7 @@ module Rokaki
|
|
|
178
279
|
if block_given? && args.empty?
|
|
179
280
|
raise ArgumentError, 'define_query_key must be called before block filter_map' unless @filter_map_query_key
|
|
180
281
|
raise ArgumentError, 'filter_model must be called before block filter_map' unless @model
|
|
181
|
-
@
|
|
282
|
+
resolve_filter_db!(model: @model)
|
|
182
283
|
|
|
183
284
|
# Enter block-collection mode
|
|
184
285
|
@__in_filter_map_block = true
|
|
@@ -210,22 +311,22 @@ module Rokaki
|
|
|
210
311
|
filter_model(model)
|
|
211
312
|
@filter_map_query_key = query_key
|
|
212
313
|
|
|
213
|
-
@
|
|
214
|
-
@_filter_mode = options[:mode] || :and
|
|
215
|
-
like(options[:like]) if options[:like]
|
|
216
|
-
ilike(options[:ilike]) if options[:ilike]
|
|
217
|
-
filters(*options[:match]) if options[:match]
|
|
314
|
+
resolve_filter_db!(model: @model, explicit: options && options[:db])
|
|
315
|
+
@_filter_mode = (options && options[:mode]) || :and
|
|
316
|
+
like(options[:like]) if options && options[:like]
|
|
317
|
+
ilike(options[:ilike]) if options && options[:ilike]
|
|
318
|
+
filters(*options[:match]) if options && options[:match]
|
|
218
319
|
end
|
|
219
320
|
|
|
220
321
|
def filter(model, options)
|
|
221
322
|
filter_model(model)
|
|
222
323
|
@filter_map_query_key = nil
|
|
223
324
|
|
|
224
|
-
@
|
|
225
|
-
@_filter_mode = options[:mode] || :and
|
|
226
|
-
like(options[:like]) if options[:like]
|
|
227
|
-
ilike(options[:ilike]) if options[:ilike]
|
|
228
|
-
filters(*options[:match]) if options[:match]
|
|
325
|
+
resolve_filter_db!(model: @model, explicit: options && options[:db])
|
|
326
|
+
@_filter_mode = (options && options[:mode]) || :and
|
|
327
|
+
like(options[:like]) if options && options[:like]
|
|
328
|
+
ilike(options[:ilike]) if options && options[:ilike]
|
|
329
|
+
filters(*options[:match]) if options && options[:match]
|
|
229
330
|
end
|
|
230
331
|
|
|
231
332
|
def filters(*filter_keys)
|
|
@@ -335,9 +436,10 @@ module Rokaki
|
|
|
335
436
|
end
|
|
336
437
|
|
|
337
438
|
def filter_model(model_class, db: nil)
|
|
338
|
-
@_filter_db = db if db
|
|
339
439
|
@model = (model_class.is_a?(Class) ? model_class : Object.const_get(model_class.capitalize))
|
|
340
440
|
class_eval "def set_model; @model ||= #{@model}; end;"
|
|
441
|
+
# Only resolve here if an explicit db is provided; otherwise defer to callers
|
|
442
|
+
resolve_filter_db!(model: @model, explicit: db) if db
|
|
341
443
|
end
|
|
342
444
|
|
|
343
445
|
def case_sensitive
|
data/lib/rokaki/version.rb
CHANGED
data/rokaki.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rokaki
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Steve Martin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-10-
|
|
11
|
+
date: 2025-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -287,6 +287,7 @@ files:
|
|
|
287
287
|
- Gemfile.lock
|
|
288
288
|
- Guardfile
|
|
289
289
|
- LICENSE.txt
|
|
290
|
+
- README.legacy.md
|
|
290
291
|
- README.md
|
|
291
292
|
- Rakefile
|
|
292
293
|
- bin/console
|