rokaki 0.16.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: f6c0197e9af455ef746957b92e5a28a4aa8a34ad35712c2aacae5ad6797f4d18
4
- data.tar.gz: 7cc5acc3e65cd850a50de797285c55fdbdab9c47fc2a790a68d6ec23c4064b4d
3
+ metadata.gz: 52ffc6e9a926824c6730632c566d42570997aa5e2a2674ea07e79d0722a628fc
4
+ data.tar.gz: 248ecf8294fe48488f0b8a8990f1602ab7cd3c10af95731be2a8e3f0933905c8
5
5
  SHA512:
6
- metadata.gz: addeae565323de0ce359cd73d01e3c7e31f457849b59fb373942436358561fd085fcf36117a4c8c75279ce549a26473364298067c2b760285eaec1f964423193
7
- data.tar.gz: 147a531f6489bc41a102b60b16689c21bc1d852fb18a4d1658115943b35d0d64905d44b81c1ff1d1c4832b5d7739dac4758a436b262c7a6e64aa75db161d92ca
6
+ metadata.gz: ff1071aaa8e6a151fa0382ec294df042a4afb5d8c8e00bd5a25fe2ffdc67ba54971b649edc5cfd3019895a44c64ee2b0ce352032f8f24d2a525a3cd2ef6dadd0
7
+ data.tar.gz: 99dcd53ddb3c068522a7a2eba6b262c8f5bfb9bc78433596e4d15a8ea1363e31895be3b2c39270276955a802fe2198ae2d74ddba4d60a063519903ace4f71263
data/CHANGELOG.md CHANGED
@@ -1,4 +1,13 @@
1
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
2
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.
3
12
  - Tests: Added shared examples to exercise auto‑detection behavior under each adapter suite.
4
13
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rokaki (0.16.0)
4
+ rokaki (0.17.0)
5
5
  activesupport
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -38,6 +38,25 @@ Docs
38
38
 
39
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.
40
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
+
41
60
  ---
42
61
 
43
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
@@ -78,3 +79,20 @@ database: ":memory:"
78
79
  ```
79
80
 
80
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.
@@ -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
@@ -14,6 +14,7 @@ Rokaki is a small Ruby library that helps you build safe, composable filters for
14
14
 
15
15
  Get started below or jump to:
16
16
  - [Usage](./usage)
17
+ - [Rokaki's DSL Syntax](./dsl-syntax)
17
18
  - [Database adapters](./adapters)
18
19
  - [Configuration](./configuration)
19
20
 
@@ -22,7 +23,7 @@ Get started below or jump to:
22
23
  Add to your application's Gemfile:
23
24
 
24
25
  ```ruby
25
- gem "rokaki", "~> 0.15"
26
+ gem "rokaki", "~> 0.17"
26
27
  ```
27
28
 
28
29
  Then:
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
@@ -80,6 +81,83 @@ Each accepts a single string or an array of strings. Rokaki generates adapter‑
80
81
  - MySQL: `LIKE`/`LIKE BINARY` and, in nested-like contexts, `REGEXP` where designed
81
82
  - SQL Server: `LIKE` with safe escaping; arrays expand into OR chains of parameterized `LIKE` predicates
82
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
+
83
161
  ## Nested filters
84
162
 
85
163
  Use `nested :association` to scope filters to joined tables. Rokaki handles the necessary joins and qualified columns.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.16.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.16.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