activerecord-has_some_of_many 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +110 -0
- data/lib/activerecord/has_some_of_many/associations.rb +81 -0
- data/lib/activerecord/has_some_of_many/version.rb +7 -0
- data/lib/activerecord-has_some_of_many.rb +9 -0
- metadata +66 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a519b095203661135d0889d1679af1641796fd1ad08f22ef7c98018a907e700b
|
4
|
+
data.tar.gz: 6619a47edd047b8fe5bbc4c8c1ecc7c1c8b0b0d4d97f5230019104e1f359f290
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 166385a50777f3b578a0d14d880ccd878510545942cab07208cc8ca5f7ab88a86e448df460d591d90d2e2afd68f8de8b776b079b965052d12b51526753ae9aa3
|
7
|
+
data.tar.gz: 3d76b0b5882c2baa1c597fc69fdb6a302b56c011433d8e6df370f220d1280d1f42bc18186c8fec388d3f0819d60eeec108793e2118831d6dc6738013fb0e43bf
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright Ben Sheldon [he/him]
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
# `activerecord-has_some_of_many`
|
2
|
+
|
3
|
+
This gem adds new optimized Active Record association methods (`has_one_of_many`, `has_some_of_many`) for "top N" queries to ActiveRecord using `JOIN LATERAL` that are eager-loadable (`includes(:association)`, `preloads(:association)`) to avoid N+1 queries, and is compatible with typical queries and batch methods (`find_each`, `in_batches`, find_in_batches`). For example, you might have these types of queries in your application:
|
4
|
+
|
5
|
+
- Users have many posts, and you want to query the most recent post for each user
|
6
|
+
- Posts have many comments, and you want to query the 5 most recent visible comments for each post
|
7
|
+
- Posts have many comments, and you want to query the one comment with the largest `votes_count` for each post
|
8
|
+
|
9
|
+
You can read more about these types of queries on [Benito Serna's "Fetching the top n per group with a lateral join with rails"](https://bhserna.com/fetching-the-top-n-per-group-with-a-lateral-join-with-rails.html).
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
Add to your gemfile, and run `bundle install`:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem "activerecord-has_some_of_many"
|
17
|
+
```
|
18
|
+
|
19
|
+
Then you can use `has_one_of_many` and `has_some_of_many` in your ActiveRecord models to define these associations.
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class User < ActiveRecord::Base
|
23
|
+
has_one_of_many :last_post, -> { order("created_at DESC") }, class_name: "Post"
|
24
|
+
|
25
|
+
# You can also use `has_some_of_many` to get the top N records. Be sure to add a limit to the scope.
|
26
|
+
has_some_of_many :last_five_posts, -> { order("created_at DESC").limit(5) }, class_name: "Post"
|
27
|
+
|
28
|
+
# More complex scopees are possible, for example:
|
29
|
+
has_one_of_many :top_comment, -> { where(published: true).order("votes_count DESC") }, class_name: "Comment"
|
30
|
+
has_some_of_many :top_ten_comments, -> { where(published: true).order("votes_count DESC").limit(10) }, class_name: "Comment"
|
31
|
+
end
|
32
|
+
|
33
|
+
# And then preload/includes and use them like any other Rails association:
|
34
|
+
User.where(active: true).includes(:last_post, :last_five_posts, :top_comment).each do |user|
|
35
|
+
user.last_post
|
36
|
+
user.last_five_posts
|
37
|
+
user.top_comment
|
38
|
+
end
|
39
|
+
|
40
|
+
# Aad compound indexes to your database to make these queries fast!
|
41
|
+
add_index :comments, [:post_id, :created_at]
|
42
|
+
add_index :comments, [:post_id, :votes_count]
|
43
|
+
```
|
44
|
+
|
45
|
+
## Why?
|
46
|
+
|
47
|
+
Finding the "Top N" is a common problem, that can be easily solved with a `JOIN LATERAL` when writing raw SQL queries. Lateral Joins were introduced in Postgres 9.3:
|
48
|
+
|
49
|
+
> a LATERAL join is like a SQL foreach loop, in which Postgres will iterate over each row in a result set and evaluate a subquery using that row as a parameter. ([source](https://www.heap.io/blog/postgresqls-powerful-new-join-type-lateral))
|
50
|
+
|
51
|
+
For example, to find only the one most recent comments for a collection of posts, we might write:
|
52
|
+
|
53
|
+
```sql
|
54
|
+
SELECT "comments".*
|
55
|
+
FROM "posts"
|
56
|
+
INNER JOIN LATERAL (
|
57
|
+
SELECT "comments".*
|
58
|
+
FROM "comments"
|
59
|
+
WHERE "comments"."post_id" = "posts"."id"
|
60
|
+
ORDER BY "comments"."created_at" DESC
|
61
|
+
LIMIT 1
|
62
|
+
) lateral_table ON TRUE
|
63
|
+
WHERE "posts"."id" IN (1, 2, 3, 4, 5)
|
64
|
+
```
|
65
|
+
|
66
|
+
Active Record associations present a bit of a challenge. This is because the association query has `WHERE` conditions added _after_ the association scope that allows changing the foreign key, but not the column name. This means that some indirection is necessary in order to make the query work and have the conditions applied to the correct column in a way that the query planner can efficiently understand and optimize:
|
67
|
+
|
68
|
+
```SQL
|
69
|
+
SELECT "comments".*
|
70
|
+
FROM (
|
71
|
+
SELECT
|
72
|
+
"posts"."id" AS post_id_alias,
|
73
|
+
"lateral_table".*
|
74
|
+
FROM "posts"
|
75
|
+
INNER JOIN LATERAL (
|
76
|
+
SELECT "comments".*
|
77
|
+
FROM "comments"
|
78
|
+
WHERE "comments"."post_id" = "posts"."id"
|
79
|
+
ORDER BY "comments"."created_at" DESC
|
80
|
+
LIMIT 1
|
81
|
+
) lateral_table ON TRUE
|
82
|
+
) comments
|
83
|
+
WHERE "comments"."post_id_alias" IN (1, 2, 3, 4, 5)
|
84
|
+
```
|
85
|
+
|
86
|
+
The resulting optimized `EXPLAIN ANALYZE` looks like:
|
87
|
+
|
88
|
+
```sql
|
89
|
+
Nested Loop (cost=0.56..41.02 rows=5 width=72) (actual time=0.058..0.082 rows=4 loops=1)
|
90
|
+
-> Index Only Scan using posts_pkey on posts (cost=0.28..17.46 rows=5 width=8) (actual time=0.022..0.027 rows=4 loops=1)
|
91
|
+
Index Cond: (id = ANY ('{1,2,3,4,5}'::bigint[]))
|
92
|
+
Heap Fetches: 0
|
93
|
+
-> Limit (cost=0.29..4.70 rows=1 width=64) (actual time=0.012..0.013 rows=1 loops=4)
|
94
|
+
-> Index Scan Backward using index_comments_on_post_id_and_created_at on comments (cost=0.29..44.46 rows=10 width=64) (actual time=0.012..0.012 rows=1 loops=4)
|
95
|
+
Index Cond: (post_id = posts.id)
|
96
|
+
Planning Time: 0.200 ms
|
97
|
+
Execution Time: 0.106 ms
|
98
|
+
```
|
99
|
+
|
100
|
+
## History
|
101
|
+
|
102
|
+
Back in 2018 I (Ben Sheldon) was working to speed up [Open311 Status](https://status.open311.org), an uptime and performance monitor for government websites; I was using a Window Function at the time to query the most recent status for each monitored website, and it was _slow_ 🐌 I [tweeted about the problem](https://x.com/postgresql/status/1033797250936389633) and the Postgres Twitter account replied and told me about `LATERAL` joins ✨
|
103
|
+
|
104
|
+
![Tweet from Postgres](history.jpg)
|
105
|
+
|
106
|
+
A few years after that, Benito Serna shared on Reddit an excellent series of blog posts about fetching latest-N-of-each records. The first post didn't mention `LATERAL` joins, so [I commented on that](https://www.reddit.com/r/rails/comments/kmofhp/comment/ghnf0hy/) and he updated the posts to include it 🙌 Since then it's been a go-to reference for these types of queries.
|
107
|
+
|
108
|
+
## Development
|
109
|
+
|
110
|
+
- Run the tests with `bundle exec rake`
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module HasSomeOfMany
|
5
|
+
module Associations
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# @param [ActiveRecord::Base] klass The primary class
|
9
|
+
# @param [Symbol] association_type For example, `:has_one` or `:has_many`
|
10
|
+
# @param [Symbol] name The name of the association
|
11
|
+
# @param [Proc, nil] scope A scope to apply to the association
|
12
|
+
# @param [Hash] options Additional options to pass to the association
|
13
|
+
def self.build(klass, association_type, name, scope = nil, **options)
|
14
|
+
primary_key = options[:primary_key] || klass.primary_key
|
15
|
+
foreign_key = options[:foreign_key] || ActiveSupport::Inflector.foreign_key(klass, true)
|
16
|
+
foreign_key_alias = options[:foreign_key_alias] || "#{foreign_key}_alias"
|
17
|
+
|
18
|
+
lateral_subselection_scope = lambda do
|
19
|
+
relation = scope ? instance_exec(&scope) : self
|
20
|
+
limit = association_type == :has_one ? 1 : nil
|
21
|
+
ActiveRecord::HasSomeOfMany::Associations.build_scope(klass, relation, primary_key: primary_key, foreign_key: foreign_key, foreign_key_alias: foreign_key_alias, limit: limit)
|
22
|
+
end
|
23
|
+
|
24
|
+
options[:primary_key] = primary_key
|
25
|
+
options[:foreign_key] = foreign_key_alias
|
26
|
+
|
27
|
+
klass.send(association_type, name, lateral_subselection_scope, **options)
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.build_scope(klass, relation, primary_key:, foreign_key:, foreign_key_alias:, limit: nil)
|
31
|
+
lateral_table = Arel::Table.new('lateral_table')
|
32
|
+
subselect = klass.unscope(:select, :joins, :where, :order, :limit)
|
33
|
+
.select(klass.arel_table[primary_key].as(foreign_key_alias), lateral_table[Arel.star])
|
34
|
+
.arel.join(
|
35
|
+
relation
|
36
|
+
.where(relation.arel_table[foreign_key].eq(klass.arel_table[primary_key]))
|
37
|
+
.then { |query| limit ? query.limit(limit) : query }
|
38
|
+
.arel.lateral(lateral_table.name)
|
39
|
+
).on("TRUE")
|
40
|
+
|
41
|
+
select_values = if relation.klass.ignored_columns.any?
|
42
|
+
[relation.klass.arel_table[foreign_key_alias]] + relation.klass.columns.map { |column| relation.klass.arel_table[column.name] }
|
43
|
+
else
|
44
|
+
[relation.klass.arel_table[Arel.star]]
|
45
|
+
end
|
46
|
+
|
47
|
+
relation.klass.select(select_values).from(subselect.as(relation.arel_table.name))
|
48
|
+
end
|
49
|
+
|
50
|
+
class_methods do
|
51
|
+
# Fetch the first record of a has_one-like association using a lateral join
|
52
|
+
#
|
53
|
+
# @example Given posts have many comments; query the most recent comment on a post
|
54
|
+
# class Post < ApplicationRecord
|
55
|
+
# one_of_many :last_comment, -> { order(created_at: :desc) }, class_name: 'Comment'
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# Post.published.includes(:last_comment).each do |post
|
59
|
+
# posts.last_comment # => #<Comment>
|
60
|
+
# end
|
61
|
+
def has_one_of_many(name, scope = nil, **options)
|
62
|
+
ActiveRecord::HasSomeOfMany::Associations.build(self, :has_one, name, scope, **options)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Fetch a limited number of records of a has_many-like association using a lateral join
|
66
|
+
#
|
67
|
+
# @example The 10 most recent comments on a post
|
68
|
+
# class Post < ApplicationRecord
|
69
|
+
# has_some_of_many :last_ten_comments, -> { order(created_at: :desc).limit(10) }, class_name: 'Comment'
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# Post.published.includes(:last_ten_comments).each do |post
|
73
|
+
# posts.last_ten_comments # => [#<Comment>, #<Comment>, ...]
|
74
|
+
# end
|
75
|
+
def has_some_of_many(name, scope = nil, **options)
|
76
|
+
ActiveRecord::HasSomeOfMany::Associations.build(self, :has_many, name, scope, **options)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/lazy_load_hooks'
|
4
|
+
require_relative "version"
|
5
|
+
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
7
|
+
require_relative "activerecord/has_some_of_many/associations"
|
8
|
+
include ActiveRecord::HasSomeOfMany::Associations
|
9
|
+
end
|
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord-has_some_of_many
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ben Sheldon [he/him]
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-07-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 7.0.0.alpha
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 7.0.0.alpha
|
27
|
+
description: An Active Record extension for creating associations through lateral
|
28
|
+
joins
|
29
|
+
email:
|
30
|
+
- bensheldon@gmail.com
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- LICENSE.txt
|
36
|
+
- README.md
|
37
|
+
- lib/activerecord-has_some_of_many.rb
|
38
|
+
- lib/activerecord/has_some_of_many/associations.rb
|
39
|
+
- lib/activerecord/has_some_of_many/version.rb
|
40
|
+
homepage: https://github.com/bensheldon/activerecord-has_some_of_many
|
41
|
+
licenses:
|
42
|
+
- MIT
|
43
|
+
metadata:
|
44
|
+
homepage_uri: https://github.com/bensheldon/activerecord-has_some_of_many
|
45
|
+
source_code_uri: https://github.com/bensheldon/activerecord-has_some_of_many
|
46
|
+
changelog_uri: https://github.com/bensheldon/activerecord-has_some_of_many
|
47
|
+
post_install_message:
|
48
|
+
rdoc_options: []
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
requirements: []
|
62
|
+
rubygems_version: 3.5.11
|
63
|
+
signing_key:
|
64
|
+
specification_version: 4
|
65
|
+
summary: An Active Record extension for creating associations through lateral joins
|
66
|
+
test_files: []
|