activerecord-has_some_of_many 1.0.1 → 1.2.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: 7ab0ac9fc08cfe5684f0bc33fb071360ffd130fcb61e6ce582d0372d97875cee
4
- data.tar.gz: 747c855f75f1cac9aa5b0419107c8c4b7650db107587c11f1af7633b319cd747
3
+ metadata.gz: '0290c685fa491a5956467a8fc6109104b1622f27e524efeca9f5d147433214cd'
4
+ data.tar.gz: 8b39ae1096090a9747198730bd2a98fb87e162dd03f0692dad73b6ea374d37cf
5
5
  SHA512:
6
- metadata.gz: ee776f879139db208e90494496ca4f311398220aed654184822b586b327465a295f212c7b965a176da8231b5c2506dc8df1d2be5be7b0a0f83be0bec29ebfe02
7
- data.tar.gz: 66deb994b6d7524e0c77fd508bfc84ed8d8c55ed7af15655d88c4cccc869bc9a08b366034620fde1f8c3c3406184d1a80d955ebaf91e24be1ef3b9dde2c15f17
6
+ metadata.gz: 19388466d6c9e3ca6166cef76bc4e376cf6804be4bbafb6232505077e65b293d42d7fbb8e448c95befdc163197f8eac6b812b8bac4c95b1c3557457b0561af8c
7
+ data.tar.gz: d4752090dd3d5c239550c1ed334583d990512328c2f7cbcf8fc220b845a944779c94e6afb780f5a6c524c976710fa637a6f5a89d75abdfbaa79e5b03afe43007
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 scopees are possible, for example:
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
- # Aad compound indexes to your database to make these queries fast!
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
- .where(relation.arel_table[foreign_key].eq(klass.arel_table[primary_key]))
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module HasSomeOfMany
5
- VERSION = "1.0.1"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -5,5 +5,7 @@ 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,17 +1,31 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-has_some_of_many
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.2.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-07-31 00:00:00.000000000 Z
11
+ date: 2024-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rails
14
+ name: activerecord
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
+ - !ruby/object:Gem::Dependency
28
+ name: railties
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
31
  - - ">="
@@ -36,6 +50,7 @@ files:
36
50
  - README.md
37
51
  - lib/activerecord-has_some_of_many.rb
38
52
  - lib/activerecord/has_some_of_many/associations.rb
53
+ - lib/activerecord/has_some_of_many/relation_rewriter.rb
39
54
  - lib/activerecord/has_some_of_many/version.rb
40
55
  homepage: https://github.com/bensheldon/activerecord-has_some_of_many
41
56
  licenses: