{"id":15562066,"url":"https://github.com/stevegeek/quo","last_synced_at":"2025-10-08T17:25:02.787Z","repository":{"id":63620211,"uuid":"567397890","full_name":"stevegeek/quo","owner":"stevegeek","description":"Quo is a query object gem for Rails/ActiveRecord","archived":false,"fork":false,"pushed_at":"2025-07-25T10:27:05.000Z","size":289,"stargazers_count":12,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-09-13T09:06:07.748Z","etag":null,"topics":["activerecord","queryobject","rails"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/stevegeek.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2022-11-17T17:49:22.000Z","updated_at":"2025-07-25T10:27:08.000Z","dependencies_parsed_at":"2024-09-05T19:57:27.747Z","dependency_job_id":"913161a3-1399-4c4f-86c0-c5d739866a6c","html_url":"https://github.com/stevegeek/quo","commit_stats":{"total_commits":44,"total_committers":1,"mean_commits":44.0,"dds":0.0,"last_synced_commit":"086a58750f3d7a4106332653cf7465c9c41fd90b"},"previous_names":[],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/stevegeek/quo","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stevegeek%2Fquo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stevegeek%2Fquo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stevegeek%2Fquo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stevegeek%2Fquo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/stevegeek","download_url":"https://codeload.github.com/stevegeek/quo/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/stevegeek%2Fquo/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":278882165,"owners_count":26062234,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-08T02:00:06.501Z","response_time":56,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","queryobject","rails"],"created_at":"2024-10-02T16:11:17.929Z","updated_at":"2025-10-08T17:25:02.754Z","avatar_url":"https://github.com/stevegeek.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Quo: Query Objects for ActiveRecord \u0026 Collections\n\nQuo helps you organize database and collection queries into reusable, composable, and testable objects.\n\n## Quick Example\n\n```ruby\n# Define query objects to encapsulate query logic\nclass RecentPostsQuery \u003c Quo::RelationBackedQuery\n  # Type-safe properties with defaults\n  prop :days_ago, Integer, default: -\u003e { 7 }\n  \n  def query\n    Post.where(Post.arel_table[:created_at].gt(days_ago.days.ago))\n      .order(created_at: :desc)\n  end\nend\n\n# Use queries with pagination\nposts_query = RecentPostsQuery.new(days_ago: 30, page: 1, page_size: 10)\npage1 = posts_query.results\n# =\u003e Returns first 10 posts from the last 30 days\n\n# Navigate between pages\npage2_query = posts_query.next_page_query\npage2 = page2_query.results\n# =\u003e Returns next 10 posts\n\nclass CommentNotSpamQuery \u003c Quo::RelationBackedQuery\n  prop :spam_score_threshold, _Float(0..1.0)\n\n  def query\n    comments = Comment.arel_table\n    Comment.where(\n      comments[:spam_score].eq(nil).or(comments[:spam_score].lt(spam_score_threshold))\n    )\n  end\nend\n\n# Get recent posts (last 10 days) which have comments that are not Spam\nposts_last_10_days = RecentPostsQuery.new(days_ago: 10).joins(:comments)\n\n# Compose your queries\nquery = posts_last_10_days + CommentNotSpamQuery.new(spam_score_threshold: 0.5)\n\n# Transform results\ntransformed_query = query.transform { |post| PostPresenter.new(post) }\n\n# Work with result sets\ntransformed_query.results.each do |presenter|\n  puts presenter.formatted_title\nend\n```\n\n\n## Core Features\n\n### Collections\n* Query objects can wrap either an ActiveRecord relation (`RelationBackedQuery`) or any Enumerable collection (`CollectionBackedQuery`)\n* Built-in pagination that works with both database queries and enumerable collections\n* Flexible interface for creating custom queries or wrapping existing queries\n\n### Configurable\n* Type-safe properties with optional default values using the Literal gem\n* Each query is (kinda) \"immutable\" - operations return new query instances, mutation is actively frowned upon\n* Configure your own base classes, default page sizes, and more\n\n### Composition and Transformation\n* Combine queries using the `+` operator (alias for `compose` method)\n* Mix and match relation-backed and collection-backed queries\n* Join queries with explicit join conditions using the `joins` parameter\n* Transform results consistently using the `transform` method\n\n### Fluent API\n* Chain methods that mirror ActiveRecord's query interface (where, order, limit, etc.)\n* Access utility methods that work on both relation and collection queries (exists?, empty?, etc.)\n* Navigation helpers for pagination (next_page_query, previous_page_query)\n\n### Query Results\n* Clear separation between query definition and execution with `Results` objects\n* Automatic application of transformations across all result methods\n* Consistent interface regardless of the underlying query type\n* Support for common methods: each, map, first/last, count, exists?, group_by, and more\n\n\n## Core Concepts\n\nQuery objects encapsulate query logic in dedicated classes, making complex queries more manageable and reusable.\n\nQuo provides two main components:\n1. **Query Objects** - Define and configure queries\n2. **Results Objects** - Execute queries and provide access to the results\n\n## Creating Query Objects\n\n### Relation-Backed Queries\n\nFor queries based on ActiveRecord relations:\n\n```ruby\nclass RecentActiveUsers \u003c Quo::RelationBackedQuery\n  # Define typed properties\n  prop :days_ago, Integer, default: -\u003e { 30 }\n  \n  def query\n    User\n      .where(active: true)\n      .where(\"created_at \u003e ?\", days_ago.days.ago)\n  end\nend\n\n# Create and use the query\nquery = RecentActiveUsers.new(days_ago: 7)\nresults = query.results\n\n# Work with results\nresults.each { |user| puts user.email }\nputs \"Found #{results.count} users\"\n```\n\n### Collection-Backed Queries\n\nFor queries based on any Enumerable collection:\n\n```ruby\nclass CachedUsers \u003c Quo::CollectionBackedQuery\n  prop :role, String\n  \n  def collection\n    @cached_users ||= Rails.cache.fetch(\"all_users\", expires_in: 1.hour) do\n      User.all.to_a\n    end.select { |user| user.role == role }\n  end\nend\n\n# Use the query\nadmins = CachedUsers.new(role: \"admin\").results\n```\n\n## Quick Queries with Wrap and to_collection\n\n### Creating Query Objects with Wrap\n\nCreate query objects on the fly without subclassing using the `wrap` class method:\n\n```ruby\n# Relation-backed query from an ActiveRecord relation\nusers_query = Quo::RelationBackedQuery.wrap(User.active).new\nactive_users = users_query.results\n\n# Relation-backed query with a block\nposts_query = Quo::RelationBackedQuery.wrap(props: {tag: String}) do\n  Post.where(published: true).where(\"title LIKE ?\", \"%#{tag}%\")\nend\ntagged_posts = posts_query.new(tag: \"ruby\").results\n\n# Collection-backed query from an array\nitems_query = Quo::CollectionBackedQuery.wrap([1, 2, 3]).new\nitems = items_query.results\n\n# Collection-backed query with properties and a block\nfiltered_query = Quo::CollectionBackedQuery.wrap(props: {min: Integer}) do\n  [1, 2, 3, 4, 5].select { |n| n \u003e= min }\nend\nresult = filtered_query.new(min: 3).results # [3, 4, 5]\n```\n\n### Converting Between Query Types\n\nConvert a relation-backed query to a collection-backed query using `to_collection`:\n\n```ruby\n# Start with a relation-backed query\nrelation_query = UsersByState.new(state: \"California\")\n\n# Convert to a collection-backed query (executes the query)\ncollection_query = relation_query.to_collection\ncollection_query.collection? # =\u003e true\ncollection_query.relation? # =\u003e false\n\n# You can optionally specify a total count (useful for pagination)\ncollection_query = relation_query.to_collection(total_count: 100)\n```\n\nThis is useful when you want to convert an ActiveRecord relation to an enumerable collection while preserving the query interface.\n\n## Type-Safe Properties\n\nQuo uses the `Literal` gem for typed properties:\n\n```ruby\nclass UsersByState \u003c Quo::RelationBackedQuery\n  prop :state, String\n  prop :minimum_age, Integer, default: -\u003e { 18 }\n  prop :active_only, Boolean, default: -\u003e { true }\n\n  def query\n    scope = User.where(state: state)\n    scope = scope.where(\"age \u003e= ?\", minimum_age) if minimum_age.present?\n    scope = scope.where(active: true) if active_only\n    scope\n  end\nend\n\nquery = UsersByState.new(state: \"California\", minimum_age: 21)\n```\n\n## Pagination\n\n```ruby\nquery = UsersByState.new(\n  state: \"California\",\n  page: 2,\n  page_size: 20\n)\n\n# Get paginated results for page 2 with 20 items\nusers = query.results\n\n# Navigation to next and previous pages creates new queries\nnext_page = query.next_page_query\nprev_page = query.previous_page_query\n```\n\n## Composing Queries\n\nQuo provides extensive query composition capabilities, letting you combine multiple query objects:\n\n```ruby\nclass ActiveUsers \u003c Quo::RelationBackedQuery\n  def query\n    User.where(active: true)\n  end\nend\n\nclass PremiumUsers \u003c Quo::RelationBackedQuery\n  def query\n    User.where(subscription_tier: \"premium\")\n  end\nend\n\n# Compose queries using the + operator\nactive_premium = ActiveUsers.new + PremiumUsers.new\nusers = active_premium.results\n```\n\nYou can compose queries in several ways:\n* At the class level: `ActiveUsers.compose(PremiumUsers)` or `ActiveUsers + PremiumUsers`\n* At the instance level: `active_query.compose(premium_query)` or `active_query + premium_query`\n* With joins: `active_query.compose(premium_query, joins: :some_association)`\n\nQuo handles different composition scenarios automatically:\n* Relation + Relation: Uses ActiveRecord's merge capabilities\n* Relation + Collection: Combines the results of both\n* Collection + Collection: Concatenates the collections\n\nFor example, to compose query objects with proper joins:\n\n```ruby\n# Query for posts\nclass PostsQuery \u003c Quo::RelationBackedQuery\n  def query\n    Post.where(published: true)\n  end\nend\n\n# Query for authors\nclass AuthorsQuery \u003c Quo::RelationBackedQuery\n  def query\n    Author.where(active: true)\n  end\nend\n\n# Compose with a joins parameter to specify the relationship\ncomposed_query = PostsQuery.new.compose(AuthorsQuery.new, joins: :author)\n# You can also use this equivalent form:\n# composed_query = PostsQuery.new.joins(:author) + AuthorsQuery.new\n\n# Returns published posts by active authors\nresults = composed_query.results\n```\n\n\n## Utility Methods\n\nQuo query objects provide several utility methods to help you work with them:\n\n```ruby\nquery = UsersByState.new(state: \"California\")\n\n# Check query type\nquery.relation?   # =\u003e true if backed by an ActiveRecord relation\nquery.collection? # =\u003e true if backed by a collection\n\n# Check pagination status\nquery.paged?      # =\u003e true if pagination is enabled (page is set)\n\n# Check transformation status\nquery.transform?  # =\u003e true if a transformer is set\n\n# Get the raw underlying query without pagination\nraw_query = query.unwrap_unpaginated  # =\u003e The ActiveRecord relation or collection\n\n# Get the configured query with pagination\nconfigured_query = query.unwrap  # =\u003e The query with pagination applied\n\n# For RelationBackedQuery, get SQL representation\nputs query.to_sql  # =\u003e \"SELECT users.* FROM users WHERE users.state = 'California'\"\n```\n\n## Transforming Results\n\n```ruby\nquery = UsersByState.new(state: \"California\")\n  .transform { |user| UserPresenter.new(user) }\n\n# Results are automatically transformed\npresenters = query.results.to_a # Array of UserPresenter objects\n```\n\n## Working with Results Objects\n\nWhen you call `.results` on a query object, you get a `Results` object that wraps the underlying collection and ensures consistent application of transformations.\n\n```ruby\n# Create a query with a transformer\nusers_query = UsersByState.new(state: \"California\")\n  .transform { |user| UserPresenter.new(user) }\n\n# Get results - transformations are applied consistently\nresults = users_query.results\n\n# Existence checks\nresults.exists?  # =\u003e true/false\nresults.empty?   # =\u003e false/true\n\n# Count methods\nresults.count        # Total count of results (ignoring pagination)\nresults.total_count  # Same as count\nresults.size         # Same as count\nresults.page_count   # Count of items on current page (respects pagination)\nresults.page_size    # Same as page_count\n\n# Enumerable methods - all respect transformations\nresults.each { |presenter| puts presenter.formatted_name }\nresults.map { |presenter| presenter.email }\nresults.select { |presenter| presenter.active? }\nresults.reject { |presenter| presenter.inactive? }\nresults.first  # Returns the first transformed item\nresults.last   # Returns the last transformed item\nresults.first(3)  # Returns the first 3 transformed items\nresults.to_a  # Returns all transformed items as an array\n\n# ActiveRecord extensions (for RelationResults)\nresults.find(123)  # Find by id and transform\nresults.find_by(email: \"user@example.com\")  # Find by attributes and transform\nresults.where(active: true)  # Returns a new Results with the condition applied\n\n# Methods are delegated to the underlying collection\n# and transformations are applied consistently\nresults.group_by(\u0026:role)  # Groups transformed objects by role\n```\n\nQuo provides two types of Results objects:\n- `RelationResults` - For ActiveRecord-based queries, delegates to the underlying relation\n- `CollectionResults` - For collection-based queries, delegates to the enumerable collection\n\n## Fluent API for Building Queries\n\nQuo implements a fluent API that mirrors ActiveRecord's query interface, allowing you to chain methods that build up your query:\n\n```ruby\n# Start with a base query\nquery = UsersByState.new(state: \"California\")\n\n# Chain method calls to build your query\nrefined_query = query\n  .order(created_at: :desc)    # Order results\n  .includes(:profile, :posts)  # Eager load associations\n  .joins(:posts)               # Join with posts\n  .where(verified: true)       # Add conditions\n  .limit(10)                   # Limit results\n  .group(\"users.role\")         # Group results\n  \n# Original query remains unchanged\noriginal_results = query.results\nrefined_results = refined_query.results\n\n# You can further refine as needed\nadmin_query = refined_query.where(role: \"admin\")\n```\n\nAvailable methods for relation-backed queries include:\n* `where` - Add conditions to the query\n* `not` - Negate conditions\n* `or` - Add OR conditions\n* `order` - Set the order of results\n* `reorder` - Replace existing order\n* `limit` - Limit the number of results\n* `offset` - Set an offset for results\n* `includes` - Eager load associations\n* `preload` - Preload associations\n* `eager_load` - Eager load with LEFT OUTER JOIN\n* `joins` - Add inner joins\n* `left_outer_joins` - Add left outer joins\n* `group` - Group results\n* `select` - Specify columns to select\n* `distinct` - Return distinct results\n\nEach method returns a new query instance without modifying the original, ensuring queries are immutable and can be safely composed.\n\n## Association Preloading in Collection-Backed Queries\n\nWhen working with enumerable collections of ActiveRecord models, you can still preload associations to avoid N+1 queries. This is particularly useful when you have collections that don't come directly from the database but still need efficient association loading.\n\nInclude the `Quo::Preloadable` module in your collection-backed query and use the `includes` or `preload` methods:\n\n```ruby\nclass FirstAndLastUsers \u003c Quo::CollectionBackedQuery\n  include Quo::Preloadable\n  \n  def collection\n    [User.first, User.last] # These users come from separate queries\n  end\nend\n\n# Preload the profiles and posts for both users in a single efficient query\nquery = FirstAndLastUsers.new.includes(:profile, :posts)\n\n# Check that the association is loaded\nquery.results.first.profile.loaded? # =\u003e true\nquery.results.last.posts.loaded? # =\u003e true\n\n# Access the preloaded associations without triggering additional queries\nquery.results.each do |user|\n  puts \"#{user.name} has #{user.posts.size} posts\"\nend\n```\n\nThe `Preloadable` module overrides the `query` method to apply ActiveRecord's preloader to your collection.\n\n### Composing with Joins\n\n```ruby\nclass ProductsQuery \u003c Quo::RelationBackedQuery\n  def query\n    Product.where(active: true)\n  end\nend\n\nclass CategoriesQuery \u003c Quo::RelationBackedQuery\n  def query\n    Category.where(featured: true)\n  end\nend\n\n# Compose with a join\nproducts = ProductsQuery.new.compose(CategoriesQuery.new, joins: :category)\n\n# Equivalent to:\n# Product.joins(:category)\n#        .where(products: { active: true })\n#        .where(categories: { featured: true })\n```\n\n## Testing Helpers\n\nQuo provides testing helpers for both Minitest and RSpec to make your query objects easy to test in isolation.\n\n### Minitest\n\nThe `Quo::Minitest::Helpers` module includes the `fake_query` method that lets you mock query results without hitting the database:\n\n```ruby\nclass UserQueryTest \u003c ActiveSupport::TestCase\n  include Quo::Minitest::Helpers\n\n  test \"filters users by state\" do\n    # Create test data\n    users = [User.new(name: \"Alice\"), User.new(name: \"Bob\")]\n    \n    # Mock the query results within the block\n    fake_query(UsersByState, results: users) do\n      # Any instance of UsersByState created inside this block\n      # will return the mocked results regardless of query parameters\n      result = UsersByState.new(state: \"California\").results.to_a\n      assert_equal users, result\n      \n      # You can create multiple instances with different parameters\n      other_result = UsersByState.new(state: \"New York\").results.to_a\n      assert_equal users, other_result\n    end\n    \n    # After the block, normal behavior resumes\n  end\n  \n  test \"works with pagination\" do\n    users = (1..10).map { |i| User.new(name: \"User #{i}\") }\n    \n    fake_query(UsersByState, results: users) do\n      # Pagination still works with fake query results\n      paginated = UsersByState.new(state: \"California\", page: 1, page_size: 5).results\n      assert_equal 5, paginated.page_count\n      assert_equal 10, paginated.total_count\n    end\n  end\nend\n```\n\n### RSpec\n\nThe same functionality is available for RSpec through the `Quo::RSpec::Helpers` module:\n\n```ruby\nRSpec.describe UsersByState do\n  include Quo::RSpec::Helpers\n\n  it \"filters users by state\" do\n    users = [User.new(name: \"Alice\"), User.new(name: \"Bob\")]\n    \n    fake_query(UsersByState, results: users) do\n      result = UsersByState.new(state: \"California\").results.to_a\n      expect(result).to eq(users)\n      \n      # Test that transformations still work\n      transformed = UsersByState.new(state: \"California\")\n        .transform { |user| user.name.upcase }\n        .results\n        \n      expect(transformed.first).to eq(\"ALICE\")\n    end\n  end\n  \n  it \"can be nested for testing composed queries\" do\n    users = [User.new(name: \"Alice\", active: true)]\n    premium_users = [User.new(name: \"Bob\", subscription: \"premium\")]\n    \n    # Nested fake_query calls for testing composition\n    fake_query(ActiveUsers, results: users) do\n      fake_query(PremiumUsers, results: premium_users) do\n        composed = ActiveUsers.new + PremiumUsers.new\n        expect(composed.results.count).to eq(2)\n      end\n    end\n  end\nend\n```\n\n## Project Organization\n\nSuggested directory structure:\n\n```\napp/\n  queries/\n    application_query.rb\n    users/\n      active_users_query.rb\n      by_state_query.rb\n    products/\n      featured_products_query.rb\n```\n\nBase classes:\n\n```ruby\n# app/queries/application_query.rb\nclass ApplicationQuery \u003c Quo::RelationBackedQuery\n  # Common functionality\nend\n\n# app/queries/application_collection_query.rb\nclass ApplicationCollectionQuery \u003c Quo::CollectionBackedQuery\n  # Common functionality\nend\n```\n\n## Installation\n\nAdd to your Gemfile:\n\n```ruby\ngem \"quo\"\n```\n\nThen execute:\n\n```\n$ bundle install\n```\n\n## Configuration\n\nQuo provides several configuration options to customize its behavior to your needs. Configure these in an initializer:\n\n```ruby\n# config/initializers/quo.rb\nmodule Quo\n  # Set the default number of items per page (default: 20)\n  self.default_page_size = 25\n  \n  # Set the maximum allowed page size to prevent excessive resource usage (default: 200)\n  self.max_page_size = 100\n  \n  # Set custom base classes for your queries\n  # These must be string names of constantizable classes that inherit from \n  # Quo::RelationBackedQuery and Quo::CollectionBackedQuery respectively\n  self.relation_backed_query_base_class = \"ApplicationQuery\"\n  self.collection_backed_query_base_class = \"ApplicationCollectionQuery\"\nend\n```\n\nUsing custom base classes lets you add functionality that's shared across all your query objects in your application.\n\n## Requirements\n\n- Ruby 3.1+\n- Rails 7.0+, 8.0+\n\n## Development\n\nAfter checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.\n\nTo 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/quo.\n\n## Inspired by `rectify`\n\nThis implementation is inspired by the `Rectify` gem: https://github.com/andypike/rectify. Thanks to Andy Pike for the inspiration.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstevegeek%2Fquo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fstevegeek%2Fquo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fstevegeek%2Fquo/lists"}