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 +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -1
- data/README.md +20 -0
- data/docs/adapters.md +28 -0
- data/docs/configuration.md +10 -0
- data/docs/dsl_syntax.md +196 -0
- data/docs/index.md +9 -4
- data/docs/oracle.md +138 -0
- data/docs/usage.md +139 -6
- data/lib/rokaki/filter_model/basic_filter.rb +107 -2
- data/lib/rokaki/filter_model/nested_filter.rb +63 -11
- data/lib/rokaki/filter_model.rb +96 -12
- data/lib/rokaki/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52ffc6e9a926824c6730632c566d42570997aa5e2a2674ea07e79d0722a628fc
|
|
4
|
+
data.tar.gz: 248ecf8294fe48488f0b8a8990f1602ab7cd3c10af95731be2a8e3f0933905c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|
data/docs/dsl_syntax.md
ADDED
|
@@ -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.
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
+
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
|
|
35
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(#{
|
|
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(#{
|
|
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
|
-
|
|
166
|
-
|
|
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
|
data/lib/rokaki/filter_model.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
data/lib/rokaki/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rokaki
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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-
|
|
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
|