rokaki 0.15.0 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e754206bd6927db93fa0977aed409762b9bc9aa2a663c5b0d94977c2d18ba284
4
- data.tar.gz: 9df4b1d7d067be43ac1df89f1b5962b2950b91e6bc5e915817cde2ef625f55f0
3
+ metadata.gz: 52ffc6e9a926824c6730632c566d42570997aa5e2a2674ea07e79d0722a628fc
4
+ data.tar.gz: 248ecf8294fe48488f0b8a8990f1602ab7cd3c10af95731be2a8e3f0933905c8
5
5
  SHA512:
6
- metadata.gz: bf35a2999e21604de1d4daa615042036b0f16b651e251f569b4a13706d9e0e0c17022d0856303dfa71183b2c77af03631e3cae83e3b4cb790c855d47980ccc1c
7
- data.tar.gz: ad6b7c1e51bfee821d54923ccf6447ed91b0633bc084d0cd0adbd6a901d36200025334b3415c53814f62c0cf644bbb8e5a6e1a8418c1c8bac9cdf5add470e8f8
6
+ metadata.gz: ff1071aaa8e6a151fa0382ec294df042a4afb5d8c8e00bd5a25fe2ffdc67ba54971b649edc5cfd3019895a44c64ee2b0ce352032f8f24d2a525a3cd2ef6dadd0
7
+ data.tar.gz: 99dcd53ddb3c068522a7a2eba6b262c8f5bfb9bc78433596e4d15a8ea1363e31895be3b2c39270276955a802fe2198ae2d74ddba4d60a063519903ace4f71263
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ### Unreleased
2
+ - (no changes yet)
3
+
4
+ ### 0.17.0 — 2025-10-28
5
+ - Version bump to 0.17.0.
6
+ - Documentation: Updated installation snippets to `~> 0.17`.
7
+ - Documentation: Added comprehensive Range/BETWEEN/MIN/MAX filter docs across README and site (usage, adapters). Clarified sub-key aliases (`between`, `from`/`since`/`after`/`start`/`min`, `to`/`until`/`before`/`end`/`max`), accepted value shapes (Range, 2‑element Array, Hash), nested examples, and equality array semantics (`IN` vs `BETWEEN`).
8
+ - Added Range/BETWEEN/MIN/MAX filters
9
+
10
+ ### 0.16.0 — 2025-10-27
11
+ - Documentation: Added backend auto‑detection feature docs across README and site (index, usage, adapters, configuration). Examples now prefer auto‑detection by default and explain explicit overrides and ambiguity errors.
12
+ - Tests: Added shared examples to exercise auto‑detection behavior under each adapter suite.
13
+
1
14
  ### 0.15.0 — 2025-10-27
2
15
  - Add first-class SQLite support: adapter-aware LIKE behavior with OR expansion for arrays.
3
16
  - Added SQLite badge in README.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rokaki (0.15.0)
4
+ rokaki (0.17.0)
5
5
  activesupport
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -16,6 +16,7 @@ Rokaki is a small DSL for building safe, composable filters for ActiveRecord que
16
16
  - Works with ActiveRecord 7.1 and 8.x
17
17
  - LIKE modes: `:prefix`, `:suffix`, `:circumfix` (+ synonyms) and array‑of‑terms
18
18
  - Nested filters with auto‑joins and qualified columns
19
+ - Auto‑detects the database backend; specify `db:` only when your app uses multiple adapters or you need an override
19
20
  - Block‑form DSL (`filter_map do ... end`) and classic argument form
20
21
  - Runtime usage: build an anonymous filter class from a payload (no predeclared class needed)
21
22
 
@@ -37,6 +38,25 @@ Docs
37
38
 
38
39
  Tip: For a dynamic runtime listener (build a filter class from a JSON/hash payload at runtime), see “Dynamic runtime listener” in the Usage docs.
39
40
 
41
+ ## Range filters (between/min/max)
42
+
43
+ Use the field name as the key and the filter type as a sub-key, or pass a `Range` directly. Aliases are supported.
44
+
45
+ ```ruby
46
+ # Top-level
47
+ Article.filter(published: { from: Date.new(2024,1,1), to: Date.new(2024,12,31) })
48
+ Article.filter(published: (Date.new(2024,1,1)..Date.new(2024,12,31)))
49
+
50
+ # Nested
51
+ Article.filter(reviews_published: { max: Time.utc(2024,6,30) })
52
+ ```
53
+
54
+ - Lower bound aliases (>=): `from`, `since`, `after`, `start`, `min`
55
+ - Upper bound aliases (<=): `to`, `until`, `before`, `end`, `max`
56
+ - Arrays always mean `IN (?)` for equality. Use a `Range` or `{ between: [from, to] }` for range filtering
57
+
58
+ See full docs: https://tevio.github.io/rokaki/usage#range-between-min-and-max-filters
59
+
40
60
  ---
41
61
 
42
62
  ## Further reading
data/docs/adapters.md CHANGED
@@ -22,6 +22,7 @@ Rokaki generates adapter‑aware SQL for PostgreSQL, MySQL, SQL Server, Oracle,
22
22
  - Case sensitivity follows DB collation by default; future versions may add inline `COLLATE` options
23
23
  - Oracle
24
24
  - Uses `LIKE`; arrays of terms are OR‑chained; case‑insensitive paths use `UPPER(column) LIKE UPPER(:q)`
25
+ - See the dedicated page: [Oracle connections](/adapters/oracle) for connection strings, NLS settings, and common errors.
25
26
  - SQLite
26
27
  - Embedded (no separate server needed)
27
28
  - Uses `LIKE`; arrays of terms are OR‑chained across predicates
@@ -52,6 +53,16 @@ When you pass an array of terms, Rokaki composes adapter‑appropriate SQL that
52
53
  - SQL Server: The server/database/column collation determines sensitivity. Rokaki currently defers to your DB’s default. If you need deterministic behavior regardless of DB defaults, consider using a case‑sensitive collation on the column or open an issue to discuss inline `COLLATE` options.
53
54
 
54
55
 
56
+ ## Backend auto-detection
57
+
58
+ Rokaki auto-detects the adapter from your model’s ActiveRecord connection in typical single-adapter apps. If multiple adapters are detected in the process and you do not specify one, Rokaki raises a helpful error asking you to choose.
59
+
60
+ - Default: no `db:` needed; the adapter is inferred from the model connection.
61
+ - Multiple adapters present: pass `db:` to `filter_model` (or call `filter_db`) to select one explicitly.
62
+ - Errors you may see:
63
+ - `Rokaki::Error: Multiple database adapters detected (...). Please declare which backend to use via db: or filter_db.`
64
+ - `Rokaki::Error: Unable to auto-detect database adapter. Ensure your model is connected or pass db: explicitly.`
65
+
55
66
  ## SQLite
56
67
 
57
68
  SQLite is embedded and requires no separate server process. Rokaki treats it as a first-class adapter.
@@ -68,3 +79,20 @@ database: ":memory:"
68
79
  ```
69
80
 
70
81
  To persist a database file locally, set `SQLITE_DATABASE` to a path (e.g., `tmp/test.sqlite3`).
82
+
83
+
84
+ ## Range/BETWEEN filters
85
+
86
+ Rokaki’s range filters (`between`, lower-bound aliases like `from`/`min`, and upper-bound aliases like `to`/`max`) are adapter‑agnostic. The library always generates parameterized predicates using `BETWEEN`, `>=`, and `<=` on the target column.
87
+
88
+ Adapter notes:
89
+ - PostgreSQL: Uses regular `WHERE column BETWEEN $1 AND $2` (or `>=`/`<=`). No special handling is required.
90
+ - MySQL/MariaDB: Uses `BETWEEN ? AND ?` (or `>=`/`<=`). Datetime values are compared with the column precision configured by your schema.
91
+ - SQLite: Uses `BETWEEN ? AND ?` (or `>=`/`<=`).
92
+ - SQL Server: Uses `BETWEEN @from AND @to` (or `>=`/`<=`). Parameters are bound via ActiveRecord.
93
+ - Oracle: Uses `BETWEEN :from AND :to` (or `>=`/`<=`). If your column type is `DATE`, be aware it has second precision; `TIMESTAMP` supports fractional seconds.
94
+
95
+ Tips:
96
+ - For date-only upper bounds (e.g., `2024-12-31`), Rokaki treats them inclusively and, when applicable, will extend to the end of day in basic filters to match expectations. If you need precise control, pass explicit `Time` values.
97
+ - Arrays are treated as equality lists (`IN (?)`) across all adapters. Use a `Range` or `{ between: [from, to] }` for range filtering.
98
+ - `nil` bounds are ignored: only the provided side is applied.
@@ -42,6 +42,16 @@ Rokaki's test helpers (used in the specs) support environment variable overrides
42
42
  ### SQLite
43
43
  - `SQLITE_DATABASE` (path to a SQLite file; if unset, tests use an in-memory DB via `":memory:"`)
44
44
 
45
+ ## Backend auto-detection
46
+
47
+ By default, Rokaki infers the database adapter from your model’s ActiveRecord connection.
48
+
49
+ - Single-adapter apps: no `db:` needed.
50
+ - Multiple adapters present: pass `db:` to `filter_model` (or call `filter_db`) to choose explicitly.
51
+ - Errors:
52
+ - `Rokaki::Error: Multiple database adapters detected (...). Please declare which backend to use via db: or filter_db.`
53
+ - `Rokaki::Error: Unable to auto-detect database adapter. Ensure your model is connected or pass db: explicitly.`
54
+
45
55
  ## SQL Server notes
46
56
 
47
57
  - Rokaki uses `LIKE` with proper escaping and OR expansion for arrays of terms.
@@ -0,0 +1,196 @@
1
+ ---
2
+ layout: page
3
+ title: Rokaki's DSL Syntax
4
+ permalink: /dsl-syntax
5
+ ---
6
+
7
+ This page describes Rokaki’s domain-specific language (DSL) for declaring mappings and how incoming payloads are interpreted, with a focus on the difference between join-structure keys and leaf-level field keys.
8
+
9
+ The same concepts apply to both DSL entry points:
10
+ - FilterModel (querying a specific ActiveRecord model)
11
+ - Filterable (key mapping only)
12
+
13
+ See also: Usage, Adapters, and Configuration pages linked from the site index.
14
+
15
+ ## Key ideas
16
+
17
+ - You declare the shape of your filterable graph in code. This defines which associations (joins) are traversed and which fields are addressable (leaves).
18
+ - At runtime, the payload mirrors only the declared structure. Values at leaves drive the operator semantics (equality, LIKE, range). The structure of joins does not change at runtime.
19
+
20
+ ## Join-structure keys vs leaf-level field keys
21
+
22
+ - Join-structure keys represent associations. They appear only in the mapping you write (and mirrored by the payload). They do not carry operators by themselves. Examples: `author`, `reviews`, `articles`.
23
+ - Leaf-level field keys represent actual database columns on the current model or on a joined association. Examples: `title`, `content`, `published`, `first_name`.
24
+ - The mapping defines where a key is treated as a join (non-leaf) vs a field (leaf). At a declared leaf, the value can be a scalar, array, range, or an operator-hash (see below). Rokaki will not traverse deeper than the declared leaf.
25
+
26
+ ## Declaring mappings
27
+
28
+ Two equivalent styles are supported:
29
+
30
+ ### Argument-based form (classic)
31
+
32
+ ```ruby
33
+ class ArticleQuery
34
+ include Rokaki::FilterModel
35
+
36
+ filter_model :article # Adapter auto-detected; pass db: if needed
37
+ define_query_key :q # Map a single query key to many fields
38
+
39
+ like title: :circumfix, content: :circumfix # LIKE mappings (no modes: option)
40
+
41
+ # Nested LIKEs and filters via association-shaped hashes
42
+ like author: { first_name: :prefix, last_name: :suffix }
43
+
44
+ # Declare equality/range-capable fields (leafs)
45
+ filters :published # enables :published in payload
46
+ filters reviews: :published # enables nested reviews.published
47
+
48
+ attr_accessor :filters
49
+ def initialize(filters: {})
50
+ @filters = filters
51
+ end
52
+ end
53
+ ```
54
+
55
+ ### Block-form DSL
56
+
57
+ ```ruby
58
+ class ArticleQuery
59
+ include Rokaki::FilterModel
60
+
61
+ filter_model :article
62
+ define_query_key :q
63
+
64
+ filter_map do
65
+ like title: :circumfix, content: :circumfix
66
+
67
+ nested :author do
68
+ like first_name: :prefix, last_name: :suffix
69
+ filters :id # leaf field under author
70
+ end
71
+
72
+ nested :reviews do
73
+ filters :published # leaf field under reviews
74
+ end
75
+ end
76
+
77
+ attr_accessor :filters
78
+ def initialize(filters: {})
79
+ @filters = filters
80
+ end
81
+ end
82
+ ```
83
+
84
+ ## Payload rules (what values mean at a leaf)
85
+
86
+ At a leaf field (e.g., `published` or `reviews.published`):
87
+
88
+ - Scalar value → equality on the column
89
+ - Example: `{ published: Time.utc(2024,1,1) }` → `WHERE published = :v`
90
+
91
+ - Array value → equality `IN` list
92
+ - Arrays always mean `IN` across adapters.
93
+ - Example: `{ published: [t1, t2, t3] }` → `WHERE published IN (?, ?, ?)`
94
+
95
+ - Range (`a..b`) → between
96
+ - Example: `{ published: (t1..t2) }` → `WHERE published BETWEEN :from AND :to`
97
+
98
+ - Operator-hash (range-style keys) → between or open-ended bounds
99
+ - Reserved keys at the leaf indicate operator semantics:
100
+ - `between`
101
+ - Lower-bound aliases (>=): `from`, `since`, `after`, `start`, `min`
102
+ - Upper-bound aliases (<=): `to`, `until`, `before`, `end`, `max`
103
+ - Examples:
104
+ - `{ published: { from: t1, to: t2 } }`
105
+ - `{ published: { between: [t1, t2] } }`
106
+ - `{ published: { min: t1 } }` → `published >= t1`
107
+ - `{ published: { max: t2 } }` → `published <= t2`
108
+
109
+ Notes:
110
+ - Only the leaf level interprets these reserved keys. Join-structure keys do not carry operators.
111
+ - Arrays never imply range; to express a range with an array, use `{ published: { between: [from, to] } }`.
112
+ - Nil bounds are ignored: `{ published: { from: t1 } }` applies only the lower bound.
113
+
114
+ ## LIKE mappings and payloads
115
+
116
+ - You declare LIKE semantics in code via the `like` mapping; the payload provides the terms.
117
+ - Modes:
118
+ - `:prefix` → `%term`
119
+ - `:suffix` → `term%`
120
+ - `:circumfix` (synonyms: `:parafix`, `:confix`, `:ambifix`) → `%term%`
121
+ - Payload values for LIKE can be a string or an array of strings. Arrays are matched with adapter-aware OR semantics.
122
+
123
+ Examples:
124
+ ```ruby
125
+ like title: :circumfix
126
+ like author: { first_name: :prefix }
127
+
128
+ # Payload examples
129
+ { q: "First" }
130
+ { author: { first_name: ["Ada", "Al"] } }
131
+ ```
132
+
133
+ ## Nested examples
134
+
135
+ Top-level field range:
136
+ ```ruby
137
+ ArticleQuery.new(filters: { published: { since: Time.utc(2024,1,1), until: Time.utc(2024,6,30) } }).results
138
+ ```
139
+
140
+ Nested association field range:
141
+ ```ruby
142
+ ArticleQuery.new(filters: { reviews: { published: (Time.utc(2024,1,1)..Time.utc(2024,6,30)) } }).results
143
+ ```
144
+
145
+ Deep nested example (author → articles → reviews.published):
146
+ ```ruby
147
+ class AuthorQuery
148
+ include Rokaki::FilterModel
149
+ filter_model :author
150
+ filter_map do
151
+ nested :articles do
152
+ nested :reviews do
153
+ filters :published
154
+ end
155
+ end
156
+ end
157
+ attr_accessor :filters
158
+ def initialize(filters: {}) ; @filters = filters ; end
159
+ end
160
+
161
+ AuthorQuery.new(filters: { articles: { reviews: { published: { max: Time.utc(2024,6,30) } } } }).results
162
+ ```
163
+
164
+ ## Dynamic runtime listener
165
+
166
+ You can build a filter class at runtime from a payload (see Usage → Dynamic runtime listener). The same rules apply: the mapping fixes the join structure; leaf values drive operators.
167
+
168
+ ```ruby
169
+ payload = {
170
+ model: :article, db: :postgres, query_key: :q,
171
+ like: { title: :circumfix, author: { first_name: :prefix } }
172
+ }
173
+ listener = Class.new do
174
+ include Rokaki::FilterModel
175
+ filter_model payload[:model], db: payload[:db]
176
+ define_query_key payload[:query_key]
177
+ filter_map { like payload[:like] }
178
+ attr_accessor :filters
179
+ def initialize(filters: {}) ; @filters = filters ; end
180
+ end
181
+
182
+ listener.new(filters: { q: "First" }).results
183
+ ```
184
+
185
+ ## Adapter behavior
186
+
187
+ - Range/bounds predicates (`BETWEEN`, `>=`, `<=`) are adapter-agnostic; Rokaki binds parameters appropriately for PostgreSQL, MySQL, SQL Server, Oracle, and SQLite.
188
+ - LIKE behavior is adapter-aware (e.g., Postgres `ANY(ARRAY[..])`, SQL Server `ESCAPE` clause, Oracle `UPPER()` for case-insensitive paths). See the Adapters page for details.
189
+
190
+ ## Quick reference
191
+
192
+ - Join-structure keys: associations, declared in code, mirrored in payload structure; never carry operators.
193
+ - Leaf-level keys: columns/fields, declared in code with `filters`, accept values that determine semantics.
194
+ - Reserved leaf operator keys: `between`, `from`/`since`/`after`/`start`/`min`, `to`/`until`/`before`/`end`/`max`.
195
+ - Arrays: always equality `IN`.
196
+ - Ranges or operator-hash: range filtering.
data/docs/index.md CHANGED
@@ -10,9 +10,11 @@ Rokaki is a small Ruby library that helps you build safe, composable filters for
10
10
  - Supports simple and nested filters
11
11
  - LIKE-based matching with prefix/suffix/circumfix modes (circumfix also accepts synonyms: parafix, confix, ambifix)
12
12
  - Array-of-terms matching (adapter-aware)
13
+ - Auto-detects the database backend; specify db only when your app uses multiple adapters or you need an override
13
14
 
14
15
  Get started below or jump to:
15
16
  - [Usage](./usage)
17
+ - [Rokaki's DSL Syntax](./dsl-syntax)
16
18
  - [Database adapters](./adapters)
17
19
  - [Configuration](./configuration)
18
20
 
@@ -21,7 +23,7 @@ Get started below or jump to:
21
23
  Add to your application's Gemfile:
22
24
 
23
25
  ```ruby
24
- gem "rokaki", "~> 0.15"
26
+ gem "rokaki", "~> 0.17"
25
27
  ```
26
28
 
27
29
  Then:
@@ -40,8 +42,9 @@ Argument-based form:
40
42
  class ArticleQuery
41
43
  include Rokaki::FilterModel
42
44
 
43
- # Tell Rokaki which model to query and which DB adapter semantics to use
44
- filter_model :article, db: :postgres # or :mysql, :sqlserver, :oracle, :sqlite
45
+ # Tell Rokaki which model to query. Adapter is auto-detected from the connection.
46
+ # If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
47
+ filter_model :article
45
48
 
46
49
  # Map a single query key (:q) to multiple LIKE targets on Article
47
50
  define_query_key :q
@@ -66,7 +69,9 @@ Block-form DSL (same behavior):
66
69
  class ArticleQuery
67
70
  include Rokaki::FilterModel
68
71
 
69
- filter_model :article, db: :postgres # or :mysql, :sqlserver
72
+ # Adapter is auto-detected from the connection by default.
73
+ # If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
74
+ filter_model :article
70
75
  define_query_key :q
71
76
 
72
77
  filter_map do
data/docs/oracle.md ADDED
@@ -0,0 +1,138 @@
1
+ ---
2
+ layout: page
3
+ title: Oracle connections
4
+ permalink: /adapters/oracle
5
+ ---
6
+
7
+ This page collects Oracle‑specific connection tips for Rokaki (and ActiveRecord in general), including environment variables, client library notes, and how to avoid common errors during local development and CI runs.
8
+
9
+ Rokaki uses ActiveRecord’s `oracle_enhanced` adapter and ruby‑oci8 under the hood. All examples below assume ActiveRecord 7.1–8.x as used by Rokaki.
10
+
11
+ ## Quick start: commands that work
12
+
13
+ - Preferred full descriptor (stable across environments):
14
+
15
+ ```bash
16
+ RBENV_VERSION=3.3.0 \
17
+ ORACLE_USERNAME=system ORACLE_PASSWORD=oracle \
18
+ NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
19
+ ORACLE_DATABASE='(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=FREEPDB1)))' \
20
+ bundle exec rspec spec/lib/04_oracle_aware_spec.rb --format documentation
21
+ ```
22
+
23
+ - EZCONNECT (be sure to include the double slash prefix):
24
+
25
+ ```bash
26
+ RBENV_VERSION=3.3.0 \
27
+ ORACLE_DATABASE=//127.0.0.1:1521/FREEPDB1 \
28
+ ORACLE_USERNAME=system ORACLE_PASSWORD=oracle \
29
+ NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
30
+ bundle exec rspec spec/lib/04_oracle_aware_spec.rb
31
+ ```
32
+
33
+ Notes:
34
+ - Oracle Free images typically expose a pluggable database (PDB) service named `FREEPDB1`.
35
+ - Oracle XE images typically use `XEPDB1` instead.
36
+ - If you hit listener errors (ORA‑12514/12521), verify the exact service via `lsnrctl status` inside your container and confirm host port mapping.
37
+
38
+ ## Environment variables Rokaki tests understand
39
+
40
+ The spec helper accepts overrides which are passed to ActiveRecord’s connection config (see `spec/support/database_manager.rb`):
41
+
42
+ - `ORACLE_HOST` — defaults to `localhost`
43
+ - `ORACLE_PORT` — defaults to `1521`
44
+ - `ORACLE_USERNAME` — database username
45
+ - `ORACLE_PASSWORD` — database password
46
+ - `ORACLE_DATABASE` — EZCONNECT or TNS/descriptor, takes precedence over `ORACLE_SERVICE_NAME`
47
+ - `ORACLE_SERVICE_NAME` — service name (e.g., `FREEPDB1` or `XEPDB1`)
48
+ - `NLS_LANG` — recommended: `AMERICAN_AMERICA.AL32UTF8`
49
+
50
+ If only `ORACLE_SERVICE_NAME` is provided, Rokaki’s test helper composes a full descriptor automatically. If `ORACLE_DATABASE` is provided, it is used as‑is (recommended for EZCONNECT or explicit descriptors).
51
+
52
+ ## ruby‑oci8 and Instant Client
53
+
54
+ - Build‑time (already set in this repo’s `.bundle/config`):
55
+
56
+ ```yaml
57
+ BUNDLE_BUILD__RUBY___OCI8: "--with-instant-client-dir=/opt/oracle/instantclient_23_3 \
58
+ --with-instant-client-include=/opt/oracle/instantclient_23_3/sdk/include \
59
+ --with-instant-client-lib=/opt/oracle/instantclient_23_3"
60
+ ```
61
+
62
+ - Runtime (set these if the client libraries aren’t found or you see NLS errors):
63
+
64
+ macOS:
65
+ ```bash
66
+ export DYLD_LIBRARY_PATH=/opt/oracle/instantclient_23_3:$DYLD_LIBRARY_PATH
67
+ ```
68
+ Linux:
69
+ ```bash
70
+ export LD_LIBRARY_PATH=/opt/oracle/instantclient_23_3:$LD_LIBRARY_PATH
71
+ ```
72
+ Optional (explicit NLS data path):
73
+ ```bash
74
+ export OCI_NLS10=/opt/oracle/instantclient_23_3/nls/data
75
+ ```
76
+
77
+ ## Common errors and fixes
78
+
79
+ - ORA‑12705: Cannot access NLS data files or invalid environment specified
80
+ - Cause: invalid/missing NLS settings or client libraries not found.
81
+ - Fix: set `NLS_LANG=AMERICAN_AMERICA.AL32UTF8` (or leave unset), ensure Instant Client libraries are on `DYLD_LIBRARY_PATH` (macOS) or `LD_LIBRARY_PATH` (Linux). Optionally set `OCI_NLS10`.
82
+
83
+ - ORA‑12514 / ORA‑12521: TNS:listener does not currently know of service requested in connect descriptor / service not registered
84
+ - Cause: wrong `SERVICE_NAME`, wrong host/port, container not exposing the service.
85
+ - Fix: run `lsnrctl status` inside the container; use the exact `SERVICE_NAME` (e.g., `FREEPDB1`), confirm host port mapping. For EZCONNECT, remember the `//` prefix: `//HOST:PORT/SERVICE`.
86
+
87
+ - ORA‑01017: invalid username/password; logon denied
88
+ - Cause: wrong credentials for the target service/PDB.
89
+ - Fix: double‑check username/password; for tests you can connect as `SYSTEM` to bootstrap schema, or create a dedicated test user (see below).
90
+
91
+ ## Creating a dedicated test schema user
92
+
93
+ From `SYSTEM` (connected to the target PDB service):
94
+
95
+ ```sql
96
+ CREATE USER ROKAKI IDENTIFIED BY rokaki;
97
+ GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE TO ROKAKI;
98
+ ALTER USER ROKAKI QUOTA UNLIMITED ON USERS;
99
+ ```
100
+
101
+ Then connect with:
102
+
103
+ ```bash
104
+ ORACLE_USERNAME=ROKAKI ORACLE_PASSWORD=rokaki \
105
+ ORACLE_DATABASE=//127.0.0.1:1521/FREEPDB1 \
106
+ NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
107
+ bundle exec rspec spec/lib/04_oracle_aware_spec.rb
108
+ ```
109
+
110
+ ## Rails `database.yml` examples
111
+
112
+ Using `oracle_enhanced` with service name:
113
+
114
+ ```yaml
115
+ production:
116
+ adapter: oracle_enhanced
117
+ host: <%= ENV["ORACLE_HOST"] || "localhost" %>
118
+ port: <%= (ENV["ORACLE_PORT"] || 1521).to_i %>
119
+ username: <%= ENV["ORACLE_USERNAME"] %>
120
+ password: <%= ENV["ORACLE_PASSWORD"] %>
121
+ service_name: <%= ENV["ORACLE_SERVICE_NAME"] || "FREEPDB1" %>
122
+ ```
123
+
124
+ Using EZCONNECT/descriptor directly:
125
+
126
+ ```yaml
127
+ production:
128
+ adapter: oracle_enhanced
129
+ username: <%= ENV["ORACLE_USERNAME"] %>
130
+ password: <%= ENV["ORACLE_PASSWORD"] %>
131
+ database: <%= ENV["ORACLE_DATABASE"] %> # e.g., //127.0.0.1:1521/FREEPDB1
132
+ ```
133
+
134
+ ## CI and local tips
135
+
136
+ - Prefer the full `(DESCRIPTION=...)` form in CI to avoid resolver quirks.
137
+ - On Oracle Free containers the default service is `FREEPDB1`; on XE it’s `XEPDB1`.
138
+ - If your tests need to create tables, use a user with `CREATE TABLE` and `CREATE SEQUENCE` privileges (our specs do this automatically).
data/docs/usage.md CHANGED
@@ -5,13 +5,14 @@ permalink: /usage
5
5
  ---
6
6
 
7
7
  This page shows how to use Rokaki to define filters and apply them to ActiveRecord relations.
8
+ For a formal description of the mapping DSL and how payloads are interpreted (join structure vs leaf-level keys), see Rokaki's DSL Syntax: [/dsl-syntax](/dsl-syntax).
8
9
 
9
10
  ## Installation
10
11
 
11
12
  Add the gem to your Gemfile and bundle:
12
13
 
13
14
  ```ruby
14
- gem "rokaki", "~> 0.15"
15
+ gem "rokaki", "~> 0.17"
15
16
  ```
16
17
 
17
18
  ```bash
@@ -31,8 +32,9 @@ class ArticleQuery
31
32
  include Rokaki::FilterModel
32
33
  belongs_to :author
33
34
 
34
- # Choose model and adapter
35
- filter_model :article, db: :postgres # or :mysql, :sqlserver, :oracle, :sqlite
35
+ # Choose model; adapter is auto-detected from the model's connection.
36
+ # If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
37
+ filter_model :article
36
38
 
37
39
  # Map a single query key (:q) to multiple LIKE targets
38
40
  define_query_key :q
@@ -79,6 +81,83 @@ Each accepts a single string or an array of strings. Rokaki generates adapter‑
79
81
  - MySQL: `LIKE`/`LIKE BINARY` and, in nested-like contexts, `REGEXP` where designed
80
82
  - SQL Server: `LIKE` with safe escaping; arrays expand into OR chains of parameterized `LIKE` predicates
81
83
 
84
+ ## Range, BETWEEN, MIN, and MAX filters
85
+
86
+ Rokaki supports range-style filters as normal filters (not aggregates) across all adapters. You don’t have to declare special operators per field — the value shape (and optional sub-keys) drive the behavior.
87
+
88
+ Preferred syntax: use the field name as the key and the filter type as a sub-key. Aliases are supported.
89
+
90
+ - Sub-keys:
91
+ - `between` → interpret the value as a range and generate `BETWEEN`/`>=`/`<=` as appropriate
92
+ - Lower bound aliases → `>=`: `from`, `since`, `after`, `start`, `min`
93
+ - Upper bound aliases → `<=`: `to`, `until`, `before`, `end`, `max`
94
+
95
+ Accepted value shapes for `between` (also works when you pass a range directly as the field value):
96
+ - Ruby `Range`: `1..10`, `Time.utc(2024,1,1)..Time.utc(2024,12,31)`
97
+ - Two-element `Array`: `[from, to]` (only when wrapped with `{ between: [...] }`)
98
+ - Hash with aliases: `{ from:, to: }`, `{ since:, until: }`, `{ after:, before: }`, `{ start:, end: }`
99
+
100
+ Examples (top-level field):
101
+
102
+ ```ruby
103
+ class ArticleQuery
104
+ include Rokaki::FilterModel
105
+ filter_model :article
106
+
107
+ # equality filters (existing)
108
+ filters :author_id, :published
109
+
110
+ # LIKEs (existing)
111
+ define_query_key :q
112
+ like title: :circumfix
113
+ end
114
+
115
+ # Between with a Range
116
+ Article.filter(published: Date.new(2024,1,1)..Date.new(2024,12,31))
117
+
118
+ # Between with a Hash + aliases
119
+ Article.filter(published: { from: Date.new(2024,1,1), to: Date.new(2024,12,31) })
120
+ Article.filter(published: { since: Date.new(2024,1,1), until: Date.new(2024,6,30) })
121
+
122
+ # Open-ended bounds
123
+ Article.filter(published: { min: Date.new(2024,1,1) }) # >= 2024-01-01
124
+ Article.filter(published: { max: Date.new(2024,12,31) }) # <= 2024-12-31
125
+
126
+ # Between with a two-element Array
127
+ Article.filter(published: [Date.new(2024,5,1), Date.new(2024,12,1)])
128
+ ```
129
+
130
+ Nested fields use the same sub-keys and value shapes:
131
+
132
+ ```ruby
133
+ class ArticleQuery
134
+ include Rokaki::FilterModel
135
+ filter_model :article
136
+
137
+ filter_map do
138
+ nested :author do
139
+ # Range filters are value-driven; declaring the field enables the param key
140
+ filters :created_at # enables :author_created_at
141
+ end
142
+
143
+ nested :reviews do
144
+ filters :published # enables :reviews_published
145
+ end
146
+ end
147
+ end
148
+
149
+ # Params examples
150
+ Article.filter(author_created_at: { from: Time.utc(2024,1,1), to: Time.utc(2024,6,30) })
151
+ Article.filter(reviews_published: (Time.utc(2024,1,1)..Time.utc(2024,6,30)))
152
+ ```
153
+
154
+ Behavior notes:
155
+ - `min`/`max` are interpreted as lower/upper bounds, not aggregate functions.
156
+ - Passing a `Range` or two-element `Array` directly as the field value is treated as a between filter automatically.
157
+ - Arrays with more than two elements are treated as equality lists (`IN (?)`) — use `{ between: [...] }` if you intend a range.
158
+ - `nil` bounds are ignored: only the provided side is applied (e.g., `{ from: t }` becomes `>= t`).
159
+ - All generated predicates are parameterized and adapter‑agnostic (`BETWEEN`, `>=`, `<=`).
160
+
82
161
  ## Nested filters
83
162
 
84
163
  Use `nested :association` to scope filters to joined tables. Rokaki handles the necessary joins and qualified columns.
@@ -112,8 +191,9 @@ Rokaki also supports a block-form DSL that is equivalent to the argument-based f
112
191
  class ArticleQuery
113
192
  include Rokaki::FilterModel
114
193
 
115
- # Choose model and adapter
116
- filter_model :article, db: :postgres # or :mysql, :sqlserver
194
+ # Choose model; adapter is auto-detected from the model's connection.
195
+ # If your app uses multiple adapters, pass db: explicitly (e.g., db: :postgres)
196
+ filter_model :article
117
197
 
118
198
  # Declare a single query key used by all LIKE/equality filters below
119
199
  define_query_key :q
@@ -188,6 +268,59 @@ Tips:
188
268
  - Inside the block, `nested :association` affects all `filters` declared within it.
189
269
 
190
270
 
271
+ ## Backend auto-detection
272
+
273
+ 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.
274
+
275
+ - Single-adapter apps: No configuration needed — Rokaki infers the adapter from the model connection.
276
+ - 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.
277
+ - Explicit override: You can always specify `db:` on `filter_model` or call `filter_db` later.
278
+
279
+ Examples:
280
+
281
+ ```ruby
282
+ class ArticleQuery
283
+ include Rokaki::FilterModel
284
+
285
+ # Adapter auto-detected (recommended default)
286
+ filter_model :article
287
+ define_query_key :q
288
+
289
+ filter_map do
290
+ like title: :circumfix
291
+ end
292
+ end
293
+ ```
294
+
295
+ Explicit selection/override:
296
+
297
+ ```ruby
298
+ class ArticleQuery
299
+ include Rokaki::FilterModel
300
+
301
+ # Option A: choose upfront
302
+ filter_model :article, db: :postgres
303
+
304
+ # Option B: or set it later
305
+ # filter_model :article
306
+ # filter_db :sqlite
307
+ end
308
+ ```
309
+
310
+ Ambiguity behavior (apps with multiple adapters):
311
+
312
+ - If Rokaki sees multiple adapters in use and you haven’t specified one, it raises:
313
+
314
+ ```
315
+ Rokaki::Error: Multiple database adapters detected (...). Please declare which backend to use via db: or filter_db.
316
+ ```
317
+
318
+ - If it cannot detect any adapter at all, it raises:
319
+
320
+ ```
321
+ Rokaki::Error: Unable to auto-detect database adapter. Ensure your model is connected or pass db: explicitly.
322
+ ```
323
+
191
324
  ## Dynamic runtime listener (no code changes needed)
192
325
 
193
326
  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.
@@ -197,7 +330,7 @@ You can construct a Rokaki filter class at runtime from a payload (e.g., JSON
197
330
  # Example payload (e.g., parsed JSON)
198
331
  payload = {
199
332
  model: :article,
200
- db: :postgres, # or :mysql, :sqlserver, :oracle
333
+ db: :postgres, # optional; or :mysql, :sqlserver, :oracle, :sqlite
201
334
  query_key: :q, # the key in params with search term(s)
202
335
  like: { # like mappings (deeply nested allowed)
203
336
  title: :circumfix,
@@ -3,13 +3,16 @@
3
3
  module Rokaki
4
4
  module FilterModel
5
5
  class BasicFilter
6
- def initialize(keys:, prefix:, infix:, like_semantics:, i_like_semantics:, db:)
6
+ def initialize(keys:, prefix:, infix:, like_semantics:, i_like_semantics:, db:, between_keys: nil, min_keys: nil, max_keys: nil)
7
7
  @keys = keys
8
8
  @prefix = prefix
9
9
  @infix = infix
10
10
  @like_semantics = like_semantics
11
11
  @i_like_semantics = i_like_semantics
12
12
  @db = db
13
+ @between_keys = Array(between_keys).compact
14
+ @min_keys = Array(min_keys).compact
15
+ @max_keys = Array(max_keys).compact
13
16
  @filter_query = nil
14
17
  end
15
18
  attr_reader :keys, :prefix, :infix, :like_semantics, :i_like_semantics, :db, :filter_query
@@ -83,12 +86,114 @@ module Rokaki
83
86
  key: key
84
87
  )
85
88
  else
86
- query = "@model.where(#{key}: #{filter})"
89
+ # New preferred style: field => { between:/from:/to:/min:/max: }
90
+ # Also accept direct Range/Array/Hash with from/to aliases.
91
+ query = <<-RUBY
92
+ begin
93
+ _val = #{filter}
94
+ if _val.is_a?(Hash)
95
+ # Support wrapper keys like :between as well as bound aliases/min/max
96
+ _inner = _val
97
+ if _val.key?(:between) || _val.key?('between')
98
+ _inner = _val[:between] || _val['between']
99
+ end
100
+ _from = _inner[:from] || _inner['from'] || _inner[:since] || _inner['since'] || _inner[:after] || _inner['after'] || _inner[:start] || _inner['start'] || _inner[:min] || _inner['min']
101
+ _to = _inner[:to] || _inner['to'] || _inner[:until] || _inner['until'] || _inner[:before] || _inner['before'] || _inner[:end] || _inner['end'] || _inner[:max] || _inner['max']
102
+
103
+ if _from.nil? && _to.nil?
104
+ # If hash contains range-like but with different container (e.g., { between: range })
105
+ if _inner.is_a?(Range)
106
+ _from = _inner.begin; _to = _inner.end
107
+ elsif _inner.is_a?(Array)
108
+ _from, _to = _inner[0], _inner[1]
109
+ end
110
+ end
111
+
112
+ # Adjust inclusive end-of-day behavior if upper bound appears to be a date or midnight time
113
+ if !_to.nil? && (_to.is_a?(Date) && !_to.is_a?(DateTime) || (_to.respond_to?(:hour) && _to.hour == 0 && _to.min == 0 && _to.sec == 0))
114
+ _to = (_to.respond_to?(:to_time) ? _to.to_time : _to) + 86399
115
+ end
116
+ if !_from.nil? && !_to.nil?
117
+ @model.where("#{key} BETWEEN :from AND :to", from: _from, to: _to)
118
+ elsif !_from.nil?
119
+ @model.where("#{key} >= :from", from: _from)
120
+ elsif !_to.nil?
121
+ @model.where("#{key} <= :to", to: _to)
122
+ else
123
+ # Fall back to equality with the original hash
124
+ @model.where(#{key}: _val)
125
+ end
126
+ elsif _val.is_a?(Range)
127
+ #{build_between_query(filter: filter, key: key)}
128
+ else
129
+ # Equality and IN semantics for arrays and scalars (Arrays are always IN lists)
130
+ @model.where(#{key}: _val)
131
+ end
132
+ end
133
+ RUBY
87
134
  end
88
135
 
89
136
  @filter_query = query
90
137
  end
91
138
 
139
+ def build_between_query(filter:, key:)
140
+ # Accept [from, to], Range, or {from:, to:}
141
+ # Build appropriate where conditions with bound params
142
+ <<-RUBY
143
+ begin
144
+ _val = #{filter}
145
+ _from = _to = nil
146
+ if _val.is_a?(Range)
147
+ _from = _val.begin
148
+ _to = _val.end
149
+ elsif _val.is_a?(Array)
150
+ _from, _to = _val[0], _val[1]
151
+ elsif _val.is_a?(Hash)
152
+ # allow aliases for from/to
153
+ _from = _val[:from] || _val['from'] || _val[:since] || _val['since'] || _val[:after] || _val['after'] || _val[:start] || _val['start']
154
+ _to = _val[:to] || _val['to'] || _val[:until] || _val['until'] || _val[:before] || _val['before'] || _val[:end] || _val['end']
155
+ else
156
+ # single value → equality
157
+ return @model.where(#{key}: _val)
158
+ end
159
+
160
+ if !_from.nil? && !_to.nil?
161
+ @model.where("#{key} BETWEEN :from AND :to", from: _from, to: _to)
162
+ elsif !_from.nil?
163
+ @model.where("#{key} >= :from", from: _from)
164
+ elsif !_to.nil?
165
+ @model.where("#{key} <= :to", to: _to)
166
+ else
167
+ @model
168
+ end
169
+ end
170
+ RUBY
171
+ end
172
+
173
+ def parse_range_semantics(key)
174
+ k = key.to_s
175
+ %w[_between _min _max _from _to _after _before _since _until _start _end].each do |suf|
176
+ if k.end_with?(suf)
177
+ base = k.sub(/#{Regexp.escape(suf)}\z/, '')
178
+ op = case suf
179
+ when '_between' then :between
180
+ when '_min' then :min
181
+ when '_max' then :max
182
+ when '_from','_after','_since','_start' then :from
183
+ when '_to','_before','_until','_end' then :to
184
+ else nil
185
+ end
186
+ return [base, op]
187
+ end
188
+ end
189
+ [nil, nil]
190
+ end
191
+
192
+ def build_compare_query(op:, filter:, column:)
193
+ operator = (op == :'>=') ? '>=' : '<='
194
+ %Q{@model.where("#{column} #{operator} :v", v: #{filter})}
195
+ end
196
+
92
197
  # # @model.where('`authors`.`first_name` LIKE BINARY :query', query: "%teev%").or(@model.where('`authors`.`first_name` LIKE BINARY :query', query: "%imi%"))
93
198
  # if Array == filter
94
199
  # first_term = filter.unshift
@@ -129,21 +129,17 @@ module Rokaki
129
129
  where_after.push(" }")
130
130
  end
131
131
 
132
- joins = joins_before + joins_after
132
+ joins_arr = joins_before + joins_after
133
+ joins_str = joins_arr.join
133
134
 
134
135
  name += "#{leaf}"
135
- where_middle = ["{ #{leaf}: #{prefix}#{name} }"]
136
-
137
- where = where_before + where_middle + where_after
138
- joins = joins.join
139
- where = where.join
140
136
 
141
137
  if search_mode
142
138
  if db == :sqlserver || db == :oracle
143
139
  key_leaf = "#{keys.last.to_s.pluralize}.#{leaf}"
144
140
  helper = db == :sqlserver ? 'sqlserver_like' : 'oracle_like'
145
141
  @filter_methods << "def #{prefix}filter#{infix}#{name};"\
146
- "#{helper}(@model.joins(#{joins}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
142
+ "#{helper}(@model.joins(#{joins_str}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
147
143
 
148
144
  @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
149
145
  else
@@ -157,14 +153,51 @@ module Rokaki
157
153
  )
158
154
 
159
155
  @filter_methods << "def #{prefix}filter#{infix}#{name};"\
160
- "@model.joins(#{joins}).#{query}; end;"
156
+ "@model.joins(#{joins_str}).#{query}; end;"
161
157
 
162
158
  @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
163
159
  end
164
160
  else
165
- @filter_methods << "def #{prefix}filter#{infix}#{name};"\
166
- "@model.joins(#{joins}).where(#{where}); end;"
167
-
161
+ # Preferred: value Hash with sub-keys between/from/to/min/max; also accept Range/Array directly
162
+ qualified_col = "#{keys.last.to_s.pluralize}.#{leaf}"
163
+ body = <<-RUBY
164
+ begin
165
+ _val = #{prefix}#{name}
166
+ rel = @model.joins(#{joins_str})
167
+ if _val.is_a?(Hash)
168
+ _inner = _val
169
+ if _val.key?(:between) || _val.key?('between')
170
+ _inner = _val[:between] || _val['between']
171
+ end
172
+ _from = _inner[:from] || _inner['from'] || _inner[:since] || _inner['since'] || _inner[:after] || _inner['after'] || _inner[:start] || _inner['start'] || _inner[:min] || _inner['min']
173
+ _to = _inner[:to] || _inner['to'] || _inner[:until] || _inner['until'] || _inner[:before] || _inner['before'] || _inner[:end] || _inner['end'] || _inner[:max] || _inner['max']
174
+ if _from.nil? && _to.nil?
175
+ if _inner.is_a?(Range)
176
+ _from = _inner.begin; _to = _inner.end
177
+ elsif _inner.is_a?(Array)
178
+ _from, _to = _inner[0], _inner[1]
179
+ end
180
+ end
181
+ if !_from.nil? && !_to.nil?
182
+ rel.where("#{qualified_col} BETWEEN :from AND :to", from: _from, to: _to)
183
+ elsif !_from.nil?
184
+ rel.where("#{qualified_col} >= :from", from: _from)
185
+ elsif !_to.nil?
186
+ rel.where("#{qualified_col} <= :to", to: _to)
187
+ else
188
+ rel.where("#{qualified_col} = :v", v: _val)
189
+ end
190
+ elsif _val.is_a?(Range)
191
+ rel.where("#{qualified_col} BETWEEN :from AND :to", from: _val.begin, to: _val.end)
192
+ elsif _val.is_a?(Array)
193
+ # Arrays represent IN semantics for equality; use BETWEEN only when explicitly wrapped via :between
194
+ rel.where("#{qualified_col} IN (?)", _val)
195
+ else
196
+ rel.where("#{qualified_col} = :v", v: _val)
197
+ end
198
+ end
199
+ RUBY
200
+ @filter_methods << "def #{prefix}filter#{infix}#{name};#{body}; end;"
168
201
  @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
169
202
  end
170
203
  end
@@ -182,6 +215,25 @@ module Rokaki
182
215
  query
183
216
  end
184
217
 
218
+ def parse_range_semantics(key)
219
+ k = key.to_s
220
+ %w[_between _min _max _from _to _after _before _since _until _start _end].each do |suf|
221
+ if k.end_with?(suf)
222
+ base = k.sub(/#{Regexp.escape(suf)}\z/, '')
223
+ op = case suf
224
+ when '_between' then :between
225
+ when '_min' then :from # min → lower bound
226
+ when '_max' then :to # max → upper bound
227
+ when '_from','_after','_since','_start' then :from
228
+ when '_to','_before','_until','_end' then :to
229
+ else nil
230
+ end
231
+ return [base, op]
232
+ end
233
+ end
234
+ [nil, nil]
235
+ end
236
+
185
237
  end
186
238
  end
187
239
  end
@@ -159,6 +159,89 @@ module Rokaki
159
159
  end
160
160
  end
161
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
+
162
245
  # Merge two nested like/ilike mappings
163
246
  def deep_merge_like(a, b)
164
247
  return b if a.nil? || a == {}
@@ -196,7 +279,7 @@ module Rokaki
196
279
  if block_given? && args.empty?
197
280
  raise ArgumentError, 'define_query_key must be called before block filter_map' unless @filter_map_query_key
198
281
  raise ArgumentError, 'filter_model must be called before block filter_map' unless @model
199
- @_filter_db ||= :postgres
282
+ resolve_filter_db!(model: @model)
200
283
 
201
284
  # Enter block-collection mode
202
285
  @__in_filter_map_block = true
@@ -228,22 +311,22 @@ module Rokaki
228
311
  filter_model(model)
229
312
  @filter_map_query_key = query_key
230
313
 
231
- @_filter_db = options[:db] || :postgres
232
- @_filter_mode = options[:mode] || :and
233
- like(options[:like]) if options[:like]
234
- ilike(options[:ilike]) if options[:ilike]
235
- 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]
236
319
  end
237
320
 
238
321
  def filter(model, options)
239
322
  filter_model(model)
240
323
  @filter_map_query_key = nil
241
324
 
242
- @_filter_db = options[:db] || :postgres
243
- @_filter_mode = options[:mode] || :and
244
- like(options[:like]) if options[:like]
245
- ilike(options[:ilike]) if options[:ilike]
246
- 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]
247
330
  end
248
331
 
249
332
  def filters(*filter_keys)
@@ -353,9 +436,10 @@ module Rokaki
353
436
  end
354
437
 
355
438
  def filter_model(model_class, db: nil)
356
- @_filter_db = db if db
357
439
  @model = (model_class.is_a?(Class) ? model_class : Object.const_get(model_class.capitalize))
358
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
359
443
  end
360
444
 
361
445
  def case_sensitive
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.15.0"
2
+ VERSION = "0.17.0"
3
3
  end
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.15.0
4
+ version: 0.17.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-27 00:00:00.000000000 Z
11
+ date: 2025-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -296,7 +296,9 @@ files:
296
296
  - docs/_config.yml
297
297
  - docs/adapters.md
298
298
  - docs/configuration.md
299
+ - docs/dsl_syntax.md
299
300
  - docs/index.md
301
+ - docs/oracle.md
300
302
  - docs/usage.md
301
303
  - lib/rokaki.rb
302
304
  - lib/rokaki/filter_model.rb