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.
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.13"
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 and adapter
35
- filter_model :article, db: :postgres # or :mysql, :sqlserver
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 and adapter
116
- filter_model :article, db: :postgres # or :mysql, :sqlserver
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
- if join_map.empty?
226
- filter_query = "@model.#{query}"
227
- elsif join_map.is_a?(Array)
228
- filter_query = "@model.joins(*#{join_map}).#{query}"
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
- filter_query = "@model.joins(**#{join_map}).#{query}"
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 # :sqlserver and others
251
- query = "where(\"#{key_leaf} #{type.to_s.upcase} :query\", "
252
- if search_mode == :circumfix
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
@@ -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
- @_filter_db ||= :postgres
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
- @_filter_db = options[:db] || :postgres
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
- @_filter_db = options[:db] || :postgres
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
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.14.0"
2
+ VERSION = "0.16.0"
3
3
  end
data/rokaki.gemspec CHANGED
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  lib = File.expand_path('lib', __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
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.14.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-25 00:00:00.000000000 Z
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