Rokaki is a small DSL for building safe, composable filters for ActiveRecord queries — without writing SQL. It maps incoming params to predicates on models and associations and works across PostgreSQL, MySQL, SQL Server, and Oracle.
- Works with ActiveRecord 7.1 and 8.x
- LIKE modes:
:prefix,:suffix,:circumfix(+ synonyms) and array‑of‑terms - Nested filters with auto‑joins and qualified columns
- Block‑form DSL (
filter_map do ... end) and classic argument form - Runtime usage: build an anonymous filter class from a payload (no predeclared class needed)
Install
gem "rokaki"
Docs
- Usage and examples: https://tevio.github.io/rokaki/usage
- Adapters: https://tevio.github.io/rokaki/adapters
- Configuration: https://tevio.github.io/rokaki/configuration
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.
Rokaki
Rokaki is a small DSL for building safe, composable filters for ActiveRecord queries — without writing SQL. It maps incoming params to predicates on models and associations and works across PostgreSQL, MySQL, SQL Server, and Oracle.
- Works with ActiveRecord 7.1 and 8.x
- LIKE modes:
:prefix,:suffix,:circumfix(+ synonyms) and array‑of‑terms - Nested filters with auto‑joins and qualified columns
- Block‑form DSL (
filter_map do ... end) and classic argument form - Runtime usage: build an anonymous filter class from a payload (no predeclared class needed)
Install
gem "rokaki"
Minimal example
class ArticleQuery
include Rokaki::FilterModel
filter_model :article, db: :postgres
define_query_key :q
filter_map do
like title: :circumfix
nested :author do
like first_name: :prefix
end
end
attr_accessor :filters
def initialize(filters: {}) ; @filters = filters ; end
end
ArticleQuery.new(filters: { q: ["Kavya", "Mateo"] }).results
Docs
- Usage and examples: https://tevio.github.io/rokaki/usage
- Adapters: https://tevio.github.io/rokaki/adapters
- Configuration: https://tevio.github.io/rokaki/configuration
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.
Further reading
- Usage and examples: https://tevio.github.io/rokaki/usage
- Database adapters: https://tevio.github.io/rokaki/adapters
- Configuration and environment variables: https://tevio.github.io/rokaki/configuration
Changelog: see CHANGELOG.md.
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.
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.
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.
Installation
Add this line to your application's Gemfile:
You can install from Rubygems:
gem 'rokaki'
Or from github
gem 'rokaki', git: 'https://github.com/tevio/rokaki.git'
And then execute:
$ bundle
Rokaki::Filterable - Usage
To use the DSL first include the Rokaki::Filterable module in your por class.
#define_filter_keys
A Simple Example
A simple example might be:-
class FilterArticles
include Rokaki::Filterable
def initialize(filters:)
@filters = filters
@articles = Article
end
attr_accessor :filters
define_filter_keys :date, author: [:first_name, :last_name]
def filter_results
@articles = @articles.where(date: date) if date
@articles = @articles.joins(:author).where(authors: { first_name: }) if
@articles = @articles.joins(:author).where(authors: { last_name: }) if
end
end
article_filter = FilterArticles.new(filters: {
date: '10-10-10',
author: {
first_name: 'Steve',
last_name: 'Martin'
}})
article_filter. == 'Steve'
article_filter. == 'Martin'
article_filter.date == '10-10-10'
In this example Rokaki maps the "flat" attribute "keys" date, author_first_name and author_last_name to a @filters object with the expected deep structure { date: '10-10-10', author: { first_name: 'Steve' } }, to make it simple to use them in filter queries.
A More Complex Example
class AdvancedFilterable
include Rokaki::Filterable
def initialize(filters:)
@fyltrz = filters
end
attr_accessor :fyltrz
filterable_object_name :fyltrz
filter_key_infix :__
define_filter_keys :basic, advanced: {
filter_key_1: [:filter_key_2, { filter_key_3: :deep_node }],
filter_key_4: :deep_leaf_array
}
end
advanced_filterable = AdvancedFilterable.new(filters: {
basic: 'ABC',
advanced: {
filter_key_1: {
filter_key_2: '123',
filter_key_3: { deep_node: 'NODE' }
},
filter_key_4: { deep_leaf_array: [1,2,3,4] }
}
})
advanced_filterable.advanced__filter_key_4__deep_leaf_array == [1,2,3,4]
advanced_filterable.advanced__filter_key_1__filter_key_3__deep_node == 'NODE'
#define_filter_map
The define_filter_map method is more suited to classic "search", where you might want to search multiple fields on a model or across a graph. See the section on filter_map with OR for more on this kind of application.
This method takes a single field in the passed in filters hash and maps it to fields named in the second param, this is useful if you want to search for a single value across many different fields or associated tables simultaneously.
A Simple Example
class FilterMap
include Rokaki::Filterable
def initialize(fylterz:)
@fylterz = fylterz
end
attr_accessor :fylterz
filterable_object_name :fylterz
define_filter_map :query, :mapped_a, association: :field
end
filter_map = FilterMap.new(fylterz: { query: 'H2O' })
filter_map.mapped_a == 'H2O'
filter_map.association_field = 'H2O'
Additional Filterable options
You can specify several configuration options, for example a filter_key_prefix and a filter_key_infix to change the structure of the generated filter accessors.
filter_key_prefix :__ would result in key accessors like __author_first_name
filter_key_infix :__ would result in key accessors like author__first_name
filterable_object_name :fyltrz would use an internal filter state object named @fyltrz instead of the default @filters
Rokaki::FilterModel - Usage
ActiveRecord
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:-
# Given the models
class Author < ActiveRecord::Base
has_many :articles, inverse_of: :author
end
class Article < ActiveRecord::Base
belongs_to :author, inverse_of: :articles, required: true
end
class ArticleFilter
include Rokaki::FilterModel
filters :date, :title, author: [:first_name, :last_name]
attr_accessor :filters
def initialize(filters:, model: Article)
@filters = filters
@model = model
end
end
filter = ArticleFilter.new(filters: params[:filters])
filtered_results = filter.results
Arrays of params
You can also filter collections of fields, simply pass an array of filter values instead of a single value, eg:- { date: '10-10-10', author: { first_name: ['Author1', 'Author2'] } }.
Partial matching
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:-
1. The filter command syntax
class ArticleFilter
include Rokaki::FilterModel
filter :article,
like: { # you can use ilike here instead if you want case insensitive results
author: {
first_name: :circumfix,
last_name: :circumfix
}
}
attr_accessor :filters
def initialize(filters:)
@filters = filters
end
end
Or
2. The filter_map command syntax
filter_map takes the model name, then a single 'query' field and maps it to fields named in the options, this is useful if you want to search for a single value across many different fields or associated tables simultaneously. (builds on define_filter_map)
class AuthorFilter
include Rokaki::FilterModel
filter_map :author, :query,
like: {
articles: {
title: :circumfix,
reviews: {
title: :circumfix
}
},
}
attr_accessor :filters, :model
def initialize(filters:)
@filters = filters
end
end
filters = { query: "Jiddu" }
= AuthorFilter.new(filters: filters).results
In the above example we search for authors who have written articles containing the word "Jiddu" in the title that also have reviews containing the sames word in their titles.
The above example performs an "ALL" like query, where all fields must satisfy the query term. Conversly you can use or to perform an "ANY", where any of the fields within the or will satisfy the query term, like so:-
class AuthorFilter
include Rokaki::FilterModel
filter_map :author, :query,
like: {
articles: {
title: :circumfix,
or: { # the or is aware of the join and will generate a compound join aware or query
reviews: {
title: :circumfix
}
}
},
}
attr_accessor :filters, :model
def initialize(filters:)
@filters = filters
end
end
filters = { query: "Lao" }
= AuthorFilter.new(filters: filters).results
CAVEATS
Active record OR over a join may require you to add something like the following in an initializer in order for it to function properly:-
#structurally_incompatible_values_for_or
module ActiveRecord
module QueryMethods
def structurally_incompatible_values_for_or(other)
Relation::SINGLE_VALUE_METHODS.reject { |m| send("#{m}_value") == other send("#{m}_value") } +
(Relation::MULTI_VALUE_METHODS - [:joins, :eager_load, :references, :extending]).reject { |m| send("#{m}_values") == other send("#{m}_values") } +
(Relation::CLAUSE_METHODS - [:having, :where]).reject { |m| send("#{m}_clause") == other send("#{m}_clause") }
end
end
end
A has one relation to a model called Or
If you happen to have a model/table named 'Or' then you can override the or: key syntax by specifying a special or_key:-
class AuthorFilter
include Rokaki::FilterModel
or_key :my_or
filter_map :author, :query,
like: {
articles: {
title: :circumfix,
my_or: { # the or is aware of the join and will generate a compound join aware or query
or: { # The Or model has a title field
title: :circumfix
}
}
},
}
attr_accessor :filters, :model
def initialize(filters:)
@filters = filters
end
end
filters = { query: "Syntaxes" }
= AuthorFilter.new(filters: filters).results
See this issue for details.
3. The porcelain command syntax
In this syntax you will need to provide three keywords:- filters, like and filter_model if you are not passing in the model type and assigning it to @model
class ArticleFilter
include Rokaki::FilterModel
filters :date, :title, author: [:first_name, :last_name]
like title: :circumfix
# ilike title: :circumfix # case insensitive mode
attr_accessor :filters
def initialize(filters:, model: Article)
@filters = filters
@model = model
end
end
Or without the model in the initializer
class ArticleFilter
include Rokaki::FilterModel
filters :date, :title, author: [:first_name, :last_name]
like title: :circumfix
filter_model :article
attr_accessor :filters
def initialize(filters:)
@filters = filters
end
end
Would produce a query with a LIKE which circumfixes '%' around the filter term, like:-
@model = @model.where('title LIKE :query', query: "%#{title}%")
Deep nesting
You can filter joins both with basic matching and partial matching
class ArticleFilter
include Rokaki::FilterModel
filter :author,
like: {
articles: {
reviews: {
title: :circumfix
}
},
}
attr_accessor :filters
def initialize(filters:)
@filters = filters
end
end
Array params
You can pass array params (and partially match them), to filters (search multiple matches) in databases that support it (postgres) by passing the db param to the filter keyword, and passing an array of search terms at runtine
class ArticleFilter
include Rokaki::FilterModel
filter :article,
like: {
author: {
first_name: :circumfix,
last_name: :circumfix
}
},
match: i[title created_at],
db: :postgres
attr_accessor :filters
def initialize(filters:)
@filters = filters
end
end
filterable = ArticleFilter.new(filters:
{
author: {
first_name: ['Match One', 'Match Two']
}
}
)
filterable.results
Development
Ruby setup
After checking out the repo, run bin/setup to install dependencies.
Setting up the test databases
Postgres
docker pull postgres
docker run --name rokaki-postgres -e POSTGRES_USER=rokaki -e POSTGRES_PASSWORD=rokaki -d -p 5432:5432 postgres
Mysql
docker pull mysql
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
Specialised test runner
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.
From the rokaki root directory run: ./spec/ordered_run.sh. This is the same script that runs on the Github CI here:
Standard test runner (only recommended for development cycles)
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.
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.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/tevio/rokaki. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Rokaki project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.