rokaki 0.12.0 → 0.14.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: dcbf82920cbfeff8466f6ccd583f1a66652082e087c2d6641806be8720dd8d6c
4
- data.tar.gz: 4165d387e1fba3820fc302ef414f0019077067a1dca7d307943030f0aec99207
3
+ metadata.gz: ed3b16c71be49df063699af31b64190d9aa2db724bd5f6310e458aa33f63025a
4
+ data.tar.gz: c81607decccf33f1e9a71b1e68c3bd478428b22a1f375f9e5554b39ddfc0d8d6
5
5
  SHA512:
6
- metadata.gz: 8e5d866b175e0799ad9bff2cbb8660e8ebca48785e508223e7583452eb7e95e56c1c35f15ff8625fc456365651b82efff40636579f90469d9909f650f6ee2b1d
7
- data.tar.gz: e974f32a7626a94fb618295ea9600e9747af73bd35986f9e843a81b4bc6168c98180ef85d150fc1ad96ff4fa4700daae5d16d75596bc2d3dd9279ba8d6a98c64
6
+ metadata.gz: 18d022ef801a2a978f01d1f142c34fb95171131d30f8f8cbe8837a60fc9ea44c162d48e9f672380a965f578de65b568124686399974918497141e0eca549b29a
7
+ data.tar.gz: 77f547a998d588d4128f6df533ae0e555c8561b402e496b53b66c7daee87e42a7ed3b2152ef5101930abd568ee2b0b268aeb71bb75d841feabb102238d776f49
@@ -35,21 +35,67 @@ jobs:
35
35
  - 1433:1433
36
36
  # No built-in healthcheck; we'll wait in a step using nc
37
37
 
38
+ oracle:
39
+ image: gvenzl/oracle-free:23-slim
40
+ env:
41
+ ORACLE_PASSWORD: "oracle"
42
+ APP_USER: ROKAKI
43
+ APP_USER_PASSWORD: rokaki
44
+ ports:
45
+ - 1521:1521
46
+
38
47
  steps:
39
48
  - uses: actions/checkout@v2
40
49
 
41
50
  - name: Install system dependencies
42
51
  run: |
43
52
  sudo apt-get update
44
- sudo apt-get install -y build-essential libpq-dev default-libmysqlclient-dev freetds-dev netcat-openbsd
53
+ sudo apt-get install -y build-essential libpq-dev default-libmysqlclient-dev freetds-dev netcat-openbsd unzip curl libaio1t64 libaio-dev
54
+ sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1
45
55
 
46
56
  - name: Set up Ruby
47
57
  uses: ruby/setup-ruby@v1
48
58
  with:
49
- # Not needed with a .ruby-version file
50
59
  ruby-version: 3.3.0
51
- # runs 'bundle install' and caches installed gems automatically
52
- bundler-cache: true
60
+ bundler-cache: false # we'll run bundle after Instant Client is present
61
+
62
+ - name: Install Oracle Instant Client (ZIP Basic Lite + SDK)
63
+ run: |
64
+ set -e
65
+ sudo mkdir -p /opt/oracle && cd /opt/oracle
66
+ # Download current 23.5 ZIPs directly from Oracle
67
+ curl -fL -o ic-basic.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-basic-linux.x64-23.26.0.0.0.zip
68
+ curl -fL -o ic-sdk.zip https://download.oracle.com/otn_software/linux/instantclient/2326000/instantclient-sdk-linux.x64-23.26.0.0.0.zip
69
+ sudo unzip -q -o ic-basic.zip
70
+ sudo unzip -q -o ic-sdk.zip
71
+ ls /opt/oracle
72
+ cd /opt/oracle/instantclient_23_26 && sudo ln -s ../instantclient_23_26 lib
73
+ cd /opt/oracle/instantclient_23_26 && sudo ln -s sdk/include include
74
+ IC_DIR=/opt/oracle/instantclient_23_26
75
+ echo "OCI_DIR=$IC_DIR" | sudo tee -a $GITHUB_ENV
76
+ echo "OCI_LIB_DIR=$IC_DIR" | sudo tee -a $GITHUB_ENV
77
+ echo "OCI_INC_DIR=$IC_DIR/sdk/include" | sudo tee -a $GITHUB_ENV
78
+ echo "LD_LIBRARY_PATH=$IC_DIR:$LD_LIBRARY_PATH" | sudo tee -a $GITHUB_ENV
79
+
80
+ - name: Bundle install (after Instant Client)
81
+ id: bundleInstallAfterInstantClient
82
+ env:
83
+ ORACLE_HOME: /opt/oracle/instantclient_23_26
84
+ OCI_DIR: ${{ env.OCI_DIR }}
85
+ OCI_LIB_DIR: ${{ env.OCI_LIB_DIR }}
86
+ OCI_INC_DIR: ${{ env.OCI_INC_DIR }}
87
+ LD_LIBRARY_PATH: ${{ env.LD_LIBRARY_PATH }}
88
+ run: |
89
+ ls /opt/oracle/instantclient_23_26
90
+ bundle config set build.ruby-oci8 "--with-instant-client-dir=/opt/oracle/instantclient_23_26 --with-instant-client-include=/opt/oracle/instantclient_23_26/sdk/include --with-instant-client-lib=/opt/oracle/instantclient_23_26"
91
+ bundle install --jobs 4 --retry 3
92
+
93
+ - name: Archive Build Artifact
94
+ if: failure() && steps.bundleInstallAfterInstantClient.outcome == 'failure'
95
+ uses: actions/upload-artifact@v4
96
+ with:
97
+ name: build-mkmf-log
98
+ path: /opt/hostedtoolcache/Ruby/3.3.0/x64/lib/ruby/gems/3.3.0/extensions/x86_64-linux/3.3.0/ruby-oci8-2.2.14/mkmf.log
53
99
 
54
100
  - name: Wait for databases to be ready
55
101
  shell: bash
@@ -63,7 +109,24 @@ jobs:
63
109
  for i in {1..120}; do
64
110
  nc -z 127.0.0.1 1433 && echo "SQL Server up" && break || sleep 1
65
111
  done
112
+ for i in {1..180}; do
113
+ nc -z 127.0.0.1 1521 && echo "Oracle up" && break || sleep 1
114
+ done
66
115
 
67
116
  - name: Run tests
117
+ env:
118
+ POSTGRES_HOST: 127.0.0.1
119
+ POSTGRES_USERNAME: postgres
120
+ POSTGRES_PASSWORD: postgres
121
+ SQLSERVER_HOST: 127.0.0.1
122
+ SQLSERVER_PORT: 1433
123
+ SQLSERVER_USERNAME: sa
124
+ SQLSERVER_PASSWORD: 5QL5£rv£r
125
+ ORACLE_HOST: 127.0.0.1
126
+ ORACLE_PORT: 1521
127
+ ORACLE_DATABASE: /freepdb1
128
+ ORACLE_USERNAME: ROKAKI
129
+ ORACLE_PASSWORD: rokaki
130
+ LD_LIBRARY_PATH: ${{ env.LD_LIBRARY_PATH }}
68
131
  run: |
69
132
  ./spec/ordered_run.sh
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.12.0)
4
+ rokaki (0.14.0)
5
5
  activesupport
6
6
 
7
7
  GEM
@@ -13,6 +13,10 @@ GEM
13
13
  activemodel (= 8.0.3)
14
14
  activesupport (= 8.0.3)
15
15
  timeout (>= 0.4.0)
16
+ activerecord-oracle_enhanced-adapter (8.0.0)
17
+ activerecord (~> 8.0.0)
18
+ ruby-oci8
19
+ ruby-plsql (>= 0.6.0)
16
20
  activerecord-sqlserver-adapter (8.0.9)
17
21
  activerecord (~> 8.0.0)
18
22
  tiny_tds
@@ -30,8 +34,8 @@ GEM
30
34
  tzinfo (~> 2.0, >= 2.0.5)
31
35
  uri (>= 0.13.1)
32
36
  base64 (0.3.0)
33
- benchmark (0.4.1)
34
- bigdecimal (3.2.3)
37
+ benchmark (0.5.0)
38
+ bigdecimal (3.3.1)
35
39
  byebug (12.0.0)
36
40
  coderay (1.1.3)
37
41
  concurrent-ruby (1.3.5)
@@ -42,7 +46,7 @@ GEM
42
46
  database_cleaner-core (2.0.1)
43
47
  diff-lcs (1.6.2)
44
48
  drb (2.2.3)
45
- factory_bot (6.5.5)
49
+ factory_bot (6.5.6)
46
50
  activesupport (>= 6.1.0)
47
51
  ffi (1.17.2-aarch64-linux-gnu)
48
52
  ffi (1.17.2-aarch64-linux-musl)
@@ -81,7 +85,7 @@ GEM
81
85
  logger (1.7.0)
82
86
  lumberjack (1.4.2)
83
87
  method_source (1.1.0)
84
- minitest (5.25.5)
88
+ minitest (5.26.0)
85
89
  mysql2 (0.5.7)
86
90
  bigdecimal
87
91
  nenv (0.3.0)
@@ -108,19 +112,21 @@ GEM
108
112
  ffi (~> 1.0)
109
113
  reline (0.6.2)
110
114
  io-console (~> 0.5)
111
- rspec (3.13.1)
115
+ rspec (3.13.2)
112
116
  rspec-core (~> 3.13.0)
113
117
  rspec-expectations (~> 3.13.0)
114
118
  rspec-mocks (~> 3.13.0)
115
- rspec-core (3.13.5)
119
+ rspec-core (3.13.6)
116
120
  rspec-support (~> 3.13.0)
117
121
  rspec-expectations (3.13.5)
118
122
  diff-lcs (>= 1.2.0, < 2.0)
119
123
  rspec-support (~> 3.13.0)
120
- rspec-mocks (3.13.5)
124
+ rspec-mocks (3.13.6)
121
125
  diff-lcs (>= 1.2.0, < 2.0)
122
126
  rspec-support (~> 3.13.0)
123
127
  rspec-support (3.13.6)
128
+ ruby-oci8 (2.2.14)
129
+ ruby-plsql (0.8.0)
124
130
  securerandom (0.4.1)
125
131
  shellany (0.0.1)
126
132
  sqlite3 (2.7.4-aarch64-linux-gnu)
@@ -147,7 +153,7 @@ GEM
147
153
  bigdecimal (~> 3)
148
154
  tzinfo (2.0.6)
149
155
  concurrent-ruby (~> 1.0)
150
- uri (1.0.3)
156
+ uri (1.0.4)
151
157
 
152
158
  PLATFORMS
153
159
  aarch64-linux
@@ -164,7 +170,8 @@ PLATFORMS
164
170
  x86_64-linux-musl
165
171
 
166
172
  DEPENDENCIES
167
- activerecord
173
+ activerecord (>= 7.1, < 9.0)
174
+ activerecord-oracle_enhanced-adapter (~> 8.0.0)
168
175
  activerecord-sqlserver-adapter
169
176
  bundler (~> 2.0)
170
177
  database_cleaner-active_record
@@ -178,6 +185,7 @@ DEPENDENCIES
178
185
  rake (~> 13.0)
179
186
  rokaki!
180
187
  rspec (~> 3.0)
188
+ ruby-oci8
181
189
  sqlite3
182
190
  tiny_tds
183
191
 
data/docs/adapters.md CHANGED
@@ -4,7 +4,7 @@ title: Database adapters
4
4
  permalink: /adapters
5
5
  ---
6
6
 
7
- Rokaki generates adapter‑aware SQL for PostgreSQL, MySQL, and SQL Server.
7
+ Rokaki generates adapter‑aware SQL for PostgreSQL, MySQL, SQL Server, and Oracle.
8
8
 
9
9
  ## Overview
10
10
 
data/docs/index.md CHANGED
@@ -21,7 +21,7 @@ Get started below or jump to:
21
21
  Add to your application's Gemfile:
22
22
 
23
23
  ```ruby
24
- gem "rokaki", "~> 0.11"
24
+ gem "rokaki", "~> 0.13"
25
25
  ```
26
26
 
27
27
  Then:
@@ -32,7 +32,9 @@ bundle install
32
32
 
33
33
  ## Quick start
34
34
 
35
- Use `Rokaki::FilterModel` and declare mappings with method arguments (no block DSL).
35
+ You can declare mappings in two ways: argument-based (original) or block-form DSL. Both are equivalent.
36
+
37
+ Argument-based form:
36
38
 
37
39
  ```ruby
38
40
  class ArticleQuery
@@ -58,6 +60,34 @@ end
58
60
  filtered = ArticleQuery.new(filters: params).results
59
61
  ```
60
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
+
61
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.
62
92
 
63
93
  ## Matching modes
@@ -68,6 +98,14 @@ Where `params` can include keys like `q`, `author_first_name`, `author_last_name
68
98
 
69
99
  All modes accept either a single string or an array of terms.
70
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
+
71
109
  ## Next steps
72
110
 
73
111
  - Learn the full DSL and examples in [Usage](./usage)
data/docs/usage.md CHANGED
@@ -11,7 +11,7 @@ This page shows how to use Rokaki to define filters and apply them to ActiveReco
11
11
  Add the gem to your Gemfile and bundle:
12
12
 
13
13
  ```ruby
14
- gem "rokaki", "~> 0.11"
14
+ gem "rokaki", "~> 0.13"
15
15
  ```
16
16
 
17
17
  ```bash
@@ -38,7 +38,7 @@ class ArticleQuery
38
38
  define_query_key :q
39
39
  like title: :circumfix, content: :circumfix
40
40
 
41
- # Nested LIKEs via hash mapping (no block DSL)
41
+ # Nested LIKEs via hash mapping
42
42
  like author: { first_name: :prefix, last_name: :suffix }
43
43
  end
44
44
  ```
@@ -98,3 +98,91 @@ Params would include `author_first`, `author_first_prefix`, etc.
98
98
  - Use `key:` to map a filter to a different params key.
99
99
  - Combine multiple filters; Rokaki composes them with `AND` by default.
100
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.
@@ -40,6 +40,8 @@ module Rokaki
40
40
  'LIKE BINARY'
41
41
  elsif db == :sqlserver
42
42
  'LIKE'
43
+ elsif db == :oracle
44
+ 'LIKE'
43
45
  else
44
46
  'LIKE'
45
47
  end
@@ -52,6 +54,9 @@ module Rokaki
52
54
  'LIKE'
53
55
  elsif db == :sqlserver
54
56
  'LIKE'
57
+ elsif db == :oracle
58
+ # Use 'ILIKE' as a signal; oracle_like will translate to UPPER(column) LIKE UPPER(:q)
59
+ 'ILIKE'
55
60
  else
56
61
  'LIKE'
57
62
  end
@@ -105,6 +110,9 @@ module Rokaki
105
110
  elsif db == :sqlserver
106
111
  # Delegate to helper that supports arrays and escaping with ESCAPE
107
112
  query = "sqlserver_like(@model, \"#{key}\", \"#{type}\", #{filter}, :#{mode})"
113
+ elsif db == :oracle
114
+ # Oracle helper handles case-insensitive via UPPER() when type is 'ILIKE'
115
+ query = "oracle_like(@model, \"#{key}\", \"#{type}\", #{filter}, :#{mode})"
108
116
  else
109
117
  query = "@model.where(\"#{key} #{type} :query\", "
110
118
  query += "query: \"%\#{#{filter}}%\")" if mode == :circumfix
@@ -139,10 +139,11 @@ module Rokaki
139
139
  where = where.join
140
140
 
141
141
  if search_mode
142
- if db == :sqlserver
142
+ if db == :sqlserver || db == :oracle
143
143
  key_leaf = "#{keys.last.to_s.pluralize}.#{leaf}"
144
+ helper = db == :sqlserver ? 'sqlserver_like' : 'oracle_like'
144
145
  @filter_methods << "def #{prefix}filter#{infix}#{name};"\
145
- "sqlserver_like(@model.joins(#{joins}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
146
+ "#{helper}(@model.joins(#{joins}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
146
147
 
147
148
  @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
148
149
  else
@@ -197,7 +197,7 @@ module Rokaki
197
197
  # Compute key_leaf (qualified column) like other branches
198
198
  key_leaf = keys.last ? "#{keys.last.to_s.pluralize}.#{leaf}" : leaf
199
199
 
200
- if db == :sqlserver
200
+ if db == :sqlserver || db == :oracle
201
201
  # Build relation base with joins
202
202
  if join_map.empty?
203
203
  rel_expr = "@model"
@@ -207,7 +207,11 @@ module Rokaki
207
207
  rel_expr = "@model.joins(**#{join_map})"
208
208
  end
209
209
 
210
- filter_query = "sqlserver_like(#{rel_expr}, \"#{key_leaf}\", \"#{type.to_s.upcase}\", #{filter_name}, :#{search_mode})"
210
+ if db == :sqlserver
211
+ filter_query = "sqlserver_like(#{rel_expr}, \"#{key_leaf}\", \"#{type.to_s.upcase}\", #{filter_name}, :#{search_mode})"
212
+ else
213
+ filter_query = "oracle_like(#{rel_expr}, \"#{key_leaf}\", \"#{type.to_s.upcase}\", #{filter_name}, :#{search_mode})"
214
+ end
211
215
  else
212
216
  query = build_like_query(
213
217
  type: type,
@@ -69,6 +69,27 @@ module Rokaki
69
69
  end
70
70
  end
71
71
 
72
+ # Compose an Oracle LIKE relation supporting arrays of terms and case-insensitive path via UPPER()
73
+ # type_signal: 'LIKE' for case-sensitive semantics; 'ILIKE' to indicate case-insensitive (we will translate)
74
+ def oracle_like(model, column, type_signal, value, mode)
75
+ terms = prepare_like_terms(value, mode)
76
+ ci = (type_signal.to_s.upcase == 'ILIKE')
77
+ col_expr = ci ? "UPPER(#{column})" : column
78
+ build_term = proc { |t| ci ? t.to_s.upcase : t }
79
+
80
+ if terms.is_a?(Array)
81
+ return model.none if terms.empty?
82
+ first = build_term.call(terms[0])
83
+ rel = model.where("#{col_expr} LIKE :q0 ESCAPE '\\'", q0: first)
84
+ terms[1..-1]&.each_with_index do |t, i|
85
+ rel = rel.or(model.where("#{col_expr} LIKE :q#{i + 1} ESCAPE '\\'", "q#{i + 1}".to_sym => build_term.call(t)))
86
+ end
87
+ rel
88
+ else
89
+ model.where("#{col_expr} LIKE :q ESCAPE '\\'", q: build_term.call(terms))
90
+ end
91
+ end
92
+
72
93
  def prepare_regex_terms(param, mode)
73
94
  if Array === param
74
95
  param_map = param.map { |term| ".*#{term}.*" } if mode == :circumfix
@@ -120,7 +141,72 @@ module Rokaki
120
141
  end
121
142
  end
122
143
 
123
- def filter_map(model, query_key, options)
144
+ # Merge two nested like/ilike mappings
145
+ def deep_merge_like(a, b)
146
+ return b if a.nil? || a == {}
147
+ return a if b.nil? || b == {}
148
+ a.merge(b) do |_, v1, v2|
149
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
150
+ deep_merge_like(v1, v2)
151
+ else
152
+ # Prefer later definitions
153
+ v2
154
+ end
155
+ end
156
+ end
157
+
158
+ # Wrap a normalized mapping with current nested context stack
159
+ def wrap_in_context(mapping)
160
+ return mapping if !@__ctx_stack || @__ctx_stack.empty?
161
+ @__ctx_stack.reverse.inject(mapping) { |acc, key| { key => acc } }
162
+ end
163
+
164
+ # Block DSL: nested context for like/ilike within filter_map block
165
+ def nested(name, &blk)
166
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
167
+ raise ArgumentError, 'nested requires a symbol name' unless name.is_a?(Symbol)
168
+ @__ctx_stack << name
169
+ instance_eval(&blk) if blk
170
+ @__ctx_stack.pop
171
+ else
172
+ raise NoMethodError, 'nested can only be used inside filter_map block'
173
+ end
174
+ end
175
+
176
+ def filter_map(*args, &block)
177
+ # Block form: requires prior calls to filter_model and define_query_key
178
+ if block_given? && args.empty?
179
+ raise ArgumentError, 'define_query_key must be called before block filter_map' unless @filter_map_query_key
180
+ raise ArgumentError, 'filter_model must be called before block filter_map' unless @model
181
+ @_filter_db ||= :postgres
182
+
183
+ # Enter block-collection mode
184
+ @__in_filter_map_block = true
185
+ @__block_like_accumulator = {}
186
+ @__block_ilike_accumulator = {}
187
+ @__ctx_stack = []
188
+
189
+ instance_eval(&block)
190
+
191
+ # Exit and materialize definitions
192
+ @__in_filter_map_block = false
193
+ unless @__block_like_accumulator.empty?
194
+ like(@__block_like_accumulator)
195
+ end
196
+ unless @__block_ilike_accumulator.empty?
197
+ ilike(@__block_ilike_accumulator)
198
+ end
199
+
200
+ # cleanup
201
+ @__block_like_accumulator = nil
202
+ @__block_ilike_accumulator = nil
203
+ @__ctx_stack = nil
204
+
205
+ return
206
+ end
207
+
208
+ # Positional/legacy form
209
+ model, query_key, options = args
124
210
  filter_model(model)
125
211
  @filter_map_query_key = query_key
126
212
 
@@ -143,6 +229,12 @@ module Rokaki
143
229
  end
144
230
 
145
231
  def filters(*filter_keys)
232
+ # In block form for FilterModel, allow equality filters inside nested contexts
233
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
234
+ wrapped_keys = filter_keys.map { |fk| wrap_in_context(fk) }
235
+ filter_keys = wrapped_keys
236
+ end
237
+
146
238
  if @filter_map_query_key
147
239
  define_filter_map(@filter_map_query_key, *filter_keys)
148
240
  else
@@ -256,6 +348,8 @@ module Rokaki
256
348
  'REGEXP'
257
349
  elsif @_filter_db == :sqlserver
258
350
  'LIKE'
351
+ elsif @_filter_db == :oracle
352
+ 'LIKE'
259
353
  else
260
354
  'LIKE'
261
355
  end
@@ -269,6 +363,9 @@ module Rokaki
269
363
  'REGEXP'
270
364
  elsif @_filter_db == :sqlserver
271
365
  'LIKE'
366
+ elsif @_filter_db == :oracle
367
+ # Use 'ILIKE' as a signal for case-insensitive; oracle_like will translate to UPPER(column) LIKE UPPER(:q)
368
+ 'ILIKE'
272
369
  else
273
370
  'LIKE'
274
371
  end
@@ -276,6 +373,12 @@ module Rokaki
276
373
 
277
374
  def like(args)
278
375
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
376
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
377
+ normalized = normalize_like_modes(args)
378
+ @__block_like_accumulator = deep_merge_like(@__block_like_accumulator, wrap_in_context(normalized))
379
+ return
380
+ end
381
+
279
382
  normalized = normalize_like_modes(args)
280
383
  @_like_semantics = (@_like_semantics || {}).merge(normalized)
281
384
 
@@ -285,6 +388,12 @@ module Rokaki
285
388
 
286
389
  def ilike(args)
287
390
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
391
+ if instance_variable_defined?(:@__in_filter_map_block) && @__in_filter_map_block
392
+ normalized = normalize_like_modes(args)
393
+ @__block_ilike_accumulator = deep_merge_like(@__block_ilike_accumulator, wrap_in_context(normalized))
394
+ return
395
+ end
396
+
288
397
  normalized = normalize_like_modes(args)
289
398
  @i_like_semantics = (@i_like_semantics || {}).merge(normalized)
290
399
 
@@ -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
@@ -35,7 +77,7 @@ module Rokaki
35
77
  @filter_key_prefix ||= prefix
36
78
  end
37
79
 
38
- def filter_key_infix(infix = :_)
80
+ def filter_key_infix(infix = :__)
39
81
  @filter_key_infix ||= infix
40
82
  end
41
83
 
@@ -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,35 @@ 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
+ @__block_filters ||= []
174
+ @__ctx_stack ||= []
175
+ filter_keys.each do |fk|
176
+ @__block_filters << wrap_in_context(fk)
177
+ end
178
+ return
179
+ end
180
+
181
+ define_filter_keys(*filter_keys)
182
+ end
183
+
113
184
  end
114
185
 
115
186
  def filters
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.12.0"
2
+ VERSION = "0.14.0"
3
3
  end
data/rokaki.gemspec CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
32
32
 
33
33
  spec.add_dependency 'activesupport'
34
34
 
35
- spec.add_development_dependency 'activerecord'
35
+ spec.add_development_dependency 'activerecord', '>= 7.1', '< 9.0'
36
36
  spec.add_development_dependency 'bundler', '~> 2.0'
37
37
  spec.add_development_dependency 'pry'
38
38
  spec.add_development_dependency 'pry-byebug'
@@ -50,5 +50,8 @@ Gem::Specification.new do |spec|
50
50
  # For SQL Server testing
51
51
  spec.add_development_dependency 'tiny_tds'
52
52
  spec.add_development_dependency 'activerecord-sqlserver-adapter'
53
+ # For Oracle testing (optional). Enable by setting WITH_ORACLE=1 before bundling.
54
+ spec.add_development_dependency 'ruby-oci8'
55
+ spec.add_development_dependency 'activerecord-oracle_enhanced-adapter', '~> 8.0.0'
53
56
 
54
57
  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.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Martin
@@ -30,14 +30,20 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
33
+ version: '7.1'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '9.0'
34
37
  type: :development
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
41
  - - ">="
39
42
  - !ruby/object:Gem::Version
40
- version: '0'
43
+ version: '7.1'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '9.0'
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: bundler
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -234,6 +240,34 @@ dependencies:
234
240
  - - ">="
235
241
  - !ruby/object:Gem::Version
236
242
  version: '0'
243
+ - !ruby/object:Gem::Dependency
244
+ name: ruby-oci8
245
+ requirement: !ruby/object:Gem::Requirement
246
+ requirements:
247
+ - - ">="
248
+ - !ruby/object:Gem::Version
249
+ version: '0'
250
+ type: :development
251
+ prerelease: false
252
+ version_requirements: !ruby/object:Gem::Requirement
253
+ requirements:
254
+ - - ">="
255
+ - !ruby/object:Gem::Version
256
+ version: '0'
257
+ - !ruby/object:Gem::Dependency
258
+ name: activerecord-oracle_enhanced-adapter
259
+ requirement: !ruby/object:Gem::Requirement
260
+ requirements:
261
+ - - "~>"
262
+ - !ruby/object:Gem::Version
263
+ version: 8.0.0
264
+ type: :development
265
+ prerelease: false
266
+ version_requirements: !ruby/object:Gem::Requirement
267
+ requirements:
268
+ - - "~>"
269
+ - !ruby/object:Gem::Version
270
+ version: 8.0.0
237
271
  description: A dsl for filtering data in web requests
238
272
  email:
239
273
  - steve@martian.media
@@ -248,6 +282,7 @@ files:
248
282
  - ".rspec"
249
283
  - ".ruby-version"
250
284
  - ".travis.yml"
285
+ - CHANGELOG.md
251
286
  - Gemfile
252
287
  - Gemfile.lock
253
288
  - Guardfile