rokaki 0.10.0 → 0.12.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: 5dac3a2881b83e34f6e0ee5717c4103212b3c4606b0ca05420338544a56cdffd
4
- data.tar.gz: 594b1e1ff90e0a86674ed06e0de962a3cf622814cc87f02317a1da4ddcceb437
3
+ metadata.gz: dcbf82920cbfeff8466f6ccd583f1a66652082e087c2d6641806be8720dd8d6c
4
+ data.tar.gz: 4165d387e1fba3820fc302ef414f0019077067a1dca7d307943030f0aec99207
5
5
  SHA512:
6
- metadata.gz: 1246c456cd8c613d8288a5d6bc69c65eedd67545b854b2185f512b071a04e1341c9bffe9f86a713ab9106842a8b015a73b0f8f55e9d0fecf8741b47e824c015b
7
- data.tar.gz: 2429fb0148ca0705edcf02bb434541968db87a7bda6cd26a0945d1ccec660b6a9ca45ff9f5798aeeedfd4f89371f3c1575f2d0f79d1914a695b4f898d35bf4b4
6
+ metadata.gz: 8e5d866b175e0799ad9bff2cbb8660e8ebca48785e508223e7583452eb7e95e56c1c35f15ff8625fc456365651b82efff40636579f90469d9909f650f6ee2b1d
7
+ data.tar.gz: e974f32a7626a94fb618295ea9600e9747af73bd35986f9e843a81b4bc6168c98180ef85d150fc1ad96ff4fa4700daae5d16d75596bc2d3dd9279ba8d6a98c64
@@ -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
@@ -25,8 +25,24 @@ jobs:
25
25
  - 5432:5432
26
26
  # needed because the postgres container does not provide a healthcheck
27
27
  options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
28
+
29
+ sqlserver:
30
+ image: mcr.microsoft.com/mssql/server:2022-latest
31
+ env:
32
+ ACCEPT_EULA: "Y"
33
+ MSSQL_SA_PASSWORD: "5QL5£rv£r"
34
+ ports:
35
+ - 1433:1433
36
+ # No built-in healthcheck; we'll wait in a step using nc
37
+
28
38
  steps:
29
39
  - uses: actions/checkout@v2
40
+
41
+ - name: Install system dependencies
42
+ run: |
43
+ sudo apt-get update
44
+ sudo apt-get install -y build-essential libpq-dev default-libmysqlclient-dev freetds-dev netcat-openbsd
45
+
30
46
  - name: Set up Ruby
31
47
  uses: ruby/setup-ruby@v1
32
48
  with:
@@ -34,6 +50,20 @@ jobs:
34
50
  ruby-version: 3.3.0
35
51
  # runs 'bundle install' and caches installed gems automatically
36
52
  bundler-cache: true
53
+
54
+ - name: Wait for databases to be ready
55
+ shell: bash
56
+ run: |
57
+ for i in {1..60}; do
58
+ nc -z 127.0.0.1 3306 && echo "MySQL up" && break || sleep 1
59
+ done
60
+ for i in {1..60}; do
61
+ nc -z 127.0.0.1 5432 && echo "Postgres up" && break || sleep 1
62
+ done
63
+ for i in {1..120}; do
64
+ nc -z 127.0.0.1 1433 && echo "SQL Server up" && break || sleep 1
65
+ done
66
+
37
67
  - name: Run tests
38
68
  run: |
39
69
  ./spec/ordered_run.sh
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rokaki (0.10.0)
4
+ rokaki (0.12.0)
5
5
  activesupport
6
6
 
7
7
  GEM
@@ -13,6 +13,9 @@ GEM
13
13
  activemodel (= 8.0.3)
14
14
  activesupport (= 8.0.3)
15
15
  timeout (>= 0.4.0)
16
+ activerecord-sqlserver-adapter (8.0.9)
17
+ activerecord (~> 8.0.0)
18
+ tiny_tds
16
19
  activesupport (8.0.3)
17
20
  base64
18
21
  benchmark (>= 0.3)
@@ -132,6 +135,16 @@ GEM
132
135
  sqlite3 (2.7.4-x86_64-linux-musl)
133
136
  thor (1.4.0)
134
137
  timeout (0.4.3)
138
+ tiny_tds (3.3.0)
139
+ bigdecimal (~> 3)
140
+ tiny_tds (3.3.0-aarch64-linux-gnu)
141
+ bigdecimal (~> 3)
142
+ tiny_tds (3.3.0-aarch64-linux-musl)
143
+ bigdecimal (~> 3)
144
+ tiny_tds (3.3.0-x86_64-linux-gnu)
145
+ bigdecimal (~> 3)
146
+ tiny_tds (3.3.0-x86_64-linux-musl)
147
+ bigdecimal (~> 3)
135
148
  tzinfo (2.0.6)
136
149
  concurrent-ruby (~> 1.0)
137
150
  uri (1.0.3)
@@ -152,6 +165,7 @@ PLATFORMS
152
165
 
153
166
  DEPENDENCIES
154
167
  activerecord
168
+ activerecord-sqlserver-adapter
155
169
  bundler (~> 2.0)
156
170
  database_cleaner-active_record
157
171
  factory_bot
@@ -165,6 +179,7 @@ DEPENDENCIES
165
179
  rokaki!
166
180
  rspec (~> 3.0)
167
181
  sqlite3
182
+ tiny_tds
168
183
 
169
184
  BUNDLED WITH
170
185
  2.5.3
data/README.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # Rokaki
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/rokaki.svg)](https://badge.fury.io/rb/rokaki)
4
+ [![Run RSpec tests](https://github.com/tevio/rokaki/actions/workflows/spec.yml/badge.svg)](https://github.com/tevio/rokaki/actions/workflows/spec.yml)
4
5
 
5
- This gem was born out of a desire to dry up filtering services in Rails apps or any Ruby app that uses the concept of "filters" or "facets".
6
+ This gem was written to dry up filtering services in ActiveRecord based Rails apps or any plain Ruby app looking to implement "filters" or "faceted" search.
6
7
 
7
- There are two modes of use `Filterable` and `FilterModel` that can be activated through the use of two mixins respectively, `include Rokaki::Filterable` or `include Rokaki::FilterModel`.
8
+ The overall vision is to abstract away all of the lower level repetitive SQL and relational code to allow you to write model filters in a simple, relatively intuitive way, using ruby hashes and arrays mostly.
9
+
10
+ The DSL allows you to construct complex search models to filter results through without writing any SQL. I would recommend the reader to consult the specs in order to understand the features and syntax in detail, an intermediate understanding of Ruby and rspec TDD, and basic relational logic are recommended.
11
+
12
+ There are two modes of use, `Filterable` (designed for plain Ruby) and `FilterModel` (designed for Rails) that can be activated through the use of two mixins respectively, `include Rokaki::Filterable` or `include Rokaki::FilterModel`.
8
13
  ## Installation
9
14
 
10
15
  Add this line to your application's Gemfile:
@@ -137,7 +142,7 @@ You can specify several configuration options, for example a `filter_key_prefix`
137
142
  ## `Rokaki::FilterModel` - Usage
138
143
 
139
144
  ### ActiveRecord
140
- Include `Rokaki::FilterModel` in any ActiveRecord model (only AR >= 6.0.0 tested so far) you can generate the filter keys and the actual filter lookup code using the `filters` keyword on a model like so:-
145
+ Include `Rokaki::FilterModel` in any ActiveRecord model (only AR >= 8.0.3 tested so far) you can generate the filter keys and the actual filter lookup code using the `filters` keyword on a model like so:-
141
146
 
142
147
  ```ruby
143
148
  # Given the models
@@ -173,7 +178,7 @@ You can also filter collections of fields, simply pass an array of filter values
173
178
 
174
179
 
175
180
  ### Partial matching
176
- You can use `like` (or, if you use postgres, the case insensitive `ilike`) to perform a partial match on a specific field, there are 3 options:- `:prefix`, `:circumfix` and `:suffix`. There are two syntaxes you can use for this:-
181
+ You can use `like` or the case insensitive `ilike` to perform a partial match on a specific field, there are 3 options:- `:prefix`, `:circumfix` and `:suffix`. There are two syntaxes you can use for this:-
177
182
 
178
183
  #### 1. The `filter` command syntax
179
184
 
@@ -183,7 +188,7 @@ class ArticleFilter
183
188
  include Rokaki::FilterModel
184
189
 
185
190
  filter :article,
186
- like: { # you can use ilike here instead if you use postgres and want case insensitive results
191
+ like: { # you can use ilike here instead if you want case insensitive results
187
192
  author: {
188
193
  first_name: :circumfix,
189
194
  last_name: :circumfix
@@ -323,7 +328,7 @@ class ArticleFilter
323
328
 
324
329
  filters :date, :title, author: [:first_name, :last_name]
325
330
  like title: :circumfix
326
- # ilike title: :circumfix # case insensitive postgres mode
331
+ # ilike title: :circumfix # case insensitive mode
327
332
 
328
333
  attr_accessor :filters
329
334
 
@@ -436,7 +441,13 @@ docker pull mysql
436
441
  docker run --name rokaki-mysql -e MYSQL_ROOT_PASSWORD=rokaki -e MYSQL_PASSWORD=rokaki -e MYSQL_DATABASE=rokaki -e MYSQL_USER=rokaki -d -p 3306:3306 mysql:latest mysqld
437
442
  ```
438
443
 
439
- Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
444
+ ### Specialised test runner
445
+ The test suite is designed to run against all supported database backends, since this can cause problems with timing and connection pools, the recommended way to run the tests is via a shell script.
446
+
447
+ From the rokaki root directory run: `./spec/ordered_run.sh`. This is the same script that runs on the Github CI here: [![Run RSpec tests](https://github.com/tevio/rokaki/actions/workflows/spec.yml/badge.svg)](https://github.com/tevio/rokaki/actions/workflows/spec.yml)
448
+
449
+ ### Standard test runner (only recommended for development cycles)
450
+ You can still run `rake spec` to run the tests, there's no guarantee they will all pass due to race conditions from using multiple db backends (see above), but this mode is recommended for focusing on specific backends or tests during development (comment out what you don't want). You can also run `bin/console` for an interactive prompt that will allow you to experiment.
440
451
 
441
452
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
442
453
 
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,75 @@
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.11"
25
+ ```
26
+
27
+ Then:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ Use `Rokaki::FilterModel` and declare mappings with method arguments (no block DSL).
36
+
37
+ ```ruby
38
+ class ArticleQuery
39
+ include Rokaki::FilterModel
40
+
41
+ # Tell Rokaki which model to query and which DB adapter semantics to use
42
+ filter_model :article, db: :postgres # or :mysql, :sqlserver
43
+
44
+ # Map a single query key (:q) to multiple LIKE targets on Article
45
+ define_query_key :q
46
+ like title: :circumfix, content: :circumfix
47
+
48
+ # Nested LIKEs on associated models are expressed with hashes
49
+ like author: { first_name: :prefix, last_name: :suffix }
50
+
51
+ attr_accessor :filters
52
+ def initialize(filters: {})
53
+ @filters = filters
54
+ end
55
+ end
56
+
57
+ # In a controller/service:
58
+ filtered = ArticleQuery.new(filters: params).results
59
+ ```
60
+
61
+ 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
+
63
+ ## Matching modes
64
+
65
+ - prefix: matches values that start with given term(s)
66
+ - suffix: matches values that end with given term(s)
67
+ - circumfix: matches values that contain given term(s)
68
+
69
+ All modes accept either a single string or an array of terms.
70
+
71
+ ## Next steps
72
+
73
+ - Learn the full DSL and examples in [Usage](./usage)
74
+ - See adapter specifics (PostgreSQL/MySQL/SQL Server) in [Database adapters](./adapters)
75
+ - Configure connections and environment variables in [Configuration](./configuration)
data/docs/usage.md ADDED
@@ -0,0 +1,100 @@
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.11"
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 (no block DSL)
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`).
@@ -38,6 +38,10 @@ module Rokaki
38
38
  'LIKE'
39
39
  elsif db == :mysql
40
40
  'LIKE BINARY'
41
+ elsif db == :sqlserver
42
+ 'LIKE'
43
+ else
44
+ 'LIKE'
41
45
  end
42
46
  end
43
47
 
@@ -46,6 +50,10 @@ module Rokaki
46
50
  'ILIKE'
47
51
  elsif db == :mysql
48
52
  'LIKE'
53
+ elsif db == :sqlserver
54
+ 'LIKE'
55
+ else
56
+ 'LIKE'
49
57
  end
50
58
  end
51
59
 
@@ -94,6 +102,9 @@ module Rokaki
94
102
  if db == :postgres
95
103
  query = "@model.where(\"#{key} #{type} ANY (ARRAY[?])\", "
96
104
  query += "prepare_terms(#{filter}, :#{mode}))"
105
+ elsif db == :sqlserver
106
+ # Delegate to helper that supports arrays and escaping with ESCAPE
107
+ query = "sqlserver_like(@model, \"#{key}\", \"#{type}\", #{filter}, :#{mode})"
97
108
  else
98
109
  query = "@model.where(\"#{key} #{type} :query\", "
99
110
  query += "query: \"%\#{#{filter}}%\")" if mode == :circumfix
@@ -70,6 +70,10 @@ module Rokaki
70
70
  'LIKE'
71
71
  elsif db == :mysql
72
72
  'LIKE BINARY'
73
+ elsif db == :sqlserver
74
+ 'LIKE'
75
+ else
76
+ 'LIKE'
73
77
  end
74
78
  end
75
79
 
@@ -78,6 +82,10 @@ module Rokaki
78
82
  'ILIKE'
79
83
  elsif db == :mysql
80
84
  'LIKE'
85
+ elsif db == :sqlserver
86
+ 'LIKE'
87
+ else
88
+ 'LIKE'
81
89
  end
82
90
  end
83
91
 
@@ -131,19 +139,27 @@ module Rokaki
131
139
  where = where.join
132
140
 
133
141
  if search_mode
134
- query = build_like_query(
135
- type: type,
136
- query: '',
137
- filter: "#{prefix}#{name}",
138
- search_mode: search_mode,
139
- key: keys.last.to_s.pluralize,
140
- leaf: leaf
141
- )
142
+ if db == :sqlserver
143
+ key_leaf = "#{keys.last.to_s.pluralize}.#{leaf}"
144
+ @filter_methods << "def #{prefix}filter#{infix}#{name};"\
145
+ "sqlserver_like(@model.joins(#{joins}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
142
146
 
143
- @filter_methods << "def #{prefix}filter#{infix}#{name};"\
144
- "@model.joins(#{joins}).#{query}; end;"
145
-
146
- @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
147
+ @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
148
+ else
149
+ query = build_like_query(
150
+ type: type,
151
+ query: '',
152
+ filter: "#{prefix}#{name}",
153
+ search_mode: search_mode,
154
+ key: keys.last.to_s.pluralize,
155
+ leaf: leaf
156
+ )
157
+
158
+ @filter_methods << "def #{prefix}filter#{infix}#{name};"\
159
+ "@model.joins(#{joins}).#{query}; end;"
160
+
161
+ @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
162
+ end
147
163
  else
148
164
  @filter_methods << "def #{prefix}filter#{infix}#{name};"\
149
165
  "@model.joins(#{joins}).where(#{where}); end;"
@@ -194,22 +194,37 @@ module Rokaki
194
194
  leaf = nil
195
195
  leaf = keys.pop
196
196
 
197
+ # Compute key_leaf (qualified column) like other branches
198
+ key_leaf = keys.last ? "#{keys.last.to_s.pluralize}.#{leaf}" : leaf
199
+
200
+ if db == :sqlserver
201
+ # Build relation base with joins
202
+ if join_map.empty?
203
+ rel_expr = "@model"
204
+ elsif join_map.is_a?(Array)
205
+ rel_expr = "@model.joins(*#{join_map})"
206
+ else
207
+ rel_expr = "@model.joins(**#{join_map})"
208
+ end
197
209
 
198
- query = build_like_query(
199
- type: type,
200
- query: '',
201
- filter: filter_name,
202
- search_mode: search_mode,
203
- key: keys.last,
204
- leaf: leaf
205
- )
206
-
207
- if join_map.empty?
208
- filter_query = "@model.#{query}"
209
- elsif join_map.is_a?(Array)
210
- filter_query = "@model.joins(*#{join_map}).#{query}"
210
+ filter_query = "sqlserver_like(#{rel_expr}, \"#{key_leaf}\", \"#{type.to_s.upcase}\", #{filter_name}, :#{search_mode})"
211
211
  else
212
- filter_query = "@model.joins(**#{join_map}).#{query}"
212
+ query = build_like_query(
213
+ type: type,
214
+ query: '',
215
+ filter: filter_name,
216
+ search_mode: search_mode,
217
+ key: keys.last,
218
+ leaf: leaf
219
+ )
220
+
221
+ if join_map.empty?
222
+ filter_query = "@model.#{query}"
223
+ elsif join_map.is_a?(Array)
224
+ filter_query = "@model.joins(*#{join_map}).#{query}"
225
+ else
226
+ filter_query = "@model.joins(**#{join_map}).#{query}"
227
+ end
213
228
  end
214
229
 
215
230
  if mode == or_key
@@ -225,9 +240,20 @@ module Rokaki
225
240
  if db == :postgres
226
241
  query = "where(\"#{key_leaf} #{type.to_s.upcase} ANY (ARRAY[?])\", "
227
242
  query += "prepare_terms(#{filter}, :#{search_mode}))"
228
- else
243
+ elsif db == :mysql
229
244
  query = "where(\"#{key_leaf} #{type.to_s.upcase} :query\", "
230
245
  query += "query: prepare_regex_terms(#{filter}, :#{search_mode}))"
246
+ else # :sqlserver and others
247
+ query = "where(\"#{key_leaf} #{type.to_s.upcase} :query\", "
248
+ if search_mode == :circumfix
249
+ query += "query: \"%\#{#{filter}}%\")"
250
+ elsif search_mode == :prefix
251
+ query += "query: \"%\#{#{filter}}\")"
252
+ elsif search_mode == :suffix
253
+ query += "query: \"\#{#{filter}}%\")"
254
+ else
255
+ query += "query: \"%\#{#{filter}}%\")"
256
+ end
231
257
  end
232
258
 
233
259
  query
@@ -19,6 +19,56 @@ module Rokaki
19
19
  end
20
20
  end
21
21
 
22
+ # Escape special LIKE characters in SQL Server patterns: %, _, [ and \\
23
+ def escape_like(term)
24
+ term.to_s.gsub(/[\\%_\[]/) { |m| "\\#{m}" }
25
+ end
26
+
27
+ # Build LIKE patterns with proper prefix/suffix/circumfix and escaping for SQL Server
28
+ # Returns a String when param is scalar, or an Array of Strings when param is an Array
29
+ def prepare_like_terms(param, mode)
30
+ if Array === param
31
+ case mode
32
+ when :circumfix
33
+ param.map { |t| "%#{escape_like(t)}%" }
34
+ when :prefix
35
+ param.map { |t| "%#{escape_like(t)}" }
36
+ when :suffix
37
+ param.map { |t| "#{escape_like(t)}%" }
38
+ else
39
+ param.map { |t| "%#{escape_like(t)}%" }
40
+ end
41
+ else
42
+ case mode
43
+ when :circumfix
44
+ "%#{escape_like(param)}%"
45
+ when :prefix
46
+ "%#{escape_like(param)}"
47
+ when :suffix
48
+ "#{escape_like(param)}%"
49
+ else
50
+ "%#{escape_like(param)}%"
51
+ end
52
+ end
53
+ end
54
+
55
+ # Compose a SQL Server LIKE relation supporting arrays of terms (OR chained)
56
+ # column should be a fully qualified column expression, e.g., "authors.first_name" or "cs.title"
57
+ # type is usually "LIKE"
58
+ def sqlserver_like(model, column, type, value, mode)
59
+ terms = prepare_like_terms(value, mode)
60
+ if terms.is_a?(Array)
61
+ return model.none if terms.empty?
62
+ rel = model.where("#{column} #{type} :q0 ESCAPE '\\'", q0: terms[0])
63
+ terms[1..-1]&.each_with_index do |t, i|
64
+ rel = rel.or(model.where("#{column} #{type} :q#{i + 1} ESCAPE '\\'", "q#{i + 1}".to_sym => t))
65
+ end
66
+ rel
67
+ else
68
+ model.where("#{column} #{type} :q ESCAPE '\\'", q: terms)
69
+ end
70
+ end
71
+
22
72
  def prepare_regex_terms(param, mode)
23
73
  if Array === param
24
74
  param_map = param.map { |term| ".*#{term}.*" } if mode == :circumfix
@@ -53,6 +103,23 @@ module Rokaki
53
103
 
54
104
  private
55
105
 
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
+
56
123
  def filter_map(model, query_key, options)
57
124
  filter_model(model)
58
125
  @filter_map_query_key = query_key
@@ -187,6 +254,10 @@ module Rokaki
187
254
  elsif @_filter_db == :mysql
188
255
  # 'LIKE BINARY'
189
256
  'REGEXP'
257
+ elsif @_filter_db == :sqlserver
258
+ 'LIKE'
259
+ else
260
+ 'LIKE'
190
261
  end
191
262
  end
192
263
 
@@ -196,22 +267,28 @@ module Rokaki
196
267
  elsif @_filter_db == :mysql
197
268
  # 'LIKE'
198
269
  'REGEXP'
270
+ elsif @_filter_db == :sqlserver
271
+ 'LIKE'
272
+ else
273
+ 'LIKE'
199
274
  end
200
275
  end
201
276
 
202
277
  def like(args)
203
278
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
204
- @_like_semantics = (@_like_semantics || {}).merge(args)
279
+ normalized = normalize_like_modes(args)
280
+ @_like_semantics = (@_like_semantics || {}).merge(normalized)
205
281
 
206
- like_keys = LikeKeys.new(args)
282
+ like_keys = LikeKeys.new(normalized)
207
283
  like_filters(like_keys, term_type: case_sensitive)
208
284
  end
209
285
 
210
286
  def ilike(args)
211
287
  raise ArgumentError, 'argument mush be a hash' unless args.is_a? Hash
212
- @i_like_semantics = (@i_like_semantics || {}).merge(args)
288
+ normalized = normalize_like_modes(args)
289
+ @i_like_semantics = (@i_like_semantics || {}).merge(normalized)
213
290
 
214
- like_keys = LikeKeys.new(args)
291
+ like_keys = LikeKeys.new(normalized)
215
292
  like_filters(like_keys, term_type: case_insensitive)
216
293
  end
217
294
 
@@ -1,3 +1,3 @@
1
1
  module Rokaki
2
- VERSION = "0.10.0"
2
+ VERSION = "0.12.0"
3
3
  end
data/rokaki.gemspec CHANGED
@@ -47,5 +47,8 @@ Gem::Specification.new do |spec|
47
47
  spec.add_development_dependency 'mysql2'
48
48
  spec.add_development_dependency 'sqlite3'
49
49
  spec.add_development_dependency 'database_cleaner-active_record'
50
+ # For SQL Server testing
51
+ spec.add_development_dependency 'tiny_tds'
52
+ spec.add_development_dependency 'activerecord-sqlserver-adapter'
50
53
 
51
54
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rokaki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.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-02 00:00:00.000000000 Z
11
+ date: 2025-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -206,6 +206,34 @@ dependencies:
206
206
  - - ">="
207
207
  - !ruby/object:Gem::Version
208
208
  version: '0'
209
+ - !ruby/object:Gem::Dependency
210
+ name: tiny_tds
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ - !ruby/object:Gem::Dependency
224
+ name: activerecord-sqlserver-adapter
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
209
237
  description: A dsl for filtering data in web requests
210
238
  email:
211
239
  - steve@martian.media
@@ -214,12 +242,12 @@ extensions: []
214
242
  extra_rdoc_files: []
215
243
  files:
216
244
  - ".github/workflows/codeql-analysis.yml"
245
+ - ".github/workflows/pages.yml"
217
246
  - ".github/workflows/spec.yml"
218
247
  - ".gitignore"
219
248
  - ".rspec"
220
249
  - ".ruby-version"
221
250
  - ".travis.yml"
222
- - CODE_OF_CONDUCT.md
223
251
  - Gemfile
224
252
  - Gemfile.lock
225
253
  - Guardfile
@@ -228,6 +256,12 @@ files:
228
256
  - Rakefile
229
257
  - bin/console
230
258
  - bin/setup
259
+ - docs/Gemfile
260
+ - docs/_config.yml
261
+ - docs/adapters.md
262
+ - docs/configuration.md
263
+ - docs/index.md
264
+ - docs/usage.md
231
265
  - lib/rokaki.rb
232
266
  - lib/rokaki/filter_model.rb
233
267
  - lib/rokaki/filter_model/basic_filter.rb
data/CODE_OF_CONDUCT.md DELETED
@@ -1,74 +0,0 @@
1
- # Contributor Covenant Code of Conduct
2
-
3
- ## Our Pledge
4
-
5
- In the interest of fostering an open and welcoming environment, we as
6
- contributors and maintainers pledge to making participation in our project and
7
- our community a harassment-free experience for everyone, regardless of age, body
8
- size, disability, ethnicity, gender identity and expression, level of experience,
9
- nationality, personal appearance, race, religion, or sexual identity and
10
- orientation.
11
-
12
- ## Our Standards
13
-
14
- Examples of behavior that contributes to creating a positive environment
15
- include:
16
-
17
- * Using welcoming and inclusive language
18
- * Being respectful of differing viewpoints and experiences
19
- * Gracefully accepting constructive criticism
20
- * Focusing on what is best for the community
21
- * Showing empathy towards other community members
22
-
23
- Examples of unacceptable behavior by participants include:
24
-
25
- * The use of sexualized language or imagery and unwelcome sexual attention or
26
- advances
27
- * Trolling, insulting/derogatory comments, and personal or political attacks
28
- * Public or private harassment
29
- * Publishing others' private information, such as a physical or electronic
30
- address, without explicit permission
31
- * Other conduct which could reasonably be considered inappropriate in a
32
- professional setting
33
-
34
- ## Our Responsibilities
35
-
36
- Project maintainers are responsible for clarifying the standards of acceptable
37
- behavior and are expected to take appropriate and fair corrective action in
38
- response to any instances of unacceptable behavior.
39
-
40
- Project maintainers have the right and responsibility to remove, edit, or
41
- reject comments, commits, code, wiki edits, issues, and other contributions
42
- that are not aligned to this Code of Conduct, or to ban temporarily or
43
- permanently any contributor for other behaviors that they deem inappropriate,
44
- threatening, offensive, or harmful.
45
-
46
- ## Scope
47
-
48
- This Code of Conduct applies both within project spaces and in public spaces
49
- when an individual is representing the project or its community. Examples of
50
- representing a project or community include using an official project e-mail
51
- address, posting via an official social media account, or acting as an appointed
52
- representative at an online or offline event. Representation of a project may be
53
- further defined and clarified by project maintainers.
54
-
55
- ## Enforcement
56
-
57
- Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
- reported by contacting the project team at steve@martian.media. All
59
- complaints will be reviewed and investigated and will result in a response that
60
- is deemed necessary and appropriate to the circumstances. The project team is
61
- obligated to maintain confidentiality with regard to the reporter of an incident.
62
- Further details of specific enforcement policies may be posted separately.
63
-
64
- Project maintainers who do not follow or enforce the Code of Conduct in good
65
- faith may face temporary or permanent repercussions as determined by other
66
- members of the project's leadership.
67
-
68
- ## Attribution
69
-
70
- This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
- available at [http://contributor-covenant.org/version/1/4][version]
72
-
73
- [homepage]: http://contributor-covenant.org
74
- [version]: http://contributor-covenant.org/version/1/4/