Skip to content

tevio/rokaki

Repository files navigation

Rokaki

Gem Version

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".

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.

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: author_first_name }) if author_first_name
    @articles = @articles.joins(:author).where(authors: { last_name: author_last_name }) if author_last_name
  end
end

article_filter = FilterArticles.new(filters: {
  date: '10-10-10',
  author: {
    first_name: 'Steve',
    last_name: 'Martin'
  }})
article_filter.author_first_name == 'Steve'
article_filter.author_last_name == '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 :fylterz 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 >= 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:-

# 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, 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:-

1. The filter command syntax

class ArticleFilter
  include Rokaki::FilterModel

  filter :article,
    like: { # you can use ilike here instead if you use postgres and 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" }
filtered_authors = 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" }
filtered_authors = 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" }
filtered_authors = 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 postgres 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 database

docker pull postgres
docker run --name rokaki-postgres -e POSTGRES_USER=rokaki -e POSTGRES_PASSWORD=rokaki -d -p 5432:5432 postgres

Then, run rake spec to run the tests. 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.

About

A small, simple filtering DSL

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published