rokaki 0.11.0 → 0.13.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: a8fb8c022307112af51183df513130d6ad467142320a4db9be178870583c2394
4
- data.tar.gz: 611539f7d9500f30c5a847a89a7548119c14d4eac47761e9c08b5e706ae9f77c
3
+ metadata.gz: 2659a359499939511a45107cc281e2aecce654ecf587f668e9de52d6fd8e3424
4
+ data.tar.gz: 4a5dfa2b6eef83fb731bb558575bdd5072b2c5ff2f4dc3670b11cae9487447ff
5
5
  SHA512:
6
- metadata.gz: 3648caca4052f03440da996d1aed083c3ba9a29a5f14f06e9e9b2757d4d95f3356e793e22f1b1ebe0a149536a001367e6006d26d8521ce518885e32c36502130
7
- data.tar.gz: ca064a797447597677bd5b2d2e00f6a2836bc5e69341990ac7da0742ccdc41383834f7a8c9bb6c4089a37e0293786c2536727b5c79d1dabf05a4c9df20f02b77
6
+ metadata.gz: a120036a6621d1ca69631d95fccd5fa3fac82cbb4a630bffcc5a5ea87ec026ef416fda109964eec929d1c8874af7a4dc08e06bab2cd1789779332ae9796b645e
7
+ data.tar.gz: 656df3353ae9593cdeaa75ce0b0111728fbbb420d8c4885928fcc334369ceaa07ea09f78a49b93b3ab45ea8d5767b81f9280cd9cf55260690a0a34270947c7ab
@@ -0,0 +1,31 @@
1
+ name: Build documentation (no deploy)
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+
8
+ jobs:
9
+ build-docs:
10
+ runs-on: ubuntu-latest
11
+ env:
12
+ BUNDLE_GEMFILE: docs/Gemfile
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup Ruby
18
+ uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: '3.3.0'
21
+ bundler-cache: true
22
+
23
+ - name: Build site
24
+ run: |
25
+ bundle exec jekyll build -s docs -d _site
26
+
27
+ - name: Upload built site artifact
28
+ uses: actions/upload-artifact@v4
29
+ with:
30
+ name: site
31
+ path: _site
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ ### 0.13.0 — 2025-10-25
2
+ - Add block-form DSL parity across both FilterModel and Filterable (`filter_map do ... end` with `like`, `ilike`, `nested`, and `filters`).
3
+ - Support circumfix affix synonyms: `:parafix`, `:confix`, `:ambifix` (treated as `:circumfix`).
4
+ - Improve docs with block-form examples and adapter behavior notes.
5
+ - Keep SQL Server support (introduced earlier) covered in CI; add shared tests for affix synonyms to all adapter-aware specs.
6
+ - Allow ENV overrides for all adapters in test DatabaseManager (Postgres/MySQL/SQL Server).
7
+
8
+ ### 0.12.0 — 2025-10-25
9
+ - Introduce block-form DSL for FilterModel (`filter_map do ... end`) with `like` and `nested`.
10
+ - Update docs site and GitHub Pages build.
11
+
12
+ ### 0.11.0 — 2025-10-25
13
+ - Add first-class SQL Server support.
14
+ - CI updates to run tests against SQL Server, alongside PostgreSQL and MySQL.
15
+
16
+ ### 0.10.0 and earlier
17
+ - Core DSL: Filterable and FilterModel modes, LIKE matching with prefix/suffix/circumfix, nested filters, and adapter-aware SQL for Postgres/MySQL.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rokaki (0.11.0)
4
+ rokaki (0.13.0)
5
5
  activesupport
6
6
 
7
7
  GEM
data/docs/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # docs/Gemfile
2
+ source "https://rubygems.org"
3
+
4
+ # Static site generator
5
+ gem "jekyll", "~> 4.4"
6
+
7
+ # Theme used by docs/_config.yml
8
+ gem "minima", "~> 2.5"
9
+
10
+ # GitHub-flavored Markdown support
11
+ gem "kramdown-parser-gfm", "~> 1.1"
12
+
13
+ # Ruby 3+ local serving requires webrick
14
+ gem "webrick", "~> 1.8"
15
+
16
+ # Ensure compatibility with workflow expectation
17
+ gem "rake", "~> 13.3"
data/docs/_config.yml ADDED
@@ -0,0 +1,26 @@
1
+ title: Rokaki
2
+ description: A DSL for filtering data in web requests (ActiveRecord)
3
+ # For project pages (https://<user>.github.io/<repo>), set:
4
+ # url: "https://tevio.github.io"
5
+ # baseurl: "/rokaki"
6
+ # This ensures theme assets like /assets/main.css resolve under /rokaki/ on GitHub Pages.
7
+ baseurl: "/rokaki"
8
+ url: "https://tevio.github.io"
9
+ theme: minima
10
+
11
+ # Build settings
12
+ markdown: kramdown
13
+ kramdown:
14
+ input: GFM
15
+
16
+ # Exclude existing generated RDoc folder from site
17
+ exclude:
18
+ - doc/
19
+ - pkg/
20
+ - spec/
21
+ - Gemfile
22
+ - Gemfile.lock
23
+ - Rakefile
24
+ - rokaki.gemspec
25
+ - Guardfile
26
+ - README.md
data/docs/adapters.md ADDED
@@ -0,0 +1,46 @@
1
+ ---
2
+ layout: page
3
+ title: Database adapters
4
+ permalink: /adapters
5
+ ---
6
+
7
+ Rokaki generates adapter‑aware SQL for PostgreSQL, MySQL, and SQL Server.
8
+
9
+ ## Overview
10
+
11
+ - PostgreSQL
12
+ - Case‑insensitive: `ILIKE`
13
+ - Case‑sensitive: `LIKE`
14
+ - Multi‑term: `ANY (ARRAY[...])`
15
+ - MySQL
16
+ - Case‑insensitive: `LIKE`
17
+ - Case‑sensitive: `LIKE BINARY`
18
+ - Nested‑like filters may use `REGEXP` where designed in the library
19
+ - SQL Server
20
+ - Uses `LIKE` with safe escaping
21
+ - Multi‑term input expands to OR‑chained predicates (e.g., `(col LIKE :q0 OR col LIKE :q1 ...)`) with `ESCAPE '\\'`
22
+ - Case sensitivity follows DB collation by default; future versions may add inline `COLLATE` options
23
+
24
+ ## LIKE modes
25
+
26
+ All adapters support the same modes, which you declare via the values in your `like` mapping (there is no `modes:` option):
27
+
28
+ - `prefix` → `%term`
29
+ - `suffix` → `term%`
30
+ - `circumfix` → `%term%` (synonyms supported: `:parafix`, `:confix`, `:ambifix`)
31
+
32
+ Example:
33
+
34
+ ```ruby
35
+ # Declare modes via like-mapping values (no block DSL)
36
+ like title: :circumfix
37
+ like author: { first_name: :prefix }
38
+ ```
39
+
40
+ When you pass an array of terms, Rokaki composes adapter‑appropriate SQL that matches any of the terms.
41
+
42
+ ## Notes on case sensitivity
43
+
44
+ - PostgreSQL: `ILIKE` is case‑insensitive; `LIKE` is case‑sensitive depending on collation/LC settings but generally treated as case‑sensitive for ASCII.
45
+ - MySQL: `LIKE` case sensitivity depends on column collation; `LIKE BINARY` forces byte comparison (case‑sensitive for ASCII).
46
+ - 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.
@@ -0,0 +1,63 @@
1
+ ---
2
+ layout: page
3
+ title: Configuration
4
+ permalink: /configuration
5
+ ---
6
+
7
+ This page covers configuration, environment overrides, and tips for running the test suite across adapters.
8
+
9
+ ## Environment variables
10
+
11
+ Rokaki's test helpers (used in the specs) support environment variable overrides for all adapters. These are useful when your local databases run on non‑default ports or hosts.
12
+
13
+ ### SQL Server
14
+ - `SQLSERVER_HOST` (default: `localhost`)
15
+ - `SQLSERVER_PORT` (default: `1433`)
16
+ - `SQLSERVER_USERNAME` (default: `sa`)
17
+ - `SQLSERVER_PASSWORD`
18
+ - `SQLSERVER_DATABASE` (default: `rokaki`)
19
+
20
+ ### MySQL
21
+ - `MYSQL_HOST` (default: `127.0.0.1`)
22
+ - `MYSQL_PORT` (default: `3306`)
23
+ - `MYSQL_USERNAME` (default: `rokaki`)
24
+ - `MYSQL_PASSWORD` (default: `rokaki`)
25
+ - `MYSQL_DATABASE` (default: `rokaki`)
26
+
27
+ ### PostgreSQL
28
+ - `POSTGRES_HOST` (default: `127.0.0.1`)
29
+ - `POSTGRES_PORT` (default: `5432`)
30
+ - `POSTGRES_USERNAME` (default: `postgres`)
31
+ - `POSTGRES_PASSWORD` (default: `postgres`)
32
+ - `POSTGRES_DATABASE` (default: `rokaki`)
33
+
34
+ ## SQL Server notes
35
+
36
+ - Rokaki uses `LIKE` with proper escaping and OR expansion for arrays of terms.
37
+ - Case sensitivity follows your database/column collation. Future versions may allow inline `COLLATE` options.
38
+
39
+ ## Running tests locally
40
+
41
+ Ensure you have Ruby (see `.ruby-version`), then install dependencies and run specs.
42
+
43
+ ```bash
44
+ bundle install
45
+ ./spec/ordered_run.sh
46
+ ```
47
+
48
+ Or run a single adapter suite, for example SQL Server:
49
+
50
+ ```bash
51
+ bundle exec rspec spec/lib/03_sqlserver_aware_spec.rb
52
+ ```
53
+
54
+ If your SQL Server listens on a different port (e.g., 1434), set an override:
55
+
56
+ ```bash
57
+ export SQLSERVER_PORT=1434
58
+ bundle exec rspec spec/lib/03_sqlserver_aware_spec.rb
59
+ ```
60
+
61
+ ## GitHub Actions
62
+
63
+ The repository includes CI that starts MySQL (9.4), PostgreSQL (13), and SQL Server (2022) services and runs the ordered spec suite. See `.github/workflows/spec.yml`.
data/docs/index.md ADDED
@@ -0,0 +1,113 @@
1
+ ---
2
+ layout: home
3
+ title: Rokaki
4
+ permalink: /
5
+ ---
6
+
7
+ Rokaki is a small Ruby library that helps you build safe, composable filters for ActiveRecord queries in web requests.
8
+
9
+ - Works with PostgreSQL, MySQL, and SQL Server
10
+ - Supports simple and nested filters
11
+ - LIKE-based matching with prefix/suffix/circumfix modes (circumfix also accepts synonyms: parafix, confix, ambifix)
12
+ - Array-of-terms matching (adapter-aware)
13
+
14
+ Get started below or jump to:
15
+ - [Usage](./usage)
16
+ - [Database adapters](./adapters)
17
+ - [Configuration](./configuration)
18
+
19
+ ## Installation
20
+
21
+ Add to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem "rokaki", "~> 0.13"
25
+ ```
26
+
27
+ Then:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ You can declare mappings in two ways: argument-based (original) or block-form DSL. Both are equivalent.
36
+
37
+ Argument-based form:
38
+
39
+ ```ruby
40
+ class ArticleQuery
41
+ include Rokaki::FilterModel
42
+
43
+ # Tell Rokaki which model to query and which DB adapter semantics to use
44
+ filter_model :article, db: :postgres # or :mysql, :sqlserver
45
+
46
+ # Map a single query key (:q) to multiple LIKE targets on Article
47
+ define_query_key :q
48
+ like title: :circumfix, content: :circumfix
49
+
50
+ # Nested LIKEs on associated models are expressed with hashes
51
+ like author: { first_name: :prefix, last_name: :suffix }
52
+
53
+ attr_accessor :filters
54
+ def initialize(filters: {})
55
+ @filters = filters
56
+ end
57
+ end
58
+
59
+ # In a controller/service:
60
+ filtered = ArticleQuery.new(filters: params).results
61
+ ```
62
+
63
+ Block-form DSL (same behavior):
64
+
65
+ ```ruby
66
+ class ArticleQuery
67
+ include Rokaki::FilterModel
68
+
69
+ filter_model :article, db: :postgres # or :mysql, :sqlserver
70
+ define_query_key :q
71
+
72
+ filter_map do
73
+ like title: :circumfix, content: :circumfix
74
+ nested :author do
75
+ like first_name: :prefix, last_name: :suffix
76
+ # You can also declare equality filters inside nested contexts
77
+ filters :id
78
+ end
79
+ end
80
+
81
+ attr_accessor :filters
82
+ def initialize(filters: {})
83
+ @filters = filters
84
+ end
85
+ end
86
+
87
+ # In a controller/service:
88
+ filtered = ArticleQuery.new(filters: params).results
89
+ ```
90
+
91
+ Where `params` can include keys like `q`, `author_first_name`, `author_last_name`, etc. The LIKE mode for each key is defined in your `like` mapping (e.g., `title: :circumfix`), and Rokaki builds the appropriate `WHERE` clauses safely and adapter‑aware.
92
+
93
+ ## Matching modes
94
+
95
+ - prefix: matches values that start with given term(s)
96
+ - suffix: matches values that end with given term(s)
97
+ - circumfix: matches values that contain given term(s)
98
+
99
+ All modes accept either a single string or an array of terms.
100
+
101
+ ## What’s new in 0.13.0
102
+
103
+ - Block-form DSL parity across both FilterModel and Filterable
104
+ - Circumfix affix synonyms supported: :parafix, :confix, :ambifix
105
+ - SQL Server adapter support and CI coverage
106
+ - ENV overrides for all adapters in test helpers; improved DB bootstrap in specs
107
+ - Documentation site via GitHub Pages
108
+
109
+ ## Next steps
110
+
111
+ - Learn the full DSL and examples in [Usage](./usage)
112
+ - See adapter specifics (PostgreSQL/MySQL/SQL Server) in [Database adapters](./adapters)
113
+ - Configure connections and environment variables in [Configuration](./configuration)
data/docs/usage.md ADDED
@@ -0,0 +1,188 @@
1
+ ---
2
+ layout: page
3
+ title: Usage
4
+ permalink: /usage
5
+ ---
6
+
7
+ This page shows how to use Rokaki to define filters and apply them to ActiveRecord relations.
8
+
9
+ ## Installation
10
+
11
+ Add the gem to your Gemfile and bundle:
12
+
13
+ ```ruby
14
+ gem "rokaki", "~> 0.13"
15
+ ```
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Basic setup
22
+
23
+ Include `Rokaki::Filterable` in models you want to filter, and define a `filter_map` with fields and nested associations.
24
+
25
+ ```ruby
26
+ class Author < ActiveRecord::Base
27
+ has_many :articles
28
+ end
29
+
30
+ class ArticleQuery
31
+ include Rokaki::FilterModel
32
+ belongs_to :author
33
+
34
+ # Choose model and adapter
35
+ filter_model :article, db: :postgres # or :mysql, :sqlserver
36
+
37
+ # Map a single query key (:q) to multiple LIKE targets
38
+ define_query_key :q
39
+ like title: :circumfix, content: :circumfix
40
+
41
+ # Nested LIKEs via hash mapping
42
+ like author: { first_name: :prefix, last_name: :suffix }
43
+ end
44
+ ```
45
+
46
+ ## Applying filters
47
+
48
+ Call `Model.filter(params)` to build a relation based on supported keys.
49
+
50
+ ```ruby
51
+ params = {
52
+ title_prefix: "Intro",
53
+ q: ["ruby", "rails"],
54
+ author_last_name: "martin"
55
+ }
56
+
57
+ filtered = Article.filter(params)
58
+ # => ActiveRecord::Relation (chainable)
59
+ ```
60
+
61
+ You can keep chaining other scopes/clauses:
62
+
63
+ ```ruby
64
+ Article.filter(params).order(published: :desc).limit(20)
65
+ ```
66
+
67
+ ## LIKE modes and affix options
68
+
69
+ Declare the LIKE mode via the value in your `like` mapping (there is no `modes:` option). For example: `like title: :prefix`.
70
+
71
+ - `prefix` → matches strings that start with a term (pattern: `%term`)
72
+ - `suffix` → matches strings that end with a term (pattern: `term%`)
73
+ - `circumfix` → matches strings that contain a term (pattern: `%term%`)
74
+ - Synonyms supported: `:parafix`, `:confix`, `:ambifix` (all behave the same as `:circumfix`)
75
+
76
+ Each accepts a single string or an array of strings. Rokaki generates adapter‑aware SQL:
77
+
78
+ - PostgreSQL: `LIKE`/`ILIKE` with `ANY (ARRAY[...])`
79
+ - MySQL: `LIKE`/`LIKE BINARY` and, in nested-like contexts, `REGEXP` where designed
80
+ - SQL Server: `LIKE` with safe escaping; arrays expand into OR chains of parameterized `LIKE` predicates
81
+
82
+ ## Nested filters
83
+
84
+ Use `nested :association` to scope filters to joined tables. Rokaki handles the necessary joins and qualified columns.
85
+
86
+ ```ruby
87
+ filter_map do
88
+ nested :author do
89
+ like :first_name, key: :author_first
90
+ end
91
+ end
92
+ ```
93
+
94
+ Params would include `author_first`, `author_first_prefix`, etc.
95
+
96
+ ## Customization tips
97
+
98
+ - Use `key:` to map a filter to a different params key.
99
+ - Combine multiple filters; Rokaki composes them with `AND` by default.
100
+ - For advanced cases, write custom filters in your app by extending the DSL (see source for `BasicFilter`/`NestedFilter`).
101
+
102
+
103
+ ## Block-form DSL
104
+
105
+ Note: The block-form DSL is available starting in Rokaki 0.13.0.
106
+
107
+ Rokaki also supports a block-form DSL that is equivalent to the argument-based form. Use it when you prefer grouping your mappings in a single block.
108
+
109
+ ### FilterModel block form
110
+
111
+ ```ruby
112
+ class ArticleQuery
113
+ include Rokaki::FilterModel
114
+
115
+ # Choose model and adapter
116
+ filter_model :article, db: :postgres # or :mysql, :sqlserver
117
+
118
+ # Declare a single query key used by all LIKE/equality filters below
119
+ define_query_key :q
120
+
121
+ # Declare mappings inside a block
122
+ filter_map do
123
+ # LIKE mappings on the base model
124
+ like title: :circumfix, content: :circumfix
125
+
126
+ # Nested mappings on associations
127
+ nested :author do
128
+ like first_name: :prefix, last_name: :suffix
129
+
130
+ # You can also declare equality filters in block form
131
+ filters :id
132
+ end
133
+ end
134
+
135
+ attr_accessor :filters
136
+ def initialize(filters: {})
137
+ @filters = filters
138
+ end
139
+ end
140
+
141
+ # Usage
142
+ ArticleQuery.new(filters: { q: ["Intro", "Guide"] }).results
143
+ ```
144
+
145
+ Notes:
146
+ - Modes are declared by the values in your `like` mapping (`:prefix`, `:suffix`, `:circumfix`). Synonyms `:parafix`, `:confix`, `:ambifix` behave like `:circumfix`.
147
+ - Arrays for `q` are supported across adapters. PostgreSQL uses `ANY (ARRAY[...])`, MySQL/SQL Server expand to OR chains as appropriate.
148
+
149
+ ### Filterable block form
150
+
151
+ Use the block form to define simple key accessors (no SQL). Useful for plain Ruby objects or when building a mapping layer.
152
+
153
+ ```ruby
154
+ class ArticleFilters
155
+ include Rokaki::Filterable
156
+ filter_key_prefix :__
157
+
158
+ filter_map do
159
+ filters :date, author: [:first_name, :last_name]
160
+
161
+ nested :author do
162
+ nested :location do
163
+ filters :city
164
+ end
165
+ end
166
+ end
167
+
168
+ # Expect a #filters method that returns a hash
169
+ attr_reader :filters
170
+ def initialize(filters: {})
171
+ @filters = filters
172
+ end
173
+ end
174
+
175
+ f = ArticleFilters.new(filters: {
176
+ date: '2025-01-01',
177
+ author: { first_name: 'Ada', last_name: 'Lovelace', location: { city: 'London' } }
178
+ })
179
+
180
+ f.__date # => '2025-01-01'
181
+ f.__author__first_name # => 'Ada'
182
+ f.__author__last_name # => 'Lovelace'
183
+ f.__author__location__city # => 'London'
184
+ ```
185
+
186
+ Tips:
187
+ - `filter_key_prefix` and `filter_key_infix` control the generated accessor names.
188
+ - Inside the block, `nested :association` affects all `filters` declared within it.
@@ -103,7 +103,89 @@ module Rokaki
103
103
 
104
104
  private
105
105
 
106
- def filter_map(model, query_key, options)
106
+ def normalize_like_modes(obj)
107
+ case obj
108
+ when Hash
109
+ obj.each_with_object({}) do |(k, v), h|
110
+ h[k] = normalize_like_modes(v)
111
+ end
112
+ when Array
113
+ obj.map { |e| normalize_like_modes(e) }
114
+ when Symbol
115
+ # Treat alternative affixes as circumfix
116
+ return :circumfix if [:parafix, :confix, :ambifix].include?(obj)
117
+ obj
118
+ else
119
+ obj
120
+ end
121
+ end
122
+
123
+ # Merge two nested like/ilike mappings
124
+ def deep_merge_like(a, b)
125
+ return b if a.nil? || a == {}
126
+ return a if b.nil? || b == {}
127
+ a.merge(b) do |_, v1, v2|
128
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
129
+ deep_merge_like(v1, v2)
130
+ else
131
+ # Prefer later definitions
132
+ v2
133
+ end
134
+ end
135
+ end
136
+
137
+ # Wrap a normalized mapping with current nested context stack
138
+ def wrap_in_context(mapping)
139
+ return mapping if !@__ctx_stack || @__ctx_stack.empty?
140
+ @__ctx_stack.reverse.inject(mapping) { |acc, key| { key => acc } }
141
+ end
142
+
143
+ # Block DSL: nested context for like/ilike within filter_map block
144
+ def nested(name, &blk)
145
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
146
+ raise ArgumentError, 'nested requires a symbol name' unless name.is_a?(Symbol)
147
+ @__ctx_stack << name
148
+ instance_eval(&blk) if blk
149
+ @__ctx_stack.pop
150
+ else
151
+ raise NoMethodError, 'nested can only be used inside filter_map block'
152
+ end
153
+ end
154
+
155
+ def filter_map(*args, &block)
156
+ # Block form: requires prior calls to filter_model and define_query_key
157
+ if block_given? && args.empty?
158
+ raise ArgumentError, 'define_query_key must be called before block filter_map' unless @filter_map_query_key
159
+ raise ArgumentError, 'filter_model must be called before block filter_map' unless @model
160
+ @_filter_db ||= :postgres
161
+
162
+ # Enter block-collection mode
163
+ @__in_filter_map_block = true
164
+ @__block_like_accumulator = {}
165
+ @__block_ilike_accumulator = {}
166
+ @__ctx_stack = []
167
+
168
+ instance_eval(&block)
169
+
170
+ # Exit and materialize definitions
171
+ @__in_filter_map_block = false
172
+ unless @__block_like_accumulator.empty?
173
+ like(@__block_like_accumulator)
174
+ end
175
+ unless @__block_ilike_accumulator.empty?
176
+ ilike(@__block_ilike_accumulator)
177
+ end
178
+
179
+ # cleanup
180
+ @__block_like_accumulator = nil
181
+ @__block_ilike_accumulator = nil
182
+ @__ctx_stack = nil
183
+
184
+ return
185
+ end
186
+
187
+ # Positional/legacy form
188
+ model, query_key, options = args
107
189
  filter_model(model)
108
190
  @filter_map_query_key = query_key
109
191
 
@@ -126,6 +208,12 @@ module Rokaki
126
208
  end
127
209
 
128
210
  def filters(*filter_keys)
211
+ # In block form for FilterModel, allow equality filters inside nested contexts
212
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
213
+ wrapped_keys = filter_keys.map { |fk| wrap_in_context(fk) }
214
+ filter_keys = wrapped_keys
215
+ end
216
+
129
217
  if @filter_map_query_key
130
218
  define_filter_map(@filter_map_query_key, *filter_keys)
131
219
  else
@@ -259,17 +347,31 @@ module Rokaki
259
347
 
260
348
  def like(args)
261
349
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
262
- @_like_semantics = (@_like_semantics || {}).merge(args)
350
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
351
+ normalized = normalize_like_modes(args)
352
+ @__block_like_accumulator = deep_merge_like(@__block_like_accumulator, wrap_in_context(normalized))
353
+ return
354
+ end
355
+
356
+ normalized = normalize_like_modes(args)
357
+ @_like_semantics = (@_like_semantics || {}).merge(normalized)
263
358
 
264
- like_keys = LikeKeys.new(args)
359
+ like_keys = LikeKeys.new(normalized)
265
360
  like_filters(like_keys, term_type: case_sensitive)
266
361
  end
267
362
 
268
363
  def ilike(args)
269
364
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
270
- @i_like_semantics = (@i_like_semantics || {}).merge(args)
365
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
366
+ normalized = normalize_like_modes(args)
367
+ @__block_ilike_accumulator = deep_merge_like(@__block_ilike_accumulator, wrap_in_context(normalized))
368
+ return
369
+ end
370
+
371
+ normalized = normalize_like_modes(args)
372
+ @i_like_semantics = (@i_like_semantics || {}).merge(normalized)
271
373
 
272
- like_keys = LikeKeys.new(args)
374
+ like_keys = LikeKeys.new(normalized)
273
375
  like_filters(like_keys, term_type: case_insensitive)
274
376
  end
275
377
 
@@ -13,6 +13,48 @@ module Rokaki
13
13
  module ClassMethods
14
14
  private
15
15
 
16
+ # --- Block DSL support (Filterable mode) ---
17
+ def nested(name, &blk)
18
+ if instance_variable_defined?(:@__in_filterable_block) && @__in_filterable_block
19
+ raise ArgumentError, 'nested requires a symbol name' unless name.is_a?(Symbol)
20
+ @__ctx_stack << name
21
+ instance_eval(&blk) if blk
22
+ @__ctx_stack.pop
23
+ else
24
+ raise NoMethodError, 'nested can only be used inside filter_map block'
25
+ end
26
+ end
27
+
28
+ # In Filterable, `filter_map` without args opens a block to declare `filters` with optional nesting
29
+ # For backward compatibility, if args are provided, delegate to define_filter_map
30
+ def filter_map(*args, &block)
31
+ if block_given? && args.empty?
32
+ # Enter block-collection mode
33
+ @__in_filterable_block = true
34
+ @__ctx_stack = []
35
+ @__block_filters = []
36
+
37
+ instance_eval(&block)
38
+
39
+ # Materialize collected filters
40
+ unless @__block_filters.empty?
41
+ define_filter_keys(*@__block_filters)
42
+ end
43
+
44
+ # cleanup
45
+ @__in_filterable_block = false
46
+ @__ctx_stack = nil
47
+ @__block_filters = nil
48
+ return
49
+ end
50
+
51
+ # Positional/legacy map form (delegates to define_filter_map)
52
+ if args.any?
53
+ query_field, *filter_keys = args
54
+ define_filter_map(query_field, *filter_keys)
55
+ end
56
+ end
57
+
16
58
  def define_filter_keys(*filter_keys)
17
59
  filter_keys.each do |filter_key|
18
60
  _build_filter([filter_key]) unless Hash === filter_key
@@ -96,8 +138,8 @@ module Rokaki
96
138
  if value.is_a? Array
97
139
  value.each do |av|
98
140
  if av.is_a? Symbol
99
- _keys = keys.dup << av
100
- yield _keys
141
+ _keys = keys.dup << av
142
+ yield _keys
101
143
  else
102
144
  deep_map(keys, av, &block)
103
145
  end
@@ -110,6 +152,33 @@ module Rokaki
110
152
  end
111
153
  end
112
154
 
155
+ # Helper: wrap a Symbol/Hash filter key in current nested context
156
+ def wrap_in_context(filter_key)
157
+ return filter_key unless instance_variable_defined?(:@__ctx_stack) && @__ctx_stack && !@__ctx_stack.empty?
158
+ ctx = @__ctx_stack.dup
159
+ if filter_key.is_a?(Hash)
160
+ # Nest the entire hash under the context chain
161
+ ctx.reverse.inject(filter_key) { |acc, k| { k => acc } }
162
+ else
163
+ # Symbol → build a hash with leaf
164
+ ctx.reverse.inject(filter_key) { |acc, k| { k => acc } }
165
+ end
166
+ end
167
+
168
+ public
169
+
170
+ # Enhance `filters` to support block mode accumulation
171
+ def filters(*filter_keys)
172
+ if instance_variable_defined?(:@__in_filterable_block) && @__in_filterable_block
173
+ filter_keys.each do |fk|
174
+ @__block_filters << wrap_in_context(fk)
175
+ end
176
+ return
177
+ end
178
+
179
+ define_filter_keys(*filter_keys)
180
+ end
181
+
113
182
  end
114
183
 
115
184
  def filters
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.11.0"
2
+ VERSION = "0.13.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rokaki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Martin
@@ -242,11 +242,13 @@ extensions: []
242
242
  extra_rdoc_files: []
243
243
  files:
244
244
  - ".github/workflows/codeql-analysis.yml"
245
+ - ".github/workflows/pages.yml"
245
246
  - ".github/workflows/spec.yml"
246
247
  - ".gitignore"
247
248
  - ".rspec"
248
249
  - ".ruby-version"
249
250
  - ".travis.yml"
251
+ - CHANGELOG.md
250
252
  - Gemfile
251
253
  - Gemfile.lock
252
254
  - Guardfile
@@ -255,6 +257,12 @@ files:
255
257
  - Rakefile
256
258
  - bin/console
257
259
  - bin/setup
260
+ - docs/Gemfile
261
+ - docs/_config.yml
262
+ - docs/adapters.md
263
+ - docs/configuration.md
264
+ - docs/index.md
265
+ - docs/usage.md
258
266
  - lib/rokaki.rb
259
267
  - lib/rokaki/filter_model.rb
260
268
  - lib/rokaki/filter_model/basic_filter.rb