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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module HasSomeOfMany
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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: []