[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"i-simple-icons:linkedin":3,"i-simple-icons:x":9,"i-simple-icons:github":11,"sanity-pO23hiR4ml7wgLAdSpIQIi6LOqjpnsxgkXib1zGBKbg":13,"sanity-VnfaEzxUVdzHsCP61ZpkPyTlx7xUNBedWv4QY2Kb73I":45,"i-iconoir:menu":440},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7,"hidden":8},0,24,false,"\u003Cpath fill=\"currentColor\" d=\"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037c-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85c3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.06 2.06 0 0 1-2.063-2.065a2.064 2.064 0 1 1 2.063 2.065m1.782 13.019H3.555V9h3.564zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0z\"\u002F>",true,{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":10},"\u003Cpath fill=\"currentColor\" d=\"M14.234 10.162L22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299l-.929-1.329L3.076 1.56h3.182l5.965 8.532l.929 1.329l7.754 11.09h-3.182z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":12},"\u003Cpath fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"\u002F>",{"data":14,"sourceMap":-1},{"items":15,"title":44},[16,24,29,34,39],{"_type":17,"link":18,"_key":23},"navLink",{"linkType":19,"_type":20,"external":21,"label":22},"external","cta","\u002Fservices","Services","k0svc",{"_type":17,"link":25,"_key":28},{"_type":20,"external":26,"label":27,"linkType":19},"\u002Fcases","Work","k1work",{"_type":17,"link":30,"_key":33},{"linkType":19,"_type":20,"external":31,"label":32},"\u002Fproducts","Products","kprods",{"link":35,"_key":38,"_type":17},{"_type":20,"external":36,"label":37,"linkType":19},"\u002Fabout","About","k2about",{"_key":40,"_type":17,"link":41},"k075to",{"external":42,"label":43,"linkType":19,"_type":20},"\u002Fblog","Blog","Main",{"data":46,"sourceMap":-1},{"author":47,"_id":49,"title":50,"_updatedAt":51,"slug":52,"publishedAt":55,"body":56},{"name":48},"Elvinas Predkelis","post-building-a-complex-filtering-system-with-ruby-on-rails","Building a complex filtering system with Ruby on Rails","2026-07-12T09:06:20Z",{"_type":53,"current":54},"slug","building-a-complex-filtering-system-with-ruby-on-rails","2024-09-09T09:00:00.000Z",[57,68,76,85,109,117,122,125,141,149,157,166,206,227,230,238,266,282,285,293,301,317,320,328,336,339,347,355,371,374,382,405,408,416,424,432],{"markDefs":58,"style":59,"_key":60,"_type":61,"children":62},[],"normal","k8vvkhi","block",[63],{"_key":64,"_type":65,"marks":66,"text":67},"k8u3yfo","span",[],"Building a filtering system is a common feature while building any kind of web application. As the codebase grows, the filtering system might become more complex. In this article, we'll explore how to build a dynamic filtering system in Ruby on Rails.",{"_type":61,"children":69,"markDefs":74,"style":59,"_key":75},[70],{"_key":71,"_type":65,"marks":72,"text":73},"k8wnfbf",[],"As an example, we'll try to create a filtering system for an Linkedin-like application.",[],"k8x7lsq",{"markDefs":77,"style":78,"_key":79,"_type":61,"children":80},[],"h2","k8zf6oa",[81],{"marks":82,"text":83,"_key":84,"_type":65},[],"Filterable models","k8ys0gt",{"style":59,"_key":86,"_type":61,"children":87,"markDefs":108},"k95a5ek",[88,92,96,100,104],{"text":89,"_key":90,"_type":65,"marks":91},"For this example we'll assume two models: ","k9026fx",[],{"marks":93,"text":94,"_key":95,"_type":65},[],"Person","k91ljuk",{"_type":65,"marks":97,"text":98,"_key":99},[]," and ","k92kyhn",{"_key":101,"_type":65,"marks":102,"text":103},"k93dgsh",[],"Company",{"text":105,"_key":106,"_type":65,"marks":107},". We'll be attaching the filtering logic to these entities later in the article.","k944470",[],[],{"markDefs":110,"style":59,"_key":111,"_type":61,"children":112},[],"k97tg90",[113],{"text":114,"_key":115,"_type":65,"marks":116},"The minimal setup for these models would look something like this:","k96uyv5",[],{"language":118,"_key":119,"_type":120,"code":121},"ruby","k98tmug","code","# app\u002Fmodels\u002Fpost.rb\nclass Person \u003C ApplicationRecord\n  # Concerns\n  include Filterable\n\n  # Scopes\n  scope :visible, -> { where(visible: true) }\nend",{"_type":120,"code":123,"language":118,"_key":124},"# app\u002Fmodels\u002Fcompany.rb\nclass Company \u003C ApplicationRecord\n  # Concerns\n  include Filterable\n\n  # Scopes\n  scope :visible, -> { where(visible: true) }\nend","k99m5b8",{"style":59,"_key":126,"_type":61,"children":127,"markDefs":140},"k9dxj24",[128,132,136],{"_type":65,"marks":129,"text":130,"_key":131},[],"In this case, we add a ","k9ax4kp",{"marks":133,"text":134,"_key":135,"_type":65},[],"Filterable","k9bkxn6",{"_key":137,"_type":65,"marks":138,"text":139},"k9c1ocd",[]," concern which will include all of the filtering logic. We'll discuss this concern in the next section.",[],{"_type":61,"children":142,"markDefs":147,"style":78,"_key":148},[143],{"_key":144,"_type":65,"marks":145,"text":146},"k9ehmun",[],"Concern",[],"k9fef7r",{"_type":61,"children":150,"markDefs":155,"style":59,"_key":156},[151],{"_type":65,"marks":152,"text":153,"_key":154},[],"To make the filtering system more modular, we can extract the filtering logic into a concern. In that case, we'll be able to just include the concern in any model we want to filter.","k9gtxeo",[],"k9hlho5",{"children":158,"markDefs":163,"style":164,"_key":165,"_type":61},[159],{"marks":160,"text":161,"_key":162,"_type":65},[],"Querying","k9imezq",[],"h3","k9jq1qm",{"_key":167,"_type":61,"children":168,"markDefs":205,"style":59},"k9tfbed",[169,173,177,181,185,189,193,197,201],{"_type":65,"marks":170,"text":171,"_key":172},[],"We'll assume that both of the models will be filtered by these three parameters: ","k9kxher",{"_type":65,"marks":174,"text":175,"_key":176},[],"industries","k9lel3i",{"text":178,"_key":179,"_type":65,"marks":180},", ","k9ml93l",[],{"_key":182,"_type":65,"marks":183,"text":184},"k9niqxr",[],"location",{"_key":186,"_type":65,"marks":187,"text":188},"k9oncdq",[],", and ",{"marks":190,"text":191,"_key":192,"_type":65},[],"tags","k9pupvn",{"_type":65,"marks":194,"text":195,"_key":196},[],". We'll take these parameters from the request and pass them to the ","k9qp8fx",{"marks":198,"text":199,"_key":200,"_type":65},[],"#where","k9ra6m7",{"_key":202,"_type":65,"marks":203,"text":204},"k9s55vj",[]," query method.",[],{"_key":207,"_type":61,"children":208,"markDefs":226,"style":59},"k9z9mae",[209,213,216,219,222],{"marks":210,"text":211,"_key":212,"_type":65},[],"To make it more interesting, we'll assume that the filterable attributes are associated with the ","k9u2fzv",{"marks":214,"text":94,"_key":215,"_type":65},[],"k9vha9x",{"text":98,"_key":217,"_type":65,"marks":218},"k9wkbz1",[],{"_key":220,"_type":65,"marks":221,"text":103},"k9xvkfr",[],{"marks":223,"text":224,"_key":225,"_type":65},[]," models. This means that we'll have to query the associated records.","k9yv5sb",[],{"_key":228,"_type":120,"code":229,"language":118},"ka0ufh2","Company.where(tags: { name: params[:tags] })",{"style":164,"_key":231,"_type":61,"children":232,"markDefs":237},"ka2euhv",[233],{"_key":234,"_type":65,"marks":235,"text":236},"ka1de9g",[],"Conditional chaining",[],{"children":239,"markDefs":261,"style":59,"_key":265,"_type":61},[240,244,249,253,257],{"text":241,"_key":242,"_type":65,"marks":243},"Most of the filtering magic will happen due to Ruby's ","ka3v59z",[],{"_key":245,"_type":65,"marks":246,"text":248},"ka5hzs5",[247],"ka4ys0d","`#yield_self`",{"_key":250,"_type":65,"marks":251,"text":252},"ka6wjyz",[]," method. Luckily, since Ruby 2.6, a more readable ",{"_type":65,"marks":254,"text":255,"_key":256},[],"#then","ka7dbm4",{"_key":258,"_type":65,"marks":259,"text":260},"ka85abr",[]," alias was introduced.",[262],{"_key":247,"_type":263,"href":264},"link","https:\u002F\u002Fdocs.ruby-lang.org\u002Fen\u002F2.5.0\u002FObject.html#method-i-yield_self","ka904z0",{"markDefs":267,"style":59,"_key":268,"_type":61,"children":269},[],"kadu8gw",[270,274,278],{"_type":65,"marks":271,"text":272,"_key":273},[],"The method yields self to the block and returns the result of the block, as per documentation. We'll leverage this method to chain the filtering queries conditionally. As a result, we can construct a ","kaammcp",{"marks":275,"text":276,"_key":277,"_type":65},[],"filtered_by","kabdgwj",{"text":279,"_key":280,"_type":65,"marks":281}," scope for filterable models.","kaceec3",[],{"_key":283,"_type":120,"code":284,"language":118},"kae3jeo","scope :filtered_by, ->(params) {\n  self\n    .then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }\n    .then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }\n    .then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }\n}",{"_type":61,"children":286,"markDefs":291,"style":164,"_key":292},[287],{"text":288,"_key":289,"_type":65,"marks":290},"Eager Loading","kaf464e",[],[],"kagybm7",{"_key":294,"_type":61,"children":295,"markDefs":300,"style":59},"kaiml50",[296],{"_key":297,"_type":65,"marks":298,"text":299},"kahjnz7",[],"Eager loading is a way to load all of the associated records in one go. This is useful when you have a lot of records and you want to avoid the N+1 query problem. However, when dealing with a composable filtering system, loading records eagerly might not be straightforward.",[],{"_type":61,"children":302,"markDefs":315,"style":59,"_key":316},[303,307,311],{"_type":65,"marks":304,"text":305,"_key":306},[],"We'll use ","kajnh4w",{"_type":65,"marks":308,"text":309,"_key":310},[],"#includes","kakp812",{"_type":65,"marks":312,"text":313,"_key":314},[]," method to load the associated records in one sweep. In our case, we'll construct the arguments for the loading method dynamically.","kalil70",[],"kamh9mx",{"_type":120,"code":318,"language":118,"_key":319},"def inclusion_arguments(params)\n  args = []\n  args \u003C\u003C :industries if params[:industries].present?\n  args \u003C\u003C :location if params[:location].present?\n  args \u003C\u003C :tags if params[:tags].present?\n  args\nend","kan688q",{"markDefs":321,"style":164,"_key":322,"_type":61,"children":323},[],"kapd46n",[324],{"_type":65,"marks":325,"text":326,"_key":327},[],"Final concern","kaov3v2",{"_key":329,"_type":61,"children":330,"markDefs":335,"style":59},"kar11li",[331],{"_type":65,"marks":332,"text":333,"_key":334},[],"Now that we have all the pieces, we can combine them together. This should be the final concern that we can include in our models.","kaqi4vj",[],{"_type":120,"code":337,"language":118,"_key":338},"# app\u002Fmodels\u002Fconcerns\u002Ffilterable.rb\nmodule Filterable\n  extend ActiveSupport::Concern\n\n  included do\n    scope :filtered_by, ->(params) {\n      includes(inclusion_arguments(params))\n        .then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }\n        .then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }\n        .then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }\n    }\n    scope :ordered, -> { order(created_at: :desc) }\n  end\n\n  private\n\n  def inclusion_args(params)\n    args = []\n    args \u003C\u003C :industries if params[:industries].present?\n    args \u003C\u003C :location if params[:location].present?\n    args \u003C\u003C :tags if params[:tags].present?\n    args\n  end\nend","kasi03b",{"_key":340,"_type":61,"children":341,"markDefs":346,"style":78},"kauze3a",[342],{"text":343,"_key":344,"_type":65,"marks":345},"Controller","katk39a",[],[],{"_type":61,"children":348,"markDefs":353,"style":59,"_key":354},[349],{"_type":65,"marks":350,"text":351,"_key":352},[],"Now that we have the filtering logic in place, we can use it in our controllers. We'll assume that the filtering parameters are passed as query parameters.","kavrxwu",[],"kaw2cij",{"_type":61,"children":356,"markDefs":369,"style":59,"_key":370},[357,361,365],{"_key":358,"_type":65,"marks":359,"text":360},"kaxkg10",[],"For this example, we'll have ",{"_key":362,"_type":65,"marks":363,"text":364},"kaym5pw",[],"ResultsController",{"_type":65,"marks":366,"text":367,"_key":368},[]," controller that will be responsible for rendering the filtered results of all kinds.","kazr1oo",[],"kb0pnk2",{"_key":372,"_type":120,"code":373,"language":118},"kb1jid2","# app\u002Fcontrollers\u002Fresults_controller.rb\nclass ResultsController \u003C ApplicationController\n  def index\n    @people = Person.filtered_by(params).ordered\n    @companies = Company.filtered_by(params).ordered\n  end\nend",{"children":375,"markDefs":380,"style":78,"_key":381,"_type":61},[376],{"marks":377,"text":378,"_key":379,"_type":65},[],"Bonus: Overrides","kb2biuk",[],"kb3rf4r",{"children":383,"markDefs":403,"style":59,"_key":404,"_type":61},[384,388,391,395,399],{"marks":385,"text":386,"_key":387,"_type":65},[],"In some cases, you might want to override the filtering logic for a specific model. For example, you might want to filter the ","kb4gq5b",{"_type":65,"marks":389,"text":94,"_key":390},[],"kb5q6jx",{"_type":65,"marks":392,"text":393,"_key":394},[]," model by the ","kb6g87y",{"text":396,"_key":397,"_type":65,"marks":398},"language","kb7uxoc",[],{"marks":400,"text":401,"_key":402,"_type":65},[]," attribute and have a different default ordering.","kb8p6d1",[],"kb9imuo",{"code":406,"language":118,"_key":407,"_type":120},"# app\u002Fmodels\u002Fconcerns\u002Fperson\u002Ffilterable.rb\nmodule Person::Filterable\n  extend ActiveSupport::Concern\n\n  included do\n    scope :filtered_by, ->(params) {\n      includes(inclusion_arguments(params))\n        .then { params[:languages].present? ? _1.where(languages: { name: params[:languages] }) : _1 }\n        .then { params[:industries].present? ? _1.where(industries: { name: params[:industries] }) : _1 }\n        .then { params[:location].present? ? _1.where(location: { name: params[:location] }) : _1 }\n        .then { params[:tags].present? ? _1.where(tags: { name: params[:tags] }) : _1 }\n    }\n    scope :ordered, -> { order(updated_at: :asc) }\n  end\n\n  private\n\n  def inclusion_args(params)\n    args = []\n    args \u003C\u003C :languages if params[:industries].present?\n    args \u003C\u003C :industries if params[:industries].present?\n    args \u003C\u003C :location if params[:location].present?\n    args \u003C\u003C :tags if params[:tags].present?\n    args\n  end\nend","kbabod5",{"_key":409,"_type":61,"children":410,"markDefs":415,"style":59},"kbctlsd",[411],{"_key":412,"_type":65,"marks":413,"text":414},"kbbviqd",[],"Note that we've added a namespace and placed the concern in a separate file.",[],{"_type":61,"children":417,"markDefs":422,"style":78,"_key":423},[418],{"_key":419,"_type":65,"marks":420,"text":421},"kbdd2bw",[],"Final remarks",[],"kbe12gr",{"children":425,"markDefs":430,"style":59,"_key":431,"_type":61},[426],{"_type":65,"marks":427,"text":428,"_key":429},[],"The solution that was outlined in this article is a powerful yet flexible way to implement a filter system to your Rails application. This is especially useful for applications with multiple filterable models and larger datasets.","kbfjxfb",[],"kbg2g9v",{"children":433,"markDefs":438,"style":59,"_key":439,"_type":61},[434],{"text":435,"_key":436,"_type":65,"marks":437},"It avoids unnecessary database queries and is easy to maintain. Also, as a pleasant side effect, it saves a lot of headaches while developing the frontend.","kbhqccm",[],[],"kbiyeur",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":441},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M3 5h18M3 12h18M3 19h18\"\u002F>"]