activerecord-has_some_of_many 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f35b023998c92a01d9614640c84f296ac53c15ba1459daec41da6fc042b7ff8c
|
4
|
+
data.tar.gz: d004c4ce3f54eea253f86a0347d5690693b220e9f364e9c36037df91502c021e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 52201a2fb0479d35037a324178434820f87eb3b527733e9d7ce797153b50bc8160fe01a38c8fbe698f86bb9728054b8a1a73030f500436586236d10c6d3a3841
|
7
|
+
data.tar.gz: b827a9092bd4d422c367b61afca9cd0d3edbe70738c9b7a49db1de6848e3c0c9a55d4c187b817389ea03d332a91f9536b1d1de78b7ba96ec327c86fdd6a891b7
|
data/README.md
CHANGED
@@ -1,13 +1,23 @@
|
|
1
1
|
# `activerecord-has_some_of_many`
|
2
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:
|
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
4
|
|
5
|
-
- Users have many posts, and you want to query the most recent post for each user
|
5
|
+
- Users have many posts, and you want to query the most recent post for each user
|
6
6
|
- Posts have many comments, and you want to query the 5 most recent visible comments for each post
|
7
7
|
- Posts have many comments, and you want to query the one comment with the largest `votes_count` for each post
|
8
8
|
|
9
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
10
|
|
11
|
+
## Compatibility
|
12
|
+
|
13
|
+
This gem is only compatible with databases that offer `LATERAL` joins within Active Record. As far as I'm aware, that is **only Postgres**.
|
14
|
+
|
15
|
+
This gem is not necessary on SQLite, as SQLite will perform lateral-like behavior on join queries by default. MySQL has support for lateral queries, but they are not yet implemented in Active Record.
|
16
|
+
|
17
|
+
**Really complex queries may not work; please open an issue!** This library works by rewriting Active Record queries; it's possible to create some very complex queries with Active Record. Some things that are specifically known to work:
|
18
|
+
- Model associations to the same model. It works.
|
19
|
+
- _Please help expand this list by opening an issue if you find something that doesn't work!_
|
20
|
+
|
11
21
|
## Usage
|
12
22
|
|
13
23
|
Add to your gemfile, and run `bundle install`:
|
@@ -25,7 +35,7 @@ class User < ActiveRecord::Base
|
|
25
35
|
# You can also use `has_some_of_many` to get the top N records. Be sure to add a limit to the scope.
|
26
36
|
has_some_of_many :last_five_posts, -> { order("created_at DESC").limit(5) }, class_name: "Post"
|
27
37
|
|
28
|
-
# More complex
|
38
|
+
# More complex scopes are possible, for example:
|
29
39
|
has_one_of_many :top_comment, -> { where(published: true).order("votes_count DESC") }, class_name: "Comment"
|
30
40
|
has_some_of_many :top_ten_comments, -> { where(published: true).order("votes_count DESC").limit(10) }, class_name: "Comment"
|
31
41
|
end
|
@@ -37,17 +47,17 @@ User.where(active: true).includes(:last_post, :last_five_posts, :top_comment).ea
|
|
37
47
|
user.top_comment
|
38
48
|
end
|
39
49
|
|
40
|
-
#
|
50
|
+
# Add compound indexes to your database to make these queries fast!
|
41
51
|
add_index :comments, [:post_id, :created_at]
|
42
52
|
add_index :comments, [:post_id, :votes_count]
|
43
53
|
```
|
44
54
|
|
45
55
|
## Why?
|
46
56
|
|
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:
|
57
|
+
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
58
|
|
49
59
|
> 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
|
-
|
60
|
+
|
51
61
|
For example, to find only the one most recent comments for a collection of posts, we might write:
|
52
62
|
|
53
63
|
```sql
|
@@ -33,7 +33,8 @@ module ActiveRecord
|
|
33
33
|
.select(klass.arel_table[primary_key].as(foreign_key_alias), lateral_table[Arel.star])
|
34
34
|
.arel.join(
|
35
35
|
relation
|
36
|
-
.
|
36
|
+
.then { |query| relation.table.name == klass.arel_table.name ? ActiveRecord::HasSomeOfMany::RelationRewriter.new(query).alias_table("#{klass.table_name}__alias") : query }
|
37
|
+
.then { |query| query.where(query.table[foreign_key].eq(klass.arel_table[primary_key])) }
|
37
38
|
.then { |query| limit ? query.limit(limit) : query }
|
38
39
|
.arel.lateral(lateral_table.name)
|
39
40
|
).on("TRUE")
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module HasSomeOfMany
|
5
|
+
class RelationRewriter
|
6
|
+
def initialize(relation)
|
7
|
+
@relation = relation
|
8
|
+
end
|
9
|
+
|
10
|
+
def alias_table(alias_name)
|
11
|
+
relation_class = @relation.klass
|
12
|
+
original_table = relation_class.arel_table
|
13
|
+
alias_table = original_table.alias(alias_name)
|
14
|
+
|
15
|
+
where_clause = recursively_alias_table(@relation.where_clause.ast, original_table, alias_table)
|
16
|
+
order_clause = @relation.order_values.map do |order|
|
17
|
+
if order.respond_to?(:expr)
|
18
|
+
order.expr = alias_table[order.expr.name] if order.expr.relation == original_table
|
19
|
+
end
|
20
|
+
order
|
21
|
+
end
|
22
|
+
|
23
|
+
new_relation = @relation.dup.unscope(:select, :where, :order)
|
24
|
+
new_relation.instance_variable_set(:@table, alias_table) # Is there a better way to modify the original relation?
|
25
|
+
new_relation.where(where_clause).order(order_clause)
|
26
|
+
end
|
27
|
+
|
28
|
+
def recursively_alias_table(node, original_table, alias_table)
|
29
|
+
case node
|
30
|
+
when Arel::Nodes::And, Arel::Nodes::Or
|
31
|
+
return nil if node.children.empty?
|
32
|
+
|
33
|
+
# Recurse for left and right nodes
|
34
|
+
node.children.each { |child| recursively_alias_table(child, original_table, alias_table) }
|
35
|
+
node
|
36
|
+
when Arel::Nodes::Grouping
|
37
|
+
# Recurse for the expression inside the grouping
|
38
|
+
node.expr = recursively_alias_table(node.expr, original_table, alias_table)
|
39
|
+
node
|
40
|
+
when Arel::Nodes::Equality, Arel::Nodes::GreaterThan, Arel::Nodes::LessThan, Arel::Nodes::HomogeneousIn
|
41
|
+
# For conditions, modify left-hand side (the column)
|
42
|
+
if node.left.relation == original_table
|
43
|
+
node.left.relation = alias_table
|
44
|
+
end
|
45
|
+
node
|
46
|
+
else
|
47
|
+
node
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -1,9 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'active_support/lazy_load_hooks'
|
4
|
-
require_relative "version"
|
4
|
+
require_relative "activerecord/has_some_of_many/version"
|
5
5
|
|
6
6
|
ActiveSupport.on_load(:active_record) do
|
7
7
|
require_relative "activerecord/has_some_of_many/associations"
|
8
|
+
require_relative "activerecord/has_some_of_many/relation_rewriter"
|
9
|
+
|
8
10
|
include ActiveRecord::HasSomeOfMany::Associations
|
9
11
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: activerecord-has_some_of_many
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Sheldon [he/him]
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-09-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -36,6 +36,7 @@ files:
|
|
36
36
|
- README.md
|
37
37
|
- lib/activerecord-has_some_of_many.rb
|
38
38
|
- lib/activerecord/has_some_of_many/associations.rb
|
39
|
+
- lib/activerecord/has_some_of_many/relation_rewriter.rb
|
39
40
|
- lib/activerecord/has_some_of_many/version.rb
|
40
41
|
homepage: https://github.com/bensheldon/activerecord-has_some_of_many
|
41
42
|
licenses:
|