Project

union_of

0.02
The project is in a healthy, maintained state
Create associations that combine multiple Active Record associations using a SQL UNION under the hood, with full support for joins, preloading and eager loading on the union association, as well as through-union associations.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

>= 7.0
 Project Readme

union_of

CI Gem Version

Use union_of to create associations that combine multiple Active Record associations using a SQL UNION under the hood. union_of fully supports joins, preloading, and eager loading, as well as through-union associations.

This gem was extracted from Keygen and is being used in production to serve millions of API requests per day, performantly querying tables with millions and millions of rows.

Sponsored by:

Keygen

A fair source software licensing and distribution API.

Installation

Add this line to your application's Gemfile:

gem 'union_of'

And then execute:

$ bundle

Or install it yourself as:

$ gem install union_of

Usage

To use union_of, define a has_many association as you would normally, and provide the associations you'd like to union together via the union_of: keyword:

class Book < ActiveRecord::Base
  # the primary author of the book
  belongs_to :author, class_name: 'User', foreign_key: 'author_id', optional: true

  # coauthors of the book via a join table
  has_many :coauthorships
  has_many :coauthors, through: :coauthorships, source: :user

  # prefacers for the book via a join table
  has_many :prefaces
  has_many :prefacers, through: :prefaces, source: :user

  # prefacers for the book via a join table
  has_many :forewords
  has_many :foreworders, through: :forewords, source: :user

  # illustrators for the book via a join table
  has_many :illustrations
  has_many :illustrators, -> { distinct }, through: :illustrations, source: :user

  # editors for the book via a join table
  has_many :edits
  has_many :editors, -> { distinct }, through: :edits, source: :user

  # union association for all contributors to the book
  has_many :contributors, class_name: 'User', union_of: %i[
    author
    coauthors
    foreworders
    prefacers
    illustrators
    editors
  ]
end

Here's a quick example of what's possible:

# contributors to the book
author = User.create(name: 'Isaac Asimov')
editor = User.create(name: 'John W. Campbell')
illustrator = User.create(name: 'Frank Kelly Freas')
writer = User.create(name: 'Ray Bradbury')

# create book by the author
book = Book.create(title: 'I, Robot', author:)

# assign a preface by the author
Preface.create(user: author, book:)

# assign foreword writers
Foreword.create(user: writer, book:)
Foreword.create(user: editor, book:)

# assign an illustrator
Illustration.create(user: illustrator, book:)

# assign an editor
Edit.create(user: editor, book:)

# access all contributors (author, editors, illustrator, etc.)
book.contributors.to_a
# => [#<User id=1, name="Isaac Asimov">, #<User id=2, name="John W. Campbell">, #<User id=3, name="Frank Kelly Freas">, #<User id=4, name="Ray Bradbury">]

# example of querying the union of contributors
book.contributors.order(:name).limit(3)
# => [#<User id=4, name="Frank Kelly Freas">, #<User id=3, name="Isaac Asimov">, #<User id=2, name="John W. Campbell">]

book.contributors.where(id: editor.id)
# => [#<User id=2, name="John W. Campbell">]

book.contributors.to_sql
# => SELECT * FROM users WHERE id IN (
#      SELECT id FROM users WHERE id = 1
#      UNION
#      SELECT users.id FROM users INNER JOIN prefaces ON users.id = prefaces.user_id WHERE prefaces.book_id = 1
#      UNION
#      SELECT users.id FROM users INNER JOIN forewords ON users.id = forewords.user_id WHERE forewords.book_id = 1
#      UNION
#      SELECT DISTINCT users.id FROM users INNER JOIN illustrations ON users.id = illustrations.user_id WHERE illustrations.book_id = 1
#      UNION
#      SELECT DISTINCT users.id FROM users INNER JOIN edits ON users.id = edits.user_id WHERE edits.book_id = 1
#   )

# example of more advanced querying e.g. preloading the union
Book.joins(:contributors).where(contributors: { ... })
Book.preload(:contributors)
Book.eager_load(:contributors)
Book.includes(:contributors)

Right now, the underlying table and model for each unioned association must match. We'd like to change that in the future. Originally, union_of was created to make migrating from a one-to-many relationship to a many-to-many relationship easier and safer, while retaining backwards compatibility.

There is support for complex unions as well, e.g. a union made up of direct and through associations, even when those associations utilize union associations.

Supported databases

We currently support PostgreSQL, MySQL, and MariaDB. We'd love contributions that add SQLite support, but we probably won't add it ourselves.

Supported Rubies

union_of supports Ruby 3.1 and above. We encourage you to upgrade if you're on an older version. Ruby 3 provides a lot of great features, like better pattern matching and a new shorthand hash syntax.

Performance notes

As is expected, you will need to pay close attention to performance and ensure your tables are indexed well. We have tried to make the underlying UNION queries as efficient as possible, but please open an issue or PR if you are encountering a performance issue that is caused by this gem. But good indexing will go a long way.

We use Postgres in production, but we do not actively use MySQL or MariaDB, so there may be performance issues we are unaware of. If you stumble upon issues, please open an issue or a PR.

Acknowledgements

union_of was inspired by active_record_union. For unions outside of associations, check it out.

Is it any good?

Yes.

Contributing

If you have an idea, or have discovered a bug, please open an issue or create a pull request.

License

The gem is available as open source under the terms of the MIT License.