rokaki 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/Gemfile.lock +1 -1
- data/README.md +19 -0
- data/docs/adapters.md +18 -0
- data/docs/dsl_syntax.md +196 -0
- data/docs/index.md +2 -1
- data/docs/oracle.md +138 -0
- data/docs/usage.md +79 -1
- data/lib/rokaki/filter_model/basic_filter.rb +107 -2
- data/lib/rokaki/filter_model/nested_filter.rb +63 -11
- data/lib/rokaki/version.rb +1 -1
- metadata +4 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 52ffc6e9a926824c6730632c566d42570997aa5e2a2674ea07e79d0722a628fc
         | 
| 4 | 
            +
              data.tar.gz: 248ecf8294fe48488f0b8a8990f1602ab7cd3c10af95731be2a8e3f0933905c8
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: ff1071aaa8e6a151fa0382ec294df042a4afb5d8c8e00bd5a25fe2ffdc67ba54971b649edc5cfd3019895a44c64ee2b0ce352032f8f24d2a525a3cd2ef6dadd0
         | 
| 7 | 
            +
              data.tar.gz: 99dcd53ddb3c068522a7a2eba6b262c8f5bfb9bc78433596e4d15a8ea1363e31895be3b2c39270276955a802fe2198ae2d74ddba4d60a063519903ace4f71263
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -1,4 +1,13 @@ | |
| 1 1 | 
             
            ### Unreleased
         | 
| 2 | 
            +
            - (no changes yet)
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            ### 0.17.0 — 2025-10-28
         | 
| 5 | 
            +
            - Version bump to 0.17.0.
         | 
| 6 | 
            +
            - Documentation: Updated installation snippets to `~> 0.17`.
         | 
| 7 | 
            +
            - Documentation: Added comprehensive Range/BETWEEN/MIN/MAX filter docs across README and site (usage, adapters). Clarified sub-key aliases (`between`, `from`/`since`/`after`/`start`/`min`, `to`/`until`/`before`/`end`/`max`), accepted value shapes (Range, 2‑element Array, Hash), nested examples, and equality array semantics (`IN` vs `BETWEEN`).
         | 
| 8 | 
            +
            - Added Range/BETWEEN/MIN/MAX filters
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            ### 0.16.0 — 2025-10-27
         | 
| 2 11 | 
             
            - Documentation: Added backend auto‑detection feature docs across README and site (index, usage, adapters, configuration). Examples now prefer auto‑detection by default and explain explicit overrides and ambiguity errors.
         | 
| 3 12 | 
             
            - Tests: Added shared examples to exercise auto‑detection behavior under each adapter suite.
         | 
| 4 13 |  | 
    
        data/Gemfile.lock
    CHANGED
    
    
    
        data/README.md
    CHANGED
    
    | @@ -38,6 +38,25 @@ Docs | |
| 38 38 |  | 
| 39 39 | 
             
            Tip: For a dynamic runtime listener (build a filter class from a JSON/hash payload at runtime), see “Dynamic runtime listener” in the Usage docs.
         | 
| 40 40 |  | 
| 41 | 
            +
            ## Range filters (between/min/max)
         | 
| 42 | 
            +
             | 
| 43 | 
            +
            Use the field name as the key and the filter type as a sub-key, or pass a `Range` directly. Aliases are supported.
         | 
| 44 | 
            +
             | 
| 45 | 
            +
            ```ruby
         | 
| 46 | 
            +
            # Top-level
         | 
| 47 | 
            +
            Article.filter(published: { from: Date.new(2024,1,1), to: Date.new(2024,12,31) })
         | 
| 48 | 
            +
            Article.filter(published: (Date.new(2024,1,1)..Date.new(2024,12,31)))
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            # Nested
         | 
| 51 | 
            +
            Article.filter(reviews_published: { max: Time.utc(2024,6,30) })
         | 
| 52 | 
            +
            ```
         | 
| 53 | 
            +
             | 
| 54 | 
            +
            - Lower bound aliases (>=): `from`, `since`, `after`, `start`, `min`
         | 
| 55 | 
            +
            - Upper bound aliases (<=): `to`, `until`, `before`, `end`, `max`
         | 
| 56 | 
            +
            - Arrays always mean `IN (?)` for equality. Use a `Range` or `{ between: [from, to] }` for range filtering
         | 
| 57 | 
            +
             | 
| 58 | 
            +
            See full docs: https://tevio.github.io/rokaki/usage#range-between-min-and-max-filters
         | 
| 59 | 
            +
             | 
| 41 60 | 
             
            ---
         | 
| 42 61 |  | 
| 43 62 | 
             
            ## Further reading
         | 
    
        data/docs/adapters.md
    CHANGED
    
    | @@ -22,6 +22,7 @@ Rokaki generates adapter‑aware SQL for PostgreSQL, MySQL, SQL Server, Oracle, | |
| 22 22 | 
             
              - Case sensitivity follows DB collation by default; future versions may add inline `COLLATE` options
         | 
| 23 23 | 
             
            - Oracle
         | 
| 24 24 | 
             
              - Uses `LIKE`; arrays of terms are OR‑chained; case‑insensitive paths use `UPPER(column) LIKE UPPER(:q)`
         | 
| 25 | 
            +
              - See the dedicated page: [Oracle connections](/adapters/oracle) for connection strings, NLS settings, and common errors.
         | 
| 25 26 | 
             
            - SQLite
         | 
| 26 27 | 
             
              - Embedded (no separate server needed)
         | 
| 27 28 | 
             
              - Uses `LIKE`; arrays of terms are OR‑chained across predicates
         | 
| @@ -78,3 +79,20 @@ database: ":memory:" | |
| 78 79 | 
             
            ```
         | 
| 79 80 |  | 
| 80 81 | 
             
            To persist a database file locally, set `SQLITE_DATABASE` to a path (e.g., `tmp/test.sqlite3`).
         | 
| 82 | 
            +
             | 
| 83 | 
            +
             | 
| 84 | 
            +
            ## Range/BETWEEN filters
         | 
| 85 | 
            +
             | 
| 86 | 
            +
            Rokaki’s range filters (`between`, lower-bound aliases like `from`/`min`, and upper-bound aliases like `to`/`max`) are adapter‑agnostic. The library always generates parameterized predicates using `BETWEEN`, `>=`, and `<=` on the target column.
         | 
| 87 | 
            +
             | 
| 88 | 
            +
            Adapter notes:
         | 
| 89 | 
            +
            - PostgreSQL: Uses regular `WHERE column BETWEEN $1 AND $2` (or `>=`/`<=`). No special handling is required.
         | 
| 90 | 
            +
            - MySQL/MariaDB: Uses `BETWEEN ? AND ?` (or `>=`/`<=`). Datetime values are compared with the column precision configured by your schema.
         | 
| 91 | 
            +
            - SQLite: Uses `BETWEEN ? AND ?` (or `>=`/`<=`).
         | 
| 92 | 
            +
            - SQL Server: Uses `BETWEEN @from AND @to` (or `>=`/`<=`). Parameters are bound via ActiveRecord.
         | 
| 93 | 
            +
            - Oracle: Uses `BETWEEN :from AND :to` (or `>=`/`<=`). If your column type is `DATE`, be aware it has second precision; `TIMESTAMP` supports fractional seconds.
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            Tips:
         | 
| 96 | 
            +
            - For date-only upper bounds (e.g., `2024-12-31`), Rokaki treats them inclusively and, when applicable, will extend to the end of day in basic filters to match expectations. If you need precise control, pass explicit `Time` values.
         | 
| 97 | 
            +
            - Arrays are treated as equality lists (`IN (?)`) across all adapters. Use a `Range` or `{ between: [from, to] }` for range filtering.
         | 
| 98 | 
            +
            - `nil` bounds are ignored: only the provided side is applied.
         | 
    
        data/docs/dsl_syntax.md
    ADDED
    
    | @@ -0,0 +1,196 @@ | |
| 1 | 
            +
            ---
         | 
| 2 | 
            +
            layout: page
         | 
| 3 | 
            +
            title: Rokaki's DSL Syntax
         | 
| 4 | 
            +
            permalink: /dsl-syntax
         | 
| 5 | 
            +
            ---
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            This page describes Rokaki’s domain-specific language (DSL) for declaring mappings and how incoming payloads are interpreted, with a focus on the difference between join-structure keys and leaf-level field keys.
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            The same concepts apply to both DSL entry points:
         | 
| 10 | 
            +
            - FilterModel (querying a specific ActiveRecord model)
         | 
| 11 | 
            +
            - Filterable (key mapping only)
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            See also: Usage, Adapters, and Configuration pages linked from the site index.
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            ## Key ideas
         | 
| 16 | 
            +
             | 
| 17 | 
            +
            - You declare the shape of your filterable graph in code. This defines which associations (joins) are traversed and which fields are addressable (leaves).
         | 
| 18 | 
            +
            - At runtime, the payload mirrors only the declared structure. Values at leaves drive the operator semantics (equality, LIKE, range). The structure of joins does not change at runtime.
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            ## Join-structure keys vs leaf-level field keys
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            - Join-structure keys represent associations. They appear only in the mapping you write (and mirrored by the payload). They do not carry operators by themselves. Examples: `author`, `reviews`, `articles`.
         | 
| 23 | 
            +
            - Leaf-level field keys represent actual database columns on the current model or on a joined association. Examples: `title`, `content`, `published`, `first_name`.
         | 
| 24 | 
            +
            - The mapping defines where a key is treated as a join (non-leaf) vs a field (leaf). At a declared leaf, the value can be a scalar, array, range, or an operator-hash (see below). Rokaki will not traverse deeper than the declared leaf.
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            ## Declaring mappings
         | 
| 27 | 
            +
             | 
| 28 | 
            +
            Two equivalent styles are supported:
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            ### Argument-based form (classic)
         | 
| 31 | 
            +
             | 
| 32 | 
            +
            ```ruby
         | 
| 33 | 
            +
            class ArticleQuery
         | 
| 34 | 
            +
              include Rokaki::FilterModel
         | 
| 35 | 
            +
             | 
| 36 | 
            +
              filter_model :article               # Adapter auto-detected; pass db: if needed
         | 
| 37 | 
            +
              define_query_key :q                 # Map a single query key to many fields
         | 
| 38 | 
            +
             | 
| 39 | 
            +
              like title: :circumfix, content: :circumfix # LIKE mappings (no modes: option)
         | 
| 40 | 
            +
             | 
| 41 | 
            +
              # Nested LIKEs and filters via association-shaped hashes
         | 
| 42 | 
            +
              like author: { first_name: :prefix, last_name: :suffix }
         | 
| 43 | 
            +
             | 
| 44 | 
            +
              # Declare equality/range-capable fields (leafs)
         | 
| 45 | 
            +
              filters :published                  # enables :published in payload
         | 
| 46 | 
            +
              filters reviews: :published         # enables nested reviews.published
         | 
| 47 | 
            +
             | 
| 48 | 
            +
              attr_accessor :filters
         | 
| 49 | 
            +
              def initialize(filters: {})
         | 
| 50 | 
            +
                @filters = filters
         | 
| 51 | 
            +
              end
         | 
| 52 | 
            +
            end
         | 
| 53 | 
            +
            ```
         | 
| 54 | 
            +
             | 
| 55 | 
            +
            ### Block-form DSL
         | 
| 56 | 
            +
             | 
| 57 | 
            +
            ```ruby
         | 
| 58 | 
            +
            class ArticleQuery
         | 
| 59 | 
            +
              include Rokaki::FilterModel
         | 
| 60 | 
            +
             | 
| 61 | 
            +
              filter_model :article
         | 
| 62 | 
            +
              define_query_key :q
         | 
| 63 | 
            +
             | 
| 64 | 
            +
              filter_map do
         | 
| 65 | 
            +
                like title: :circumfix, content: :circumfix
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                nested :author do
         | 
| 68 | 
            +
                  like first_name: :prefix, last_name: :suffix
         | 
| 69 | 
            +
                  filters :id         # leaf field under author
         | 
| 70 | 
            +
                end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                nested :reviews do
         | 
| 73 | 
            +
                  filters :published  # leaf field under reviews
         | 
| 74 | 
            +
                end
         | 
| 75 | 
            +
              end
         | 
| 76 | 
            +
             | 
| 77 | 
            +
              attr_accessor :filters
         | 
| 78 | 
            +
              def initialize(filters: {})
         | 
| 79 | 
            +
                @filters = filters
         | 
| 80 | 
            +
              end
         | 
| 81 | 
            +
            end
         | 
| 82 | 
            +
            ```
         | 
| 83 | 
            +
             | 
| 84 | 
            +
            ## Payload rules (what values mean at a leaf)
         | 
| 85 | 
            +
             | 
| 86 | 
            +
            At a leaf field (e.g., `published` or `reviews.published`):
         | 
| 87 | 
            +
             | 
| 88 | 
            +
            - Scalar value → equality on the column
         | 
| 89 | 
            +
              - Example: `{ published: Time.utc(2024,1,1) }` → `WHERE published = :v`
         | 
| 90 | 
            +
             | 
| 91 | 
            +
            - Array value → equality `IN` list
         | 
| 92 | 
            +
              - Arrays always mean `IN` across adapters.
         | 
| 93 | 
            +
              - Example: `{ published: [t1, t2, t3] }` → `WHERE published IN (?, ?, ?)`
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            - Range (`a..b`) → between
         | 
| 96 | 
            +
              - Example: `{ published: (t1..t2) }` → `WHERE published BETWEEN :from AND :to`
         | 
| 97 | 
            +
             | 
| 98 | 
            +
            - Operator-hash (range-style keys) → between or open-ended bounds
         | 
| 99 | 
            +
              - Reserved keys at the leaf indicate operator semantics:
         | 
| 100 | 
            +
                - `between`
         | 
| 101 | 
            +
                - Lower-bound aliases (>=): `from`, `since`, `after`, `start`, `min`
         | 
| 102 | 
            +
                - Upper-bound aliases (<=): `to`, `until`, `before`, `end`, `max`
         | 
| 103 | 
            +
              - Examples:
         | 
| 104 | 
            +
                - `{ published: { from: t1, to: t2 } }`
         | 
| 105 | 
            +
                - `{ published: { between: [t1, t2] } }`
         | 
| 106 | 
            +
                - `{ published: { min: t1 } }` → `published >= t1`
         | 
| 107 | 
            +
                - `{ published: { max: t2 } }` → `published <= t2`
         | 
| 108 | 
            +
             | 
| 109 | 
            +
            Notes:
         | 
| 110 | 
            +
            - Only the leaf level interprets these reserved keys. Join-structure keys do not carry operators.
         | 
| 111 | 
            +
            - Arrays never imply range; to express a range with an array, use `{ published: { between: [from, to] } }`.
         | 
| 112 | 
            +
            - Nil bounds are ignored: `{ published: { from: t1 } }` applies only the lower bound.
         | 
| 113 | 
            +
             | 
| 114 | 
            +
            ## LIKE mappings and payloads
         | 
| 115 | 
            +
             | 
| 116 | 
            +
            - You declare LIKE semantics in code via the `like` mapping; the payload provides the terms.
         | 
| 117 | 
            +
            - Modes:
         | 
| 118 | 
            +
              - `:prefix` → `%term`
         | 
| 119 | 
            +
              - `:suffix` → `term%`
         | 
| 120 | 
            +
              - `:circumfix` (synonyms: `:parafix`, `:confix`, `:ambifix`) → `%term%`
         | 
| 121 | 
            +
            - Payload values for LIKE can be a string or an array of strings. Arrays are matched with adapter-aware OR semantics.
         | 
| 122 | 
            +
             | 
| 123 | 
            +
            Examples:
         | 
| 124 | 
            +
            ```ruby
         | 
| 125 | 
            +
            like title: :circumfix
         | 
| 126 | 
            +
            like author: { first_name: :prefix }
         | 
| 127 | 
            +
             | 
| 128 | 
            +
            # Payload examples
         | 
| 129 | 
            +
            { q: "First" }
         | 
| 130 | 
            +
            { author: { first_name: ["Ada", "Al"] } }
         | 
| 131 | 
            +
            ```
         | 
| 132 | 
            +
             | 
| 133 | 
            +
            ## Nested examples
         | 
| 134 | 
            +
             | 
| 135 | 
            +
            Top-level field range:
         | 
| 136 | 
            +
            ```ruby
         | 
| 137 | 
            +
            ArticleQuery.new(filters: { published: { since: Time.utc(2024,1,1), until: Time.utc(2024,6,30) } }).results
         | 
| 138 | 
            +
            ```
         | 
| 139 | 
            +
             | 
| 140 | 
            +
            Nested association field range:
         | 
| 141 | 
            +
            ```ruby
         | 
| 142 | 
            +
            ArticleQuery.new(filters: { reviews: { published: (Time.utc(2024,1,1)..Time.utc(2024,6,30)) } }).results
         | 
| 143 | 
            +
            ```
         | 
| 144 | 
            +
             | 
| 145 | 
            +
            Deep nested example (author → articles → reviews.published):
         | 
| 146 | 
            +
            ```ruby
         | 
| 147 | 
            +
            class AuthorQuery
         | 
| 148 | 
            +
              include Rokaki::FilterModel
         | 
| 149 | 
            +
              filter_model :author
         | 
| 150 | 
            +
              filter_map do
         | 
| 151 | 
            +
                nested :articles do
         | 
| 152 | 
            +
                  nested :reviews do
         | 
| 153 | 
            +
                    filters :published
         | 
| 154 | 
            +
                  end
         | 
| 155 | 
            +
                end
         | 
| 156 | 
            +
              end
         | 
| 157 | 
            +
              attr_accessor :filters
         | 
| 158 | 
            +
              def initialize(filters: {}) ; @filters = filters ; end
         | 
| 159 | 
            +
            end
         | 
| 160 | 
            +
             | 
| 161 | 
            +
            AuthorQuery.new(filters: { articles: { reviews: { published: { max: Time.utc(2024,6,30) } } } }).results
         | 
| 162 | 
            +
            ```
         | 
| 163 | 
            +
             | 
| 164 | 
            +
            ## Dynamic runtime listener
         | 
| 165 | 
            +
             | 
| 166 | 
            +
            You can build a filter class at runtime from a payload (see Usage → Dynamic runtime listener). The same rules apply: the mapping fixes the join structure; leaf values drive operators.
         | 
| 167 | 
            +
             | 
| 168 | 
            +
            ```ruby
         | 
| 169 | 
            +
            payload = {
         | 
| 170 | 
            +
              model: :article, db: :postgres, query_key: :q,
         | 
| 171 | 
            +
              like: { title: :circumfix, author: { first_name: :prefix } }
         | 
| 172 | 
            +
            }
         | 
| 173 | 
            +
            listener = Class.new do
         | 
| 174 | 
            +
              include Rokaki::FilterModel
         | 
| 175 | 
            +
              filter_model payload[:model], db: payload[:db]
         | 
| 176 | 
            +
              define_query_key payload[:query_key]
         | 
| 177 | 
            +
              filter_map { like payload[:like] }
         | 
| 178 | 
            +
              attr_accessor :filters
         | 
| 179 | 
            +
              def initialize(filters: {}) ; @filters = filters ; end
         | 
| 180 | 
            +
            end
         | 
| 181 | 
            +
             | 
| 182 | 
            +
            listener.new(filters: { q: "First" }).results
         | 
| 183 | 
            +
            ```
         | 
| 184 | 
            +
             | 
| 185 | 
            +
            ## Adapter behavior
         | 
| 186 | 
            +
             | 
| 187 | 
            +
            - Range/bounds predicates (`BETWEEN`, `>=`, `<=`) are adapter-agnostic; Rokaki binds parameters appropriately for PostgreSQL, MySQL, SQL Server, Oracle, and SQLite.
         | 
| 188 | 
            +
            - LIKE behavior is adapter-aware (e.g., Postgres `ANY(ARRAY[..])`, SQL Server `ESCAPE` clause, Oracle `UPPER()` for case-insensitive paths). See the Adapters page for details.
         | 
| 189 | 
            +
             | 
| 190 | 
            +
            ## Quick reference
         | 
| 191 | 
            +
             | 
| 192 | 
            +
            - Join-structure keys: associations, declared in code, mirrored in payload structure; never carry operators.
         | 
| 193 | 
            +
            - Leaf-level keys: columns/fields, declared in code with `filters`, accept values that determine semantics.
         | 
| 194 | 
            +
            - Reserved leaf operator keys: `between`, `from`/`since`/`after`/`start`/`min`, `to`/`until`/`before`/`end`/`max`.
         | 
| 195 | 
            +
            - Arrays: always equality `IN`.
         | 
| 196 | 
            +
            - Ranges or operator-hash: range filtering.
         | 
    
        data/docs/index.md
    CHANGED
    
    | @@ -14,6 +14,7 @@ Rokaki is a small Ruby library that helps you build safe, composable filters for | |
| 14 14 |  | 
| 15 15 | 
             
            Get started below or jump to:
         | 
| 16 16 | 
             
            - [Usage](./usage)
         | 
| 17 | 
            +
            - [Rokaki's DSL Syntax](./dsl-syntax)
         | 
| 17 18 | 
             
            - [Database adapters](./adapters)
         | 
| 18 19 | 
             
            - [Configuration](./configuration)
         | 
| 19 20 |  | 
| @@ -22,7 +23,7 @@ Get started below or jump to: | |
| 22 23 | 
             
            Add to your application's Gemfile:
         | 
| 23 24 |  | 
| 24 25 | 
             
            ```ruby
         | 
| 25 | 
            -
            gem "rokaki", "~> 0. | 
| 26 | 
            +
            gem "rokaki", "~> 0.17"
         | 
| 26 27 | 
             
            ```
         | 
| 27 28 |  | 
| 28 29 | 
             
            Then:
         | 
    
        data/docs/oracle.md
    ADDED
    
    | @@ -0,0 +1,138 @@ | |
| 1 | 
            +
            ---
         | 
| 2 | 
            +
            layout: page
         | 
| 3 | 
            +
            title: Oracle connections
         | 
| 4 | 
            +
            permalink: /adapters/oracle
         | 
| 5 | 
            +
            ---
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            This page collects Oracle‑specific connection tips for Rokaki (and ActiveRecord in general), including environment variables, client library notes, and how to avoid common errors during local development and CI runs.
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            Rokaki uses ActiveRecord’s `oracle_enhanced` adapter and ruby‑oci8 under the hood. All examples below assume ActiveRecord 7.1–8.x as used by Rokaki.
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            ## Quick start: commands that work
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            - Preferred full descriptor (stable across environments):
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            ```bash
         | 
| 16 | 
            +
            RBENV_VERSION=3.3.0 \
         | 
| 17 | 
            +
            ORACLE_USERNAME=system ORACLE_PASSWORD=oracle \
         | 
| 18 | 
            +
            NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
         | 
| 19 | 
            +
            ORACLE_DATABASE='(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=FREEPDB1)))' \
         | 
| 20 | 
            +
            bundle exec rspec spec/lib/04_oracle_aware_spec.rb --format documentation
         | 
| 21 | 
            +
            ```
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            - EZCONNECT (be sure to include the double slash prefix):
         | 
| 24 | 
            +
             | 
| 25 | 
            +
            ```bash
         | 
| 26 | 
            +
            RBENV_VERSION=3.3.0 \
         | 
| 27 | 
            +
            ORACLE_DATABASE=//127.0.0.1:1521/FREEPDB1 \
         | 
| 28 | 
            +
            ORACLE_USERNAME=system ORACLE_PASSWORD=oracle \
         | 
| 29 | 
            +
            NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
         | 
| 30 | 
            +
            bundle exec rspec spec/lib/04_oracle_aware_spec.rb
         | 
| 31 | 
            +
            ```
         | 
| 32 | 
            +
             | 
| 33 | 
            +
            Notes:
         | 
| 34 | 
            +
            - Oracle Free images typically expose a pluggable database (PDB) service named `FREEPDB1`.
         | 
| 35 | 
            +
            - Oracle XE images typically use `XEPDB1` instead.
         | 
| 36 | 
            +
            - If you hit listener errors (ORA‑12514/12521), verify the exact service via `lsnrctl status` inside your container and confirm host port mapping.
         | 
| 37 | 
            +
             | 
| 38 | 
            +
            ## Environment variables Rokaki tests understand
         | 
| 39 | 
            +
             | 
| 40 | 
            +
            The spec helper accepts overrides which are passed to ActiveRecord’s connection config (see `spec/support/database_manager.rb`):
         | 
| 41 | 
            +
             | 
| 42 | 
            +
            - `ORACLE_HOST` — defaults to `localhost`
         | 
| 43 | 
            +
            - `ORACLE_PORT` — defaults to `1521`
         | 
| 44 | 
            +
            - `ORACLE_USERNAME` — database username
         | 
| 45 | 
            +
            - `ORACLE_PASSWORD` — database password
         | 
| 46 | 
            +
            - `ORACLE_DATABASE` — EZCONNECT or TNS/descriptor, takes precedence over `ORACLE_SERVICE_NAME`
         | 
| 47 | 
            +
            - `ORACLE_SERVICE_NAME` — service name (e.g., `FREEPDB1` or `XEPDB1`)
         | 
| 48 | 
            +
            - `NLS_LANG` — recommended: `AMERICAN_AMERICA.AL32UTF8`
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            If only `ORACLE_SERVICE_NAME` is provided, Rokaki’s test helper composes a full descriptor automatically. If `ORACLE_DATABASE` is provided, it is used as‑is (recommended for EZCONNECT or explicit descriptors).
         | 
| 51 | 
            +
             | 
| 52 | 
            +
            ## ruby‑oci8 and Instant Client
         | 
| 53 | 
            +
             | 
| 54 | 
            +
            - Build‑time (already set in this repo’s `.bundle/config`):
         | 
| 55 | 
            +
             | 
| 56 | 
            +
            ```yaml
         | 
| 57 | 
            +
            BUNDLE_BUILD__RUBY___OCI8: "--with-instant-client-dir=/opt/oracle/instantclient_23_3 \
         | 
| 58 | 
            +
              --with-instant-client-include=/opt/oracle/instantclient_23_3/sdk/include \
         | 
| 59 | 
            +
              --with-instant-client-lib=/opt/oracle/instantclient_23_3"
         | 
| 60 | 
            +
            ```
         | 
| 61 | 
            +
             | 
| 62 | 
            +
            - Runtime (set these if the client libraries aren’t found or you see NLS errors):
         | 
| 63 | 
            +
             | 
| 64 | 
            +
            macOS:
         | 
| 65 | 
            +
            ```bash
         | 
| 66 | 
            +
            export DYLD_LIBRARY_PATH=/opt/oracle/instantclient_23_3:$DYLD_LIBRARY_PATH
         | 
| 67 | 
            +
            ```
         | 
| 68 | 
            +
            Linux:
         | 
| 69 | 
            +
            ```bash
         | 
| 70 | 
            +
            export LD_LIBRARY_PATH=/opt/oracle/instantclient_23_3:$LD_LIBRARY_PATH
         | 
| 71 | 
            +
            ```
         | 
| 72 | 
            +
            Optional (explicit NLS data path):
         | 
| 73 | 
            +
            ```bash
         | 
| 74 | 
            +
            export OCI_NLS10=/opt/oracle/instantclient_23_3/nls/data
         | 
| 75 | 
            +
            ```
         | 
| 76 | 
            +
             | 
| 77 | 
            +
            ## Common errors and fixes
         | 
| 78 | 
            +
             | 
| 79 | 
            +
            - ORA‑12705: Cannot access NLS data files or invalid environment specified
         | 
| 80 | 
            +
              - Cause: invalid/missing NLS settings or client libraries not found.
         | 
| 81 | 
            +
              - Fix: set `NLS_LANG=AMERICAN_AMERICA.AL32UTF8` (or leave unset), ensure Instant Client libraries are on `DYLD_LIBRARY_PATH` (macOS) or `LD_LIBRARY_PATH` (Linux). Optionally set `OCI_NLS10`.
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            - ORA‑12514 / ORA‑12521: TNS:listener does not currently know of service requested in connect descriptor / service not registered
         | 
| 84 | 
            +
              - Cause: wrong `SERVICE_NAME`, wrong host/port, container not exposing the service.
         | 
| 85 | 
            +
              - Fix: run `lsnrctl status` inside the container; use the exact `SERVICE_NAME` (e.g., `FREEPDB1`), confirm host port mapping. For EZCONNECT, remember the `//` prefix: `//HOST:PORT/SERVICE`.
         | 
| 86 | 
            +
             | 
| 87 | 
            +
            - ORA‑01017: invalid username/password; logon denied
         | 
| 88 | 
            +
              - Cause: wrong credentials for the target service/PDB.
         | 
| 89 | 
            +
              - Fix: double‑check username/password; for tests you can connect as `SYSTEM` to bootstrap schema, or create a dedicated test user (see below).
         | 
| 90 | 
            +
             | 
| 91 | 
            +
            ## Creating a dedicated test schema user
         | 
| 92 | 
            +
             | 
| 93 | 
            +
            From `SYSTEM` (connected to the target PDB service):
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            ```sql
         | 
| 96 | 
            +
            CREATE USER ROKAKI IDENTIFIED BY rokaki;
         | 
| 97 | 
            +
            GRANT CONNECT, RESOURCE, CREATE TABLE, CREATE SEQUENCE TO ROKAKI;
         | 
| 98 | 
            +
            ALTER USER ROKAKI QUOTA UNLIMITED ON USERS;
         | 
| 99 | 
            +
            ```
         | 
| 100 | 
            +
             | 
| 101 | 
            +
            Then connect with:
         | 
| 102 | 
            +
             | 
| 103 | 
            +
            ```bash
         | 
| 104 | 
            +
            ORACLE_USERNAME=ROKAKI ORACLE_PASSWORD=rokaki \
         | 
| 105 | 
            +
            ORACLE_DATABASE=//127.0.0.1:1521/FREEPDB1 \
         | 
| 106 | 
            +
            NLS_LANG=AMERICAN_AMERICA.AL32UTF8 \
         | 
| 107 | 
            +
            bundle exec rspec spec/lib/04_oracle_aware_spec.rb
         | 
| 108 | 
            +
            ```
         | 
| 109 | 
            +
             | 
| 110 | 
            +
            ## Rails `database.yml` examples
         | 
| 111 | 
            +
             | 
| 112 | 
            +
            Using `oracle_enhanced` with service name:
         | 
| 113 | 
            +
             | 
| 114 | 
            +
            ```yaml
         | 
| 115 | 
            +
            production:
         | 
| 116 | 
            +
              adapter: oracle_enhanced
         | 
| 117 | 
            +
              host: <%= ENV["ORACLE_HOST"] || "localhost" %>
         | 
| 118 | 
            +
              port: <%= (ENV["ORACLE_PORT"] || 1521).to_i %>
         | 
| 119 | 
            +
              username: <%= ENV["ORACLE_USERNAME"] %>
         | 
| 120 | 
            +
              password: <%= ENV["ORACLE_PASSWORD"] %>
         | 
| 121 | 
            +
              service_name: <%= ENV["ORACLE_SERVICE_NAME"] || "FREEPDB1" %>
         | 
| 122 | 
            +
            ```
         | 
| 123 | 
            +
             | 
| 124 | 
            +
            Using EZCONNECT/descriptor directly:
         | 
| 125 | 
            +
             | 
| 126 | 
            +
            ```yaml
         | 
| 127 | 
            +
            production:
         | 
| 128 | 
            +
              adapter: oracle_enhanced
         | 
| 129 | 
            +
              username: <%= ENV["ORACLE_USERNAME"] %>
         | 
| 130 | 
            +
              password: <%= ENV["ORACLE_PASSWORD"] %>
         | 
| 131 | 
            +
              database: <%= ENV["ORACLE_DATABASE"] %> # e.g., //127.0.0.1:1521/FREEPDB1
         | 
| 132 | 
            +
            ```
         | 
| 133 | 
            +
             | 
| 134 | 
            +
            ## CI and local tips
         | 
| 135 | 
            +
             | 
| 136 | 
            +
            - Prefer the full `(DESCRIPTION=...)` form in CI to avoid resolver quirks.
         | 
| 137 | 
            +
            - On Oracle Free containers the default service is `FREEPDB1`; on XE it’s `XEPDB1`.
         | 
| 138 | 
            +
            - If your tests need to create tables, use a user with `CREATE TABLE` and `CREATE SEQUENCE` privileges (our specs do this automatically).
         | 
    
        data/docs/usage.md
    CHANGED
    
    | @@ -5,13 +5,14 @@ permalink: /usage | |
| 5 5 | 
             
            ---
         | 
| 6 6 |  | 
| 7 7 | 
             
            This page shows how to use Rokaki to define filters and apply them to ActiveRecord relations.
         | 
| 8 | 
            +
            For a formal description of the mapping DSL and how payloads are interpreted (join structure vs leaf-level keys), see Rokaki's DSL Syntax: [/dsl-syntax](/dsl-syntax).
         | 
| 8 9 |  | 
| 9 10 | 
             
            ## Installation
         | 
| 10 11 |  | 
| 11 12 | 
             
            Add the gem to your Gemfile and bundle:
         | 
| 12 13 |  | 
| 13 14 | 
             
            ```ruby
         | 
| 14 | 
            -
            gem "rokaki", "~> 0. | 
| 15 | 
            +
            gem "rokaki", "~> 0.17"
         | 
| 15 16 | 
             
            ```
         | 
| 16 17 |  | 
| 17 18 | 
             
            ```bash
         | 
| @@ -80,6 +81,83 @@ Each accepts a single string or an array of strings. Rokaki generates adapter‑ | |
| 80 81 | 
             
            - MySQL: `LIKE`/`LIKE BINARY` and, in nested-like contexts, `REGEXP` where designed
         | 
| 81 82 | 
             
            - SQL Server: `LIKE` with safe escaping; arrays expand into OR chains of parameterized `LIKE` predicates
         | 
| 82 83 |  | 
| 84 | 
            +
            ## Range, BETWEEN, MIN, and MAX filters
         | 
| 85 | 
            +
             | 
| 86 | 
            +
            Rokaki supports range-style filters as normal filters (not aggregates) across all adapters. You don’t have to declare special operators per field — the value shape (and optional sub-keys) drive the behavior.
         | 
| 87 | 
            +
             | 
| 88 | 
            +
            Preferred syntax: use the field name as the key and the filter type as a sub-key. Aliases are supported.
         | 
| 89 | 
            +
             | 
| 90 | 
            +
            - Sub-keys:
         | 
| 91 | 
            +
              - `between` → interpret the value as a range and generate `BETWEEN`/`>=`/`<=` as appropriate
         | 
| 92 | 
            +
              - Lower bound aliases → `>=`: `from`, `since`, `after`, `start`, `min`
         | 
| 93 | 
            +
              - Upper bound aliases → `<=`: `to`, `until`, `before`, `end`, `max`
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            Accepted value shapes for `between` (also works when you pass a range directly as the field value):
         | 
| 96 | 
            +
            - Ruby `Range`: `1..10`, `Time.utc(2024,1,1)..Time.utc(2024,12,31)`
         | 
| 97 | 
            +
            - Two-element `Array`: `[from, to]` (only when wrapped with `{ between: [...] }`)
         | 
| 98 | 
            +
            - Hash with aliases: `{ from:, to: }`, `{ since:, until: }`, `{ after:, before: }`, `{ start:, end: }`
         | 
| 99 | 
            +
             | 
| 100 | 
            +
            Examples (top-level field):
         | 
| 101 | 
            +
             | 
| 102 | 
            +
            ```ruby
         | 
| 103 | 
            +
            class ArticleQuery
         | 
| 104 | 
            +
              include Rokaki::FilterModel
         | 
| 105 | 
            +
              filter_model :article
         | 
| 106 | 
            +
             | 
| 107 | 
            +
              # equality filters (existing)
         | 
| 108 | 
            +
              filters :author_id, :published
         | 
| 109 | 
            +
             | 
| 110 | 
            +
              # LIKEs (existing)
         | 
| 111 | 
            +
              define_query_key :q
         | 
| 112 | 
            +
              like title: :circumfix
         | 
| 113 | 
            +
            end
         | 
| 114 | 
            +
             | 
| 115 | 
            +
            # Between with a Range
         | 
| 116 | 
            +
            Article.filter(published: Date.new(2024,1,1)..Date.new(2024,12,31))
         | 
| 117 | 
            +
             | 
| 118 | 
            +
            # Between with a Hash + aliases
         | 
| 119 | 
            +
            Article.filter(published: { from: Date.new(2024,1,1), to: Date.new(2024,12,31) })
         | 
| 120 | 
            +
            Article.filter(published: { since: Date.new(2024,1,1), until: Date.new(2024,6,30) })
         | 
| 121 | 
            +
             | 
| 122 | 
            +
            # Open-ended bounds
         | 
| 123 | 
            +
            Article.filter(published: { min: Date.new(2024,1,1) })   # >= 2024-01-01
         | 
| 124 | 
            +
            Article.filter(published: { max: Date.new(2024,12,31) }) # <= 2024-12-31
         | 
| 125 | 
            +
             | 
| 126 | 
            +
            # Between with a two-element Array
         | 
| 127 | 
            +
            Article.filter(published: [Date.new(2024,5,1), Date.new(2024,12,1)])
         | 
| 128 | 
            +
            ```
         | 
| 129 | 
            +
             | 
| 130 | 
            +
            Nested fields use the same sub-keys and value shapes:
         | 
| 131 | 
            +
             | 
| 132 | 
            +
            ```ruby
         | 
| 133 | 
            +
            class ArticleQuery
         | 
| 134 | 
            +
              include Rokaki::FilterModel
         | 
| 135 | 
            +
              filter_model :article
         | 
| 136 | 
            +
             | 
| 137 | 
            +
              filter_map do
         | 
| 138 | 
            +
                nested :author do
         | 
| 139 | 
            +
                  # Range filters are value-driven; declaring the field enables the param key
         | 
| 140 | 
            +
                  filters :created_at   # enables :author_created_at
         | 
| 141 | 
            +
                end
         | 
| 142 | 
            +
             | 
| 143 | 
            +
                nested :reviews do
         | 
| 144 | 
            +
                  filters :published    # enables :reviews_published
         | 
| 145 | 
            +
                end
         | 
| 146 | 
            +
              end
         | 
| 147 | 
            +
            end
         | 
| 148 | 
            +
             | 
| 149 | 
            +
            # Params examples
         | 
| 150 | 
            +
            Article.filter(author_created_at: { from: Time.utc(2024,1,1), to: Time.utc(2024,6,30) })
         | 
| 151 | 
            +
            Article.filter(reviews_published: (Time.utc(2024,1,1)..Time.utc(2024,6,30)))
         | 
| 152 | 
            +
            ```
         | 
| 153 | 
            +
             | 
| 154 | 
            +
            Behavior notes:
         | 
| 155 | 
            +
            - `min`/`max` are interpreted as lower/upper bounds, not aggregate functions.
         | 
| 156 | 
            +
            - Passing a `Range` or two-element `Array` directly as the field value is treated as a between filter automatically.
         | 
| 157 | 
            +
            - Arrays with more than two elements are treated as equality lists (`IN (?)`) — use `{ between: [...] }` if you intend a range.
         | 
| 158 | 
            +
            - `nil` bounds are ignored: only the provided side is applied (e.g., `{ from: t }` becomes `>= t`).
         | 
| 159 | 
            +
            - All generated predicates are parameterized and adapter‑agnostic (`BETWEEN`, `>=`, `<=`).
         | 
| 160 | 
            +
             | 
| 83 161 | 
             
            ## Nested filters
         | 
| 84 162 |  | 
| 85 163 | 
             
            Use `nested :association` to scope filters to joined tables. Rokaki handles the necessary joins and qualified columns.
         | 
| @@ -3,13 +3,16 @@ | |
| 3 3 | 
             
            module Rokaki
         | 
| 4 4 | 
             
              module FilterModel
         | 
| 5 5 | 
             
                class BasicFilter
         | 
| 6 | 
            -
                  def initialize(keys:, prefix:, infix:, like_semantics:, i_like_semantics:, db:)
         | 
| 6 | 
            +
                  def initialize(keys:, prefix:, infix:, like_semantics:, i_like_semantics:, db:, between_keys: nil, min_keys: nil, max_keys: nil)
         | 
| 7 7 | 
             
                    @keys = keys
         | 
| 8 8 | 
             
                    @prefix = prefix
         | 
| 9 9 | 
             
                    @infix = infix
         | 
| 10 10 | 
             
                    @like_semantics = like_semantics
         | 
| 11 11 | 
             
                    @i_like_semantics = i_like_semantics
         | 
| 12 12 | 
             
                    @db = db
         | 
| 13 | 
            +
                    @between_keys = Array(between_keys).compact
         | 
| 14 | 
            +
                    @min_keys = Array(min_keys).compact
         | 
| 15 | 
            +
                    @max_keys = Array(max_keys).compact
         | 
| 13 16 | 
             
                    @filter_query = nil
         | 
| 14 17 | 
             
                  end
         | 
| 15 18 | 
             
                  attr_reader :keys, :prefix, :infix, :like_semantics, :i_like_semantics, :db, :filter_query
         | 
| @@ -83,12 +86,114 @@ module Rokaki | |
| 83 86 | 
             
                        key: key
         | 
| 84 87 | 
             
                      )
         | 
| 85 88 | 
             
                    else
         | 
| 86 | 
            -
                       | 
| 89 | 
            +
                      # New preferred style: field => { between:/from:/to:/min:/max: }
         | 
| 90 | 
            +
                      # Also accept direct Range/Array/Hash with from/to aliases.
         | 
| 91 | 
            +
                      query = <<-RUBY
         | 
| 92 | 
            +
                        begin
         | 
| 93 | 
            +
                          _val = #{filter}
         | 
| 94 | 
            +
                          if _val.is_a?(Hash)
         | 
| 95 | 
            +
                            # Support wrapper keys like :between as well as bound aliases/min/max
         | 
| 96 | 
            +
                            _inner = _val
         | 
| 97 | 
            +
                            if _val.key?(:between) || _val.key?('between')
         | 
| 98 | 
            +
                              _inner = _val[:between] || _val['between']
         | 
| 99 | 
            +
                            end
         | 
| 100 | 
            +
                            _from = _inner[:from] || _inner['from'] || _inner[:since] || _inner['since'] || _inner[:after] || _inner['after'] || _inner[:start] || _inner['start'] || _inner[:min] || _inner['min']
         | 
| 101 | 
            +
                            _to   = _inner[:to]   || _inner['to']   || _inner[:until] || _inner['until'] || _inner[:before] || _inner['before'] || _inner[:end]   || _inner['end']   || _inner[:max] || _inner['max']
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                            if _from.nil? && _to.nil?
         | 
| 104 | 
            +
                              # If hash contains range-like but with different container (e.g., { between: range })
         | 
| 105 | 
            +
                              if _inner.is_a?(Range)
         | 
| 106 | 
            +
                                _from = _inner.begin; _to = _inner.end
         | 
| 107 | 
            +
                              elsif _inner.is_a?(Array)
         | 
| 108 | 
            +
                                _from, _to = _inner[0], _inner[1]
         | 
| 109 | 
            +
                              end
         | 
| 110 | 
            +
                            end
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                            # Adjust inclusive end-of-day behavior if upper bound appears to be a date or midnight time
         | 
| 113 | 
            +
                            if !_to.nil? && (_to.is_a?(Date) && !_to.is_a?(DateTime) || (_to.respond_to?(:hour) && _to.hour == 0 && _to.min == 0 && _to.sec == 0))
         | 
| 114 | 
            +
                              _to = (_to.respond_to?(:to_time) ? _to.to_time : _to) + 86399
         | 
| 115 | 
            +
                            end
         | 
| 116 | 
            +
                            if !_from.nil? && !_to.nil?
         | 
| 117 | 
            +
                              @model.where("#{key} BETWEEN :from AND :to", from: _from, to: _to)
         | 
| 118 | 
            +
                            elsif !_from.nil?
         | 
| 119 | 
            +
                              @model.where("#{key} >= :from", from: _from)
         | 
| 120 | 
            +
                            elsif !_to.nil?
         | 
| 121 | 
            +
                              @model.where("#{key} <= :to", to: _to)
         | 
| 122 | 
            +
                            else
         | 
| 123 | 
            +
                              # Fall back to equality with the original hash
         | 
| 124 | 
            +
                              @model.where(#{key}: _val)
         | 
| 125 | 
            +
                            end
         | 
| 126 | 
            +
                          elsif _val.is_a?(Range)
         | 
| 127 | 
            +
                            #{build_between_query(filter: filter, key: key)}
         | 
| 128 | 
            +
                          else
         | 
| 129 | 
            +
                            # Equality and IN semantics for arrays and scalars (Arrays are always IN lists)
         | 
| 130 | 
            +
                            @model.where(#{key}: _val)
         | 
| 131 | 
            +
                          end
         | 
| 132 | 
            +
                        end
         | 
| 133 | 
            +
                      RUBY
         | 
| 87 134 | 
             
                    end
         | 
| 88 135 |  | 
| 89 136 | 
             
                    @filter_query = query
         | 
| 90 137 | 
             
                  end
         | 
| 91 138 |  | 
| 139 | 
            +
                  def build_between_query(filter:, key:)
         | 
| 140 | 
            +
                    # Accept [from, to], Range, or {from:, to:}
         | 
| 141 | 
            +
                    # Build appropriate where conditions with bound params
         | 
| 142 | 
            +
                    <<-RUBY
         | 
| 143 | 
            +
                      begin
         | 
| 144 | 
            +
                        _val = #{filter}
         | 
| 145 | 
            +
                        _from = _to = nil
         | 
| 146 | 
            +
                        if _val.is_a?(Range)
         | 
| 147 | 
            +
                          _from = _val.begin
         | 
| 148 | 
            +
                          _to = _val.end
         | 
| 149 | 
            +
                        elsif _val.is_a?(Array)
         | 
| 150 | 
            +
                          _from, _to = _val[0], _val[1]
         | 
| 151 | 
            +
                        elsif _val.is_a?(Hash)
         | 
| 152 | 
            +
                          # allow aliases for from/to
         | 
| 153 | 
            +
                          _from = _val[:from] || _val['from'] || _val[:since] || _val['since'] || _val[:after] || _val['after'] || _val[:start] || _val['start']
         | 
| 154 | 
            +
                          _to   = _val[:to]   || _val['to']   || _val[:until] || _val['until'] || _val[:before] || _val['before'] || _val[:end]   || _val['end']
         | 
| 155 | 
            +
                        else
         | 
| 156 | 
            +
                          # single value → equality
         | 
| 157 | 
            +
                          return @model.where(#{key}: _val)
         | 
| 158 | 
            +
                        end
         | 
| 159 | 
            +
             | 
| 160 | 
            +
                        if !_from.nil? && !_to.nil?
         | 
| 161 | 
            +
                          @model.where("#{key} BETWEEN :from AND :to", from: _from, to: _to)
         | 
| 162 | 
            +
                        elsif !_from.nil?
         | 
| 163 | 
            +
                          @model.where("#{key} >= :from", from: _from)
         | 
| 164 | 
            +
                        elsif !_to.nil?
         | 
| 165 | 
            +
                          @model.where("#{key} <= :to", to: _to)
         | 
| 166 | 
            +
                        else
         | 
| 167 | 
            +
                          @model
         | 
| 168 | 
            +
                        end
         | 
| 169 | 
            +
                      end
         | 
| 170 | 
            +
                    RUBY
         | 
| 171 | 
            +
                  end
         | 
| 172 | 
            +
             | 
| 173 | 
            +
                  def parse_range_semantics(key)
         | 
| 174 | 
            +
                    k = key.to_s
         | 
| 175 | 
            +
                    %w[_between _min _max _from _to _after _before _since _until _start _end].each do |suf|
         | 
| 176 | 
            +
                      if k.end_with?(suf)
         | 
| 177 | 
            +
                        base = k.sub(/#{Regexp.escape(suf)}\z/, '')
         | 
| 178 | 
            +
                        op = case suf
         | 
| 179 | 
            +
                             when '_between' then :between
         | 
| 180 | 
            +
                             when '_min' then :min
         | 
| 181 | 
            +
                             when '_max' then :max
         | 
| 182 | 
            +
                             when '_from','_after','_since','_start' then :from
         | 
| 183 | 
            +
                             when '_to','_before','_until','_end' then :to
         | 
| 184 | 
            +
                             else nil
         | 
| 185 | 
            +
                             end
         | 
| 186 | 
            +
                        return [base, op]
         | 
| 187 | 
            +
                      end
         | 
| 188 | 
            +
                    end
         | 
| 189 | 
            +
                    [nil, nil]
         | 
| 190 | 
            +
                  end
         | 
| 191 | 
            +
             | 
| 192 | 
            +
                  def build_compare_query(op:, filter:, column:)
         | 
| 193 | 
            +
                    operator = (op == :'>=') ? '>=' : '<='
         | 
| 194 | 
            +
                    %Q{@model.where("#{column} #{operator} :v", v: #{filter})}
         | 
| 195 | 
            +
                  end
         | 
| 196 | 
            +
             | 
| 92 197 | 
             
                  # # @model.where('`authors`.`first_name` LIKE BINARY :query', query: "%teev%").or(@model.where('`authors`.`first_name` LIKE BINARY :query', query: "%imi%"))
         | 
| 93 198 | 
             
                  # if Array == filter
         | 
| 94 199 | 
             
                  #     first_term = filter.unshift
         | 
| @@ -129,21 +129,17 @@ module Rokaki | |
| 129 129 | 
             
                      where_after.push(" }")
         | 
| 130 130 | 
             
                    end
         | 
| 131 131 |  | 
| 132 | 
            -
                     | 
| 132 | 
            +
                    joins_arr = joins_before + joins_after
         | 
| 133 | 
            +
                    joins_str = joins_arr.join
         | 
| 133 134 |  | 
| 134 135 | 
             
                    name += "#{leaf}"
         | 
| 135 | 
            -
                    where_middle = ["{ #{leaf}: #{prefix}#{name} }"]
         | 
| 136 | 
            -
             | 
| 137 | 
            -
                    where = where_before + where_middle + where_after
         | 
| 138 | 
            -
                    joins = joins.join
         | 
| 139 | 
            -
                    where = where.join
         | 
| 140 136 |  | 
| 141 137 | 
             
                    if search_mode
         | 
| 142 138 | 
             
                      if db == :sqlserver || db == :oracle
         | 
| 143 139 | 
             
                        key_leaf = "#{keys.last.to_s.pluralize}.#{leaf}"
         | 
| 144 140 | 
             
                        helper = db == :sqlserver ? 'sqlserver_like' : 'oracle_like'
         | 
| 145 141 | 
             
                        @filter_methods << "def #{prefix}filter#{infix}#{name};"\
         | 
| 146 | 
            -
                          "#{helper}(@model.joins(#{ | 
| 142 | 
            +
                          "#{helper}(@model.joins(#{joins_str}), \"#{key_leaf}\", \"#{type}\", #{prefix}#{name}, :#{search_mode}); end;"
         | 
| 147 143 |  | 
| 148 144 | 
             
                        @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
         | 
| 149 145 | 
             
                      else
         | 
| @@ -157,14 +153,51 @@ module Rokaki | |
| 157 153 | 
             
                        )
         | 
| 158 154 |  | 
| 159 155 | 
             
                        @filter_methods << "def #{prefix}filter#{infix}#{name};"\
         | 
| 160 | 
            -
                          "@model.joins(#{ | 
| 156 | 
            +
                          "@model.joins(#{joins_str}).#{query}; end;"
         | 
| 161 157 |  | 
| 162 158 | 
             
                        @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
         | 
| 163 159 | 
             
                      end
         | 
| 164 160 | 
             
                    else
         | 
| 165 | 
            -
                       | 
| 166 | 
            -
             | 
| 167 | 
            -
             | 
| 161 | 
            +
                      # Preferred: value Hash with sub-keys between/from/to/min/max; also accept Range/Array directly
         | 
| 162 | 
            +
                      qualified_col = "#{keys.last.to_s.pluralize}.#{leaf}"
         | 
| 163 | 
            +
                      body = <<-RUBY
         | 
| 164 | 
            +
                        begin
         | 
| 165 | 
            +
                          _val = #{prefix}#{name}
         | 
| 166 | 
            +
                          rel = @model.joins(#{joins_str})
         | 
| 167 | 
            +
                          if _val.is_a?(Hash)
         | 
| 168 | 
            +
                            _inner = _val
         | 
| 169 | 
            +
                            if _val.key?(:between) || _val.key?('between')
         | 
| 170 | 
            +
                              _inner = _val[:between] || _val['between']
         | 
| 171 | 
            +
                            end
         | 
| 172 | 
            +
                            _from = _inner[:from] || _inner['from'] || _inner[:since] || _inner['since'] || _inner[:after] || _inner['after'] || _inner[:start] || _inner['start'] || _inner[:min] || _inner['min']
         | 
| 173 | 
            +
                            _to   = _inner[:to]   || _inner['to']   || _inner[:until] || _inner['until'] || _inner[:before] || _inner['before'] || _inner[:end]   || _inner['end']   || _inner[:max] || _inner['max']
         | 
| 174 | 
            +
                            if _from.nil? && _to.nil?
         | 
| 175 | 
            +
                              if _inner.is_a?(Range)
         | 
| 176 | 
            +
                                _from = _inner.begin; _to = _inner.end
         | 
| 177 | 
            +
                              elsif _inner.is_a?(Array)
         | 
| 178 | 
            +
                                _from, _to = _inner[0], _inner[1]
         | 
| 179 | 
            +
                              end
         | 
| 180 | 
            +
                            end
         | 
| 181 | 
            +
                            if !_from.nil? && !_to.nil?
         | 
| 182 | 
            +
                              rel.where("#{qualified_col} BETWEEN :from AND :to", from: _from, to: _to)
         | 
| 183 | 
            +
                            elsif !_from.nil?
         | 
| 184 | 
            +
                              rel.where("#{qualified_col} >= :from", from: _from)
         | 
| 185 | 
            +
                            elsif !_to.nil?
         | 
| 186 | 
            +
                              rel.where("#{qualified_col} <= :to", to: _to)
         | 
| 187 | 
            +
                            else
         | 
| 188 | 
            +
                              rel.where("#{qualified_col} = :v", v: _val)
         | 
| 189 | 
            +
                            end
         | 
| 190 | 
            +
                          elsif _val.is_a?(Range)
         | 
| 191 | 
            +
                            rel.where("#{qualified_col} BETWEEN :from AND :to", from: _val.begin, to: _val.end)
         | 
| 192 | 
            +
                          elsif _val.is_a?(Array)
         | 
| 193 | 
            +
                            # Arrays represent IN semantics for equality; use BETWEEN only when explicitly wrapped via :between
         | 
| 194 | 
            +
                            rel.where("#{qualified_col} IN (?)", _val)
         | 
| 195 | 
            +
                          else
         | 
| 196 | 
            +
                            rel.where("#{qualified_col} = :v", v: _val)
         | 
| 197 | 
            +
                          end
         | 
| 198 | 
            +
                        end
         | 
| 199 | 
            +
                      RUBY
         | 
| 200 | 
            +
                      @filter_methods << "def #{prefix}filter#{infix}#{name};#{body}; end;"
         | 
| 168 201 | 
             
                      @filter_templates << "@model = #{prefix}filter#{infix}#{name} if #{prefix}#{name};"
         | 
| 169 202 | 
             
                    end
         | 
| 170 203 | 
             
                  end
         | 
| @@ -182,6 +215,25 @@ module Rokaki | |
| 182 215 | 
             
                    query
         | 
| 183 216 | 
             
                  end
         | 
| 184 217 |  | 
| 218 | 
            +
                  def parse_range_semantics(key)
         | 
| 219 | 
            +
                    k = key.to_s
         | 
| 220 | 
            +
                    %w[_between _min _max _from _to _after _before _since _until _start _end].each do |suf|
         | 
| 221 | 
            +
                      if k.end_with?(suf)
         | 
| 222 | 
            +
                        base = k.sub(/#{Regexp.escape(suf)}\z/, '')
         | 
| 223 | 
            +
                        op = case suf
         | 
| 224 | 
            +
                             when '_between' then :between
         | 
| 225 | 
            +
                             when '_min' then :from   # min → lower bound
         | 
| 226 | 
            +
                             when '_max' then :to     # max → upper bound
         | 
| 227 | 
            +
                             when '_from','_after','_since','_start' then :from
         | 
| 228 | 
            +
                             when '_to','_before','_until','_end' then :to
         | 
| 229 | 
            +
                             else nil
         | 
| 230 | 
            +
                             end
         | 
| 231 | 
            +
                        return [base, op]
         | 
| 232 | 
            +
                      end
         | 
| 233 | 
            +
                    end
         | 
| 234 | 
            +
                    [nil, nil]
         | 
| 235 | 
            +
                  end
         | 
| 236 | 
            +
             | 
| 185 237 | 
             
                end
         | 
| 186 238 | 
             
              end
         | 
| 187 239 | 
             
            end
         | 
    
        data/lib/rokaki/version.rb
    CHANGED
    
    
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: rokaki
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0. | 
| 4 | 
            +
              version: 0.17.0
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Steve Martin
         | 
| 8 8 | 
             
            autorequire:
         | 
| 9 9 | 
             
            bindir: exe
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2025-10- | 
| 11 | 
            +
            date: 2025-10-28 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: activesupport
         | 
| @@ -296,7 +296,9 @@ files: | |
| 296 296 | 
             
            - docs/_config.yml
         | 
| 297 297 | 
             
            - docs/adapters.md
         | 
| 298 298 | 
             
            - docs/configuration.md
         | 
| 299 | 
            +
            - docs/dsl_syntax.md
         | 
| 299 300 | 
             
            - docs/index.md
         | 
| 301 | 
            +
            - docs/oracle.md
         | 
| 300 302 | 
             
            - docs/usage.md
         | 
| 301 303 | 
             
            - lib/rokaki.rb
         | 
| 302 304 | 
             
            - lib/rokaki/filter_model.rb
         |