Skip to content

Commit

Permalink
extract gem
Browse files Browse the repository at this point in the history
  • Loading branch information
ezekg committed Aug 9, 2024
1 parent c7badb5 commit a199017
Show file tree
Hide file tree
Showing 17 changed files with 2,056 additions and 28 deletions.
55 changes: 51 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,34 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
ruby:
- '3.3'
- '3.2'
- '3.1'
ruby: ['3.3', '3.2', '3.1']
database: ['sqlite', 'mysql', 'postgres']
services:
mysql:
image: mysql:8.0
ports:
- 3306:3306
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
options: >-
--health-cmd "mysqladmin ping --silent"
--health-interval 10s
--health-timeout 5s
--health-retries 3
postgres:
image: postgres:14
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 3
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -23,5 +47,28 @@ jobs:
with:
ruby-version: ${{matrix.ruby}}
bundler-cache: true
- name: Setup database configuration
run: |
case ${{matrix.database}} in
sqlite)
echo "DATABASE_URL=sqlite3::memory:" >> $GITHUB_ENV
;;
mysql)
echo "DATABASE_URL=mysql2://root:root@localhost/test" >> $GITHUB_ENV
;;
postgres)
echo "DATABASE_URL=postgres://postgres:postgres@localhost/test" >> $GITHUB_ENV
;;
esac
- name: Wait for database
run: |
case ${{matrix.database}} in
mysql)
while ! mysqladmin ping -h"localhost" --silent; do sleep 1; done
;;
postgres)
while ! pg_isready -h "localhost" -U "postgres"; do sleep 1; done
;;
esac
- name: Test
run: bundle exec rake test
104 changes: 91 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,109 @@
# UnionOf
# union_of

TODO: Delete this and the text below, and describe your gem
[![CI](https://github.com/keygen-sh/union_of/actions/workflows/test.yml/badge.svg)](https://github.com/keygen-sh/union_of/actions)
[![Gem Version](https://badge.fury.io/rb/union_of.svg)](https://badge.fury.io/rb/union_of)

Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/union_of`. To experiment with that code, run `bin/console` for an interactive prompt.
Use `union_of` to create unions of other associations in Active Record, using a
SQL `UNION` under the hood. `union_of` has full support for joins, preloading,
and eager loading of union associations.

This gem was extracted from [Keygen](https://keygen.sh) and is being used in
production to serve millions of API requests per day.

Sponsored by:

<a href="https://keygen.sh?ref=union_of">
<div>
<img src="https://keygen.sh/images/logo-pill.png" width="200" alt="Keygen">
</div>
</a>

_A fair source software licensing and distribution API._

## Installation

TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
Add this line to your application's `Gemfile`:

Install the gem and add to the application's Gemfile by executing:
```ruby
gem 'union_of'
```

$ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
And then execute:

If bundler is not being used to manage dependencies, install the gem by executing:
```bash
$ bundle
```

$ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
Or install it yourself as:

```bash
$ gem install union_of
```

## Usage

TODO: Write usage instructions here
To use `union_of`, create a `has_many` association as you would normally, and
define the associations you'd like to union together via `union_of:`:

```ruby
class User < ActiveRecord::Base
has_many :owned_licenses
has_many :license_users
has_many :shared_licenses, through: :license_users, source: :license

# create a union of the user's owned licenses and shared licenses
has_many :licenses, union_of: %i[
owned_licenses
shared_licenses
]
end
```

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

## Development
```ruby
user = User.create
owned_license = License.create(owner: user)

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
3.times do
shared_license = License.create
license_user = LicenseUser.create(license: shared_license, user:)
end

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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
user.licenses.to_a # => [#<License id=1>, #<License id=2>, #<License id=3>, #<License id=4>]
user.licenses.order(:id).limit(1) # => [#<License id=4>]
user.licenses.where(id: 2) # => [#<License id=2>]
user.licenses.to_sql
# => SELECT * FROM licenses WHERE id IN (
# SELECT id FROM licenses WHERE owner_id = ?
# UNION
# SELECT licenses.id FROM licenses INNER JOIN license_users ON licenses.id = license_users.license_id WHERE license_users.user_id = ?
# )

User.joins(:licenses).where(licenses: { ... })
User.preload(:licenses)
User.eager_load(:licenses)
User.includes(:licenses)
```

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

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

## Is it any good?

Yes.

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/union_of.
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](https://opensource.org/licenses/MIT).
19 changes: 19 additions & 0 deletions lib/union_of.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
# frozen_string_literal: true

require 'active_support'
require 'active_record'

require_relative 'union_of/association'
require_relative 'union_of/builder'
require_relative 'union_of/macro'
require_relative 'union_of/preloader'
require_relative 'union_of/readonly_association_proxy'
require_relative 'union_of/readonly_association'
require_relative 'union_of/reflection'
require_relative 'union_of/scope'
require_relative 'union_of/version'
require_relative 'union_of/railtie'

module UnionOf
class Error < ActiveRecord::ActiveRecordError; end

class ReadonlyAssociationError < Error
def initialize(owner, reflection)
super("Cannot modify association '#{owner.class.name}##{reflection.name}' because it is read-only")
end
end
end
16 changes: 16 additions & 0 deletions lib/union_of/association.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

require_relative 'readonly_association'
require_relative 'scope'

module UnionOf
class Association < UnionOf::ReadonlyAssociation
def skip_statement_cache?(...) = true # doesn't work with cache
def association_scope
return if
klass.nil?

@association_scope ||= UnionOf::Scope.create.scope(self)
end
end
end
8 changes: 8 additions & 0 deletions lib/union_of/builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module UnionOf
class Builder < ActiveRecord::Associations::Builder::CollectionAssociation
private_class_method def self.valid_options(...) = %i[sources class_name inverse_of extend]
private_class_method def self.macro = :union_of
end
end
26 changes: 26 additions & 0 deletions lib/union_of/macro.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

require_relative 'builder'

module UnionOf
module Macro
extend ActiveSupport::Concern

class_methods do
def union_of(name, scope = nil, **options, &extension)
reflection = UnionOf::Builder.build(self, name, scope, options, &extension)

ActiveRecord::Reflection.add_union_reflection(self, name, reflection)
ActiveRecord::Reflection.add_reflection(self, name, reflection)
end

def has_many(name, scope = nil, **options, &extension)
if sources = options.delete(:union_of)
union_of(name, scope, **options.merge(sources:), &extension)
else
super
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/union_of/preloader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

require_relative 'preloader/association'

module UnionOf
module Preloader; end
end
88 changes: 88 additions & 0 deletions lib/union_of/preloader/association.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@

# frozen_string_literal: true

module UnionOf
module Preloader
class Association < ActiveRecord::Associations::Preloader::ThroughAssociation
def load_records(*)
preloaded_records # we don't need to load anything except the union associations
end

def preloaded_records
@preloaded_records ||= union_preloaders.flat_map(&:preloaded_records)
end

def records_by_owner
@records_by_owner ||= owners.each_with_object({}) do |owner, result|
if loaded?(owner)
result[owner] = target_for(owner)

next
end

records = union_records_by_owner[owner] || []
records.compact!
records.sort_by! { preload_index[_1] } unless scope.order_values.empty?
records.uniq! if scope.distinct_value

result[owner] = records
end
end

def runnable_loaders
return [self] if
data_available?

union_preloaders.flat_map(&:runnable_loaders)
end

def future_classes
return [] if
run?

union_classes = union_preloaders.flat_map(&:future_classes).uniq
source_classes = source_reflection.chain.map(&:klass)

(union_classes + source_classes).uniq
end

private

def data_available?
owners.all? { loaded?(_1) } || union_preloaders.all?(&:run?)
end

def source_reflection = reflection
def union_reflections = reflection.union_reflections

def union_preloaders
@union_preloaders ||= ActiveRecord::Associations::Preloader.new(scope:, records: owners, associations: union_reflections.collect(&:name))
.loaders
end

def union_records_by_owner
@union_records_by_owner ||= union_preloaders.map(&:records_by_owner).reduce do |left, right|
left.merge(right) do |owner, left_records, right_records|
left_records | right_records # merge record sets
end
end
end

def build_scope
scope = source_reflection.klass.unscoped

if reflection.type && !reflection.through_reflection?
scope.where!(reflection.type => model.polymorphic_name)
end

scope.merge!(reflection_scope) unless reflection_scope.empty_scope?

if preload_scope && !preload_scope.empty_scope?
scope.merge!(preload_scope)
end

cascade_strict_loading(scope)
end
end
end
end
Loading

0 comments on commit a199017

Please sign in to comment.