occams-record 0.29.0 → 0.31.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f01a553f2a0d8dcb7e2efaac70088239a05de6633375b8bf3f5c1f77a43808c8
4
- data.tar.gz: 38095dbb827e293088f515e04bfdb907f088e1bddce0cfdc43f38455e8bafab4
3
+ metadata.gz: c2c1004e553179f934744fe17227319b53e6fe663d4b6e5c246017c0a7728808
4
+ data.tar.gz: 502a1fe403a0ccf1980447d7f2b6f960cc3988a72b574df7d30a1d3db3ab69a2
5
5
  SHA512:
6
- metadata.gz: c221f51d83965ef207975806a2d5d7b4c6867ba7e1f2f9f08e2c2b78320de262d7863cfc1c215b37a12d5716b99377b2ee43d5697284e7e483f81f110572581b
7
- data.tar.gz: 37c429c2af89af793ad80bbf87fe4b8083626d540e78cfb4887ffb3eb83c6d4019a4abfc26daf438a58256a36a136d2918d04499eb390cabf62016bf8da97208
6
+ metadata.gz: 49f84fc3ef76a06740b9c9702d49a2370d3ffaac28f5af2ec7747a911fe14031c477a53eae7b8ca50fb084c8b994186068fad1c272ddab32ed34280ab8ee1a04
7
+ data.tar.gz: 4615260b822144d68c8a7c858de3f02820fbaffd5c490ae8cdf33eed96bb89529721cc527c435f90f00618c85e88992b02b46bfce2094d96187ee985f61042d8
data/README.md CHANGED
@@ -2,22 +2,27 @@
2
2
 
3
3
  > Do not multiply entities beyond necessity. -- Occam's Razor
4
4
 
5
- Occam's Record is a high-efficiency, advanced query library for ActiveRecord apps. It is **not** an ORM or an ActiveRecord replacement. Use it to solve pain points in your existing ActiveRecord app.
5
+ Occam's Record is a high-efficiency, advanced query library for ActiveRecord apps. It is **not** an ORM or an ActiveRecord replacement. Use it to solve pain points in your existing ActiveRecord app. Occams Record gives you two things:
6
+
7
+ **Performance**
6
8
 
7
9
  * 3x-5x faster than ActiveRecord queries.
8
10
  * Uses 1/3 the memory of ActiveRecord query results.
9
11
  * Eliminates the N+1 query problem.
10
- * Customize the SQL when eager loading associations.
11
- * `find_each`/`find_in_batches` respects `order` and `limit`.
12
- * Allows eager loading of associations when querying with raw SQL.
13
- * Allows `find_each`/`find_in_batches` when querying with raw SQL.
14
- * Eager load an ad hoc assocation using arbitrary SQL.
12
+
13
+ **More powerful queries & eager loading**
14
+
15
+ * Customize the SQL used to eager load associations.
16
+ * Use `ORDER BY` with `find_each`/`find_in_batches`.
17
+ * Use `find_each`/`find_in_batches` with raw SQL.
18
+ * Eager load associations when you're writing raw SQL.
19
+ * Eager load "ad hoc associations" using raw SQL.
15
20
 
16
21
  [Look over the speed and memory measurements yourself!](https://github.com/jhollinger/occams-record/wiki/Measurements) OccamsRecord achieves all of this by making some very specific trade-offs:
17
22
 
18
- * OccamsRecord results are **read-only**.
19
- * OccamsRecord results are **purely database rows** - they don't have any instance methods from your Rails models.
20
- * You **must eager load** each assocation you intend to use. If you forget one, an exception will be raised.
23
+ * OccamsRecord results are *read-only*.
24
+ * OccamsRecord results are *purely database rows* - they don't have any instance methods from your Rails models.
25
+ * You *must eager load* each assocation you intend to use. If you forget one, an exception will be raised.
21
26
 
22
27
  ---
23
28
 
@@ -48,11 +53,11 @@ orders = OccamsRecord.
48
53
  run
49
54
  ````
50
55
 
51
- `each`, `map`, `reduce`, and other Enumerable methods may be used instead of *run*. `find_each` and `find_in_batches` are also supported. Unlike their ActiveRecord counterparts they respect *ORDER BY*. Occams Record has great support for raw SQL queries too, but we'll get to those later.
56
+ `each`, `map`, `reduce`, and other Enumerable methods may be used instead of *run*. `find_each` and `find_in_batches` are also supported, and unlike their ActiveRecord counterparts they respect *ORDER BY*. Occams Record has great support for raw SQL queries too, but we'll get to those later.
52
57
 
53
58
  ## Basic eager loading
54
59
 
55
- Eager loading is similiar to ActiveRecord's `preload` (each association is loaded in a separate query). Nested associations use blocks instead of Hashes. And if you try to use an association you didn't eager load an exception will be raised.
60
+ Eager loading is similiar to ActiveRecord's `preload` (each association is loaded in a separate query). Nested associations use blocks instead of Hashes. If you try to use an association you didn't eager load an exception will be raised.
56
61
 
57
62
  ```ruby
58
63
  orders = OccamsRecord.
@@ -60,6 +65,7 @@ orders = OccamsRecord.
60
65
  eager_load(:customer).
61
66
  eager_load(:line_items) {
62
67
  eager_load(:product)
68
+ eager_load(:something_else)
63
69
  }.
64
70
  run
65
71
 
@@ -80,33 +86,34 @@ Occams Record allows you to customize each eager load query using the full power
80
86
  ```ruby
81
87
  orders = OccamsRecord.
82
88
  query(q).
83
- # Only SELECT these two columns. Your DBA will thank you, esp. on "wide" tables.
89
+ # Only SELECT the columns you need. Your DBA will thank you.
84
90
  eager_load(:customer, select: "id, name").
85
91
 
86
- # A Proc can customize the query using any of ActiveRecord's query builders and
87
- # any scopes you've defined on the LineItem model.
92
+ # A Proc can customize the query using any of ActiveRecord's query
93
+ # builders and any scopes you've defined on the LineItem model.
88
94
  eager_load(:line_items, ->(q) { q.active.order("created_at") }) {
89
95
  eager_load(:product)
96
+ eager_load(:something_else)
90
97
  }.
91
98
  run
92
99
  ```
93
100
 
94
- Occams Record also supports creating ad hoc associations using raw SQL. We'll get to that in the next section.
101
+ Occams Record also supports loading ad hoc associations using raw SQL. We'll get to that in the next section.
95
102
 
96
103
  ## Raw SQL queries
97
104
 
98
- ActiveRecord has raw SQL "escape hatches" like `find_by_sql` or `exec_query`, but they both give up critical features like eager loading and `find_each`/`find_in_batches`. Not so with Occams Record!
105
+ ActiveRecord has raw SQL escape hatches like `find_by_sql` and `exec_query`, but they give up critical features like eager loading and `find_each`/`find_in_batches`. Occams Record's escape hatches don't make you give up anything.
99
106
 
100
107
  **Batched loading**
101
108
 
102
- To use `find_each`/`find_in_batches` you must provide the limit and offset statements yourself. OccamsRecord will fill in the values for you. Also, notice that the binding syntax is a bit different (it uses Ruby's built-in named string substitution).
109
+ To use `find_each`/`find_in_batches` you must provide the limit and offset statements yourself; Occams will provide the values. Also, notice that the binding syntax is a bit different (it uses Ruby's built-in named string substitution).
103
110
 
104
111
  ```ruby
105
112
  OccamsRecord.
106
113
  sql(%(
107
114
  SELECT * FROM orders
108
115
  WHERE order_date > %{date}
109
- ORDER BY order_date DESC
116
+ ORDER BY order_date DESC, id
110
117
  LIMIT %{batch_limit}
111
118
  OFFSET %{batch_offset}
112
119
  ), {
@@ -119,14 +126,14 @@ OccamsRecord.
119
126
 
120
127
  **Eager loading**
121
128
 
122
- To use `eager_load` with a raw SQL query you must tell Occams what the base model is. (That doesn't apply if you're loading an ad hoc, raw SQL association. We'll get to those later).
129
+ To use `eager_load` with a raw SQL query you must tell Occams what the base model is. (That doesn't apply if you're loading an ad hoc, raw SQL association. We'll get to those later.)
123
130
 
124
131
  ```ruby
125
132
  orders = OccamsRecord.
126
133
  sql(%(
127
134
  SELECT * FROM orders
128
135
  WHERE order_date > %{date}
129
- ORDER BY order_date DESC
136
+ ORDER BY order_date DESC, id
130
137
  ), {
131
138
  date: 30.days.ago
132
139
  }).
@@ -153,7 +160,7 @@ products_with_orders = OccamsRecord.
153
160
  }
154
161
  ```
155
162
 
156
- But that's very wasteful. Occams gives us a better options: `eager_load_many` and `eager_load_one`.
163
+ But that's very wasteful. Occams gives us better options: `eager_load_many` and `eager_load_one`.
157
164
 
158
165
  ```ruby
159
166
  products = OccamsRecord.
@@ -174,7 +181,7 @@ products = OccamsRecord.
174
181
 
175
182
  `eager_load_one` defines an ad hoc `has_one`/`belongs_to` association.
176
183
 
177
- These ad hoc eager loaders are available on both `OccamsRecord.query` and `OccamsRecord.sql`. While eager loading with `OccamsRecord.sql` normallly requires you to declare the model, that is not necessary with `eager_load_one`/`eager_load_many`.
184
+ These ad hoc eager loaders are available on both `OccamsRecord.query` and `OccamsRecord.sql`. While eager loading with `OccamsRecord.sql` normallly requires you to declare the model, that isn't necessary when using these methods.
178
185
 
179
186
  ## Injecting instance methods
180
187
 
@@ -207,10 +214,11 @@ orders = OccamsRecord.
207
214
 
208
215
  The following ActiveRecord features are under consideration, but not high priority. Pull requests welcome!
209
216
 
210
- * `:through` associations.
217
+ * Eager loading `through` associations that involve a `has_and_belongs_to_many`.
211
218
 
212
219
  The following ActiveRecord features are not supported, and likely never will be. Pull requests are still welcome, though.
213
220
 
221
+ * Eager loading `through` associations that involve a polymorphic association.
214
222
  * ActiveRecord enum types
215
223
  * ActiveRecord serialized types
216
224
 
@@ -3,126 +3,34 @@ module OccamsRecord
3
3
  # Contains eager loaders for various kinds of associations.
4
4
  #
5
5
  module EagerLoaders
6
+ autoload :Builder, 'occams-record/eager_loaders/builder'
7
+ autoload :Context, 'occams-record/eager_loaders/context'
8
+
6
9
  autoload :Base, 'occams-record/eager_loaders/base'
7
10
  autoload :BelongsTo, 'occams-record/eager_loaders/belongs_to'
8
11
  autoload :PolymorphicBelongsTo, 'occams-record/eager_loaders/polymorphic_belongs_to'
9
12
  autoload :HasOne, 'occams-record/eager_loaders/has_one'
10
13
  autoload :HasMany, 'occams-record/eager_loaders/has_many'
11
14
  autoload :Habtm, 'occams-record/eager_loaders/habtm'
15
+ autoload :Through, 'occams-record/eager_loaders/through'
12
16
 
13
17
  autoload :AdHocBase, 'occams-record/eager_loaders/ad_hoc_base'
14
18
  autoload :AdHocOne, 'occams-record/eager_loaders/ad_hoc_one'
15
19
  autoload :AdHocMany, 'occams-record/eager_loaders/ad_hoc_many'
16
20
 
17
- # Methods for adding eager loading to a query.
18
- module Builder
19
- #
20
- # Specify an association to be eager-loaded. For maximum memory savings, only SELECT the
21
- # colums you actually need.
22
- #
23
- # @param assoc [Symbol] name of association
24
- # @param scope [Proc] a scope to apply to the query (optional). It will be passed an
25
- # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
26
- # @param select [String] a custom SELECT statement, minus the SELECT (optional)
27
- # @param use [Array<Module>] optional Module to include in the result class (single or array)
28
- # @param as [Symbol] Load the association usign a different attribute name
29
- # @yield a block where you may perform eager loading on *this* association (optional)
30
- # @return [OccamsRecord::Query] returns self
31
- #
32
- def eager_load(assoc, scope = nil, select: nil, use: nil, as: nil, &eval_block)
33
- ref = @model ? @model.reflections[assoc.to_s] : nil
34
- ref ||= @model.subclasses.map(&:reflections).detect { |x| x.has_key? assoc.to_s }&.[](assoc.to_s) if @model
35
- raise "OccamsRecord: No assocation `:#{assoc}` on `#{@model&.name || '<model missing>'}` or subclasses" if ref.nil?
36
- scope ||= ->(q) { q.select select } if select
37
- @eager_loaders << eager_loader_for_association(ref).new(ref, scope, use: use, as: as, &eval_block)
38
- self
39
- end
40
-
41
- #
42
- # Specify some arbitrary SQL to be loaded into some arbitrary attribute ("name"). The attribute will
43
- # hold either one record or none.
44
- #
45
- # In the example below, :category is NOT an association on Widget. Though if it where it would be a belongs_to. The
46
- # mapping argument says "The id column in this table (categories) maps to the category_id column in the other table (widgets)".
47
- # The %{ids} bind param will be provided for you, and in this case will be all the category_id values from the main
48
- # query.
49
- #
50
- # res = OccamsRecord.
51
- # query(Widget.order("name")).
52
- # eager_load_one(:category, {:id => :category_id}, %(
53
- # SELECT * FROM categories WHERE id IN (%{ids}) AND name != %{bad_name}
54
- # ), binds: {
55
- # bad_name: "Bad Category"
56
- # }).
57
- # run
58
- #
59
- # @param name [Symbol] name of attribute to load records into
60
- # @param mapping [Hash] a one element Hash with the key being the local/child id and the value being the foreign/parent id
61
- # @param sql [String] the SQL to query the associated records. Include a bind params called '%{ids}' for the foreign/parent ids.
62
- # @param binds [Hash] any additional binds for your query.
63
- # @param model [ActiveRecord::Base] optional - ActiveRecord model that represents what you're loading. required when using Sqlite.
64
- # @param use [Array<Module>] optional - Ruby modules to include in the result objects (single or array)
65
- # @yield eager load associations nested under this one
66
- #
67
- def eager_load_one(name, mapping, sql, binds: {}, model: nil, use: nil, &eval_block)
68
- @eager_loaders << EagerLoaders::AdHocOne.new(name, mapping, sql, binds: binds, model: model, use: use, &eval_block)
69
- self
70
- end
71
-
72
- #
73
- # Specify some arbitrary SQL to be loaded into some arbitrary attribute ("name"). The attribute will
74
- # hold an array of 0 or more associated records.
75
- #
76
- # In the example below, :parts is NOT an association on Widget. Though if it where it would be a has_many. The
77
- # mapping argument says "The widget_id column in this table (parts) maps to the id column in the other table (widgets)".
78
- # The %{ids} bind param will be provided for you, and in this case will be all the id values from the main
79
- # query.
80
- #
81
- # res = OccamsRecord.
82
- # query(Widget.order("name")).
83
- # eager_load_many(:parts, {:widget_id => :id}, %(
84
- # SELECT * FROM parts WHERE widget_id IN (%{ids}) AND sku NOT IN (%{bad_skus})
85
- # ), binds: {
86
- # bad_skus: ["G90023ASDf0"]
87
- # }).
88
- # run
89
- #
90
- # @param name [Symbol] name of attribute to load records into
91
- # @param mapping [Hash] a one element Hash with the key being the local/child id and the value being the foreign/parent id
92
- # @param sql [String] the SQL to query the associated records. Include a bind params called '%{ids}' for the foreign/parent ids.
93
- # @param use [Array<Module>] optional - Ruby modules to include in the result objects (single or array)
94
- # @param binds [Hash] any additional binds for your query.
95
- # @param model [ActiveRecord::Base] optional - ActiveRecord model that represents what you're loading. required when using Sqlite.
96
- # @yield eager load associations nested under this one
97
- #
98
- def eager_load_many(name, mapping, sql, binds: {}, model: nil, use: nil, &eval_block)
99
- @eager_loaders << EagerLoaders::AdHocMany.new(name, mapping, sql, binds: binds, model: model, use: use, &eval_block)
100
- self
101
- end
102
-
103
- private
104
-
105
- # Run all defined eager loaders into the given result rows
106
- def eager_load!(rows)
107
- @eager_loaders.each { |loader|
108
- loader.run(rows, query_logger: @query_logger)
109
- }
110
- end
111
-
112
- # Fetch the appropriate eager loader for the given association type.
113
- def eager_loader_for_association(ref)
114
- case ref.macro
115
- when :belongs_to
116
- ref.options[:polymorphic] ? PolymorphicBelongsTo : BelongsTo
117
- when :has_one
118
- HasOne
119
- when :has_many
120
- HasMany
121
- when :has_and_belongs_to_many
122
- Habtm
123
- else
124
- raise "Unsupported association type `#{macro}`"
125
- end
21
+ # Fetch the appropriate eager loader for the given association type.
22
+ def self.fetch!(ref)
23
+ case ref.macro
24
+ when :belongs_to
25
+ ref.polymorphic? ? PolymorphicBelongsTo : BelongsTo
26
+ when :has_one
27
+ HasOne
28
+ when :has_many
29
+ HasMany
30
+ when :has_and_belongs_to_many
31
+ Habtm
32
+ else
33
+ raise "Unsupported association type `#{macro}`"
126
34
  end
127
35
  end
128
36
  end
@@ -4,6 +4,8 @@ module OccamsRecord
4
4
  # Base class for eager loading ad hoc associations.
5
5
  #
6
6
  class AdHocBase
7
+ include EagerLoaders::Builder
8
+
7
9
  # @return [String] association name
8
10
  attr_reader :name
9
11
 
@@ -18,12 +20,14 @@ module OccamsRecord
18
20
  # @param use [Array<Module>] optional - Ruby modules to include in the result objects (single or array)
19
21
  # @yield eager load associations nested under this one
20
22
  #
21
- def initialize(name, mapping, sql, binds: {}, model: nil, use: nil, &eval_block)
23
+ def initialize(name, mapping, sql, binds: {}, model: nil, use: nil, &builder)
22
24
  @name = name.to_s
23
- @sql, @binds, @use, @model, @eval_block = sql, binds, use, model, eval_block
25
+ @sql, @binds, @use, @model = sql, binds, use, model
24
26
  raise ArgumentError, "Add-hoc eager loading mapping must contain exactly one key-value pair" unless mapping.size == 1
25
27
  @local_key = mapping.keys.first
26
28
  @foreign_key = mapping.fetch(@local_key)
29
+ @eager_loaders = EagerLoaders::Context.new(@model)
30
+ instance_eval(&builder) if builder
27
31
  end
28
32
 
29
33
  #
@@ -36,7 +40,7 @@ module OccamsRecord
36
40
  calc_ids(rows) { |ids|
37
41
  assoc = if ids.any?
38
42
  binds = @binds.merge({:ids => ids})
39
- RawQuery.new(@sql, binds, use: @use, query_logger: query_logger, &@eval_block).model(@model).run
43
+ RawQuery.new(@sql, binds, use: @use, eager_loaders: @eager_loaders, query_logger: query_logger).run
40
44
  else
41
45
  []
42
46
  end
@@ -4,6 +4,8 @@ module OccamsRecord
4
4
  # Base class for eagoer loading an association. IMPORTANT eager loaders MUST remain stateless after initialization!
5
5
  #
6
6
  class Base
7
+ include EagerLoaders::Builder
8
+
7
9
  # @return [String] association name
8
10
  attr_reader :name
9
11
 
@@ -13,12 +15,16 @@ module OccamsRecord
13
15
  # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
14
16
  # @param use [Array(Module)] optional Module to include in the result class (single or array)
15
17
  # @param as [Symbol] Load the association usign a different attribute name
18
+ # @param optimizer [Symbol] Only used for `through` associations. Options are :none (load all intermediate records) | :select (load all intermediate records but only SELECT the necessary columns)
16
19
  # @yield perform eager loading on *this* association (optional)
17
20
  #
18
- def initialize(ref, scope = nil, use: nil, as: nil, &eval_block)
19
- @ref, @scope, @use, @as, @eval_block = ref, scope, use, as, eval_block
21
+ def initialize(ref, scope = nil, use: nil, as: nil, optimizer: :select, &builder)
22
+ @ref, @scope, @use, @as = ref, scope, use, as
20
23
  @model = ref.klass
21
24
  @name = (as || ref.name).to_s
25
+ @eager_loaders = EagerLoaders::Context.new(@model)
26
+ @optimizer = optimizer
27
+ instance_eval(&builder) if builder
22
28
  end
23
29
 
24
30
  #
@@ -29,7 +35,7 @@ module OccamsRecord
29
35
  #
30
36
  def run(rows, query_logger: nil)
31
37
  query(rows) { |*args|
32
- assoc_rows = args[0] ? Query.new(args[0], use: @use, query_logger: query_logger, &@eval_block).run : []
38
+ assoc_rows = args[0] ? Query.new(args[0], use: @use, eager_loaders: @eager_loaders, query_logger: query_logger).run : []
33
39
  merge! assoc_rows, rows, *args[1..-1]
34
40
  }
35
41
  end
@@ -0,0 +1,112 @@
1
+ module OccamsRecord
2
+ module EagerLoaders
3
+ #
4
+ # Methods for adding eager loading to a query.
5
+ #
6
+ # Users MUST have an OccamsRecord::EagerLoaders::Context at @eager_loaders.
7
+ #
8
+ module Builder
9
+ #
10
+ # Specify an association to be eager-loaded. For maximum memory savings, only SELECT the
11
+ # colums you actually need.
12
+ #
13
+ # @param assoc [Symbol] name of association
14
+ # @param scope [Proc] a scope to apply to the query (optional). It will be passed an
15
+ # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
16
+ # @param select [String] a custom SELECT statement, minus the SELECT (optional)
17
+ # @param use [Array<Module>] optional Module to include in the result class (single or array)
18
+ # @param as [Symbol] Load the association usign a different attribute name
19
+ # @param optimizer [Symbol] Only used for `through` associations. Options are :none (load all intermediate records) | :select (load all intermediate records but only SELECT the necessary columns)
20
+ # @yield a block where you may perform eager loading on *this* association (optional)
21
+ # @return [OccamsRecord::Query] returns self
22
+ #
23
+ def eager_load(assoc, scope = nil, select: nil, use: nil, as: nil, optimizer: :select, &builder)
24
+ @eager_loaders.add(assoc, scope, select: select, use: use, as: as, optimizer: optimizer, &builder)
25
+ self
26
+ end
27
+
28
+ #
29
+ # Same as eager_load, except it returns the new eager loader object instead of self. You can use the
30
+ # new object to call "nest" again, programtically building up nested eager loads instead of passing
31
+ # nested blocks.
32
+ #
33
+ # @param assoc [Symbol] name of association
34
+ # @param scope [Proc] a scope to apply to the query (optional). It will be passed an
35
+ # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
36
+ # @param select [String] a custom SELECT statement, minus the SELECT (optional)
37
+ # @param use [Array<Module>] optional Module to include in the result class (single or array)
38
+ # @param as [Symbol] Load the association usign a different attribute name
39
+ # @param optimizer [Symbol] Only used for `through` associations. Options are :none (load all intermediate records) | :select (load all intermediate records but only SELECT the necessary columns)
40
+ # @return [OccamsRecord::EagerLoaders::Base] returns self
41
+ #
42
+ #
43
+ def nest(assoc, scope = nil, select: nil, use: nil, as: nil, optimizer: :select)
44
+ raise ArgumentError, "OccamsRecord::EagerLoaders::Builder#nest does not accept a block!" if block_given?
45
+ @eager_loaders.add(assoc, scope, select: select, use: use, as: as, optimizer: optimizer) ||
46
+ raise("OccamsRecord::EagerLoaders::Builder#nest may not be called under a polymorphic association")
47
+ end
48
+
49
+ #
50
+ # Specify some arbitrary SQL to be loaded into some arbitrary attribute ("name"). The attribute will
51
+ # hold either one record or none.
52
+ #
53
+ # In the example below, :category is NOT an association on Widget. Though if it where it would be a belongs_to. The
54
+ # mapping argument says "The id column in this table (categories) maps to the category_id column in the other table (widgets)".
55
+ # The %{ids} bind param will be provided for you, and in this case will be all the category_id values from the main
56
+ # query.
57
+ #
58
+ # res = OccamsRecord.
59
+ # query(Widget.order("name")).
60
+ # eager_load_one(:category, {:id => :category_id}, %(
61
+ # SELECT * FROM categories WHERE id IN (%{ids}) AND name != %{bad_name}
62
+ # ), binds: {
63
+ # bad_name: "Bad Category"
64
+ # }).
65
+ # run
66
+ #
67
+ # @param name [Symbol] name of attribute to load records into
68
+ # @param mapping [Hash] a one element Hash with the key being the local/child id and the value being the foreign/parent id
69
+ # @param sql [String] the SQL to query the associated records. Include a bind params called '%{ids}' for the foreign/parent ids.
70
+ # @param binds [Hash] any additional binds for your query.
71
+ # @param model [ActiveRecord::Base] optional - ActiveRecord model that represents what you're loading. required when using Sqlite.
72
+ # @param use [Array<Module>] optional - Ruby modules to include in the result objects (single or array)
73
+ # @yield eager load associations nested under this one
74
+ #
75
+ def eager_load_one(name, mapping, sql, binds: {}, model: nil, use: nil, &builder)
76
+ @eager_loaders << EagerLoaders::AdHocOne.new(name, mapping, sql, binds: binds, model: model, use: use, &builder)
77
+ self
78
+ end
79
+
80
+ #
81
+ # Specify some arbitrary SQL to be loaded into some arbitrary attribute ("name"). The attribute will
82
+ # hold an array of 0 or more associated records.
83
+ #
84
+ # In the example below, :parts is NOT an association on Widget. Though if it where it would be a has_many. The
85
+ # mapping argument says "The widget_id column in this table (parts) maps to the id column in the other table (widgets)".
86
+ # The %{ids} bind param will be provided for you, and in this case will be all the id values from the main
87
+ # query.
88
+ #
89
+ # res = OccamsRecord.
90
+ # query(Widget.order("name")).
91
+ # eager_load_many(:parts, {:widget_id => :id}, %(
92
+ # SELECT * FROM parts WHERE widget_id IN (%{ids}) AND sku NOT IN (%{bad_skus})
93
+ # ), binds: {
94
+ # bad_skus: ["G90023ASDf0"]
95
+ # }).
96
+ # run
97
+ #
98
+ # @param name [Symbol] name of attribute to load records into
99
+ # @param mapping [Hash] a one element Hash with the key being the local/child id and the value being the foreign/parent id
100
+ # @param sql [String] the SQL to query the associated records. Include a bind params called '%{ids}' for the foreign/parent ids.
101
+ # @param use [Array<Module>] optional - Ruby modules to include in the result objects (single or array)
102
+ # @param binds [Hash] any additional binds for your query.
103
+ # @param model [ActiveRecord::Base] optional - ActiveRecord model that represents what you're loading. required when using Sqlite.
104
+ # @yield eager load associations nested under this one
105
+ #
106
+ def eager_load_many(name, mapping, sql, binds: {}, model: nil, use: nil, &builder)
107
+ @eager_loaders << EagerLoaders::AdHocMany.new(name, mapping, sql, binds: binds, model: model, use: use, &builder)
108
+ self
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,113 @@
1
+ module OccamsRecord
2
+ module EagerLoaders
3
+ #
4
+ # A container for all eager loading on a particular Active Record model. Usually the context is initialized
5
+ # with the model, and all eager loaders are immediately initialized. Any errors (like a wrong association name
6
+ # ) will be thrown immediately and before any queries are run.
7
+ #
8
+ # However, in certain situations the model cannot be known until runtime (e.g. eager loading off of a
9
+ # polymorphic association). In these cases the model won't be set, or the eager loaders fully initialized,
10
+ # until the parent queries have run. This means that certain errors (like a wrong association name) won't be
11
+ # noticed until very late, after queries have started running.
12
+ #
13
+ class Context
14
+ # @return [ActiveRecord::Base]
15
+ attr_reader :model
16
+
17
+ #
18
+ # Initialize a new eager loading context.
19
+ #
20
+ # @param mode [ActiveRecord::Base] the model that contains the associations that will be referenced.
21
+ #
22
+ def initialize(model = nil)
23
+ @model = model
24
+ @loaders = []
25
+ @dynamic_loaders = []
26
+ end
27
+
28
+ #
29
+ # Set the model.
30
+ #
31
+ # @param model [ActiveRecord::Base]
32
+ #
33
+ def model=(model)
34
+ @model = model
35
+ @loaders = @loaders + @dynamic_loaders.map { |args|
36
+ build_loader(*args)
37
+ }
38
+ @dynamic_loaders = []
39
+ end
40
+
41
+ #
42
+ # Return the names of the associations being loaded.
43
+ #
44
+ # @return [Array<String>]
45
+ #
46
+ def names
47
+ @loaders.map(&:name) +
48
+ @loaders.select { |l| l.respond_to? :through_name }.map(&:through_name) # TODO make not hacky
49
+ end
50
+
51
+ #
52
+ # Append an already-initialized eager loader.
53
+ #
54
+ # @param loader [OccamsRecord::EagerLoaders::Base]
55
+ # @return [OccamsRecord::EagerLoaders::Base] the added loader
56
+ #
57
+ def <<(loader)
58
+ @loaders << loader
59
+ loader
60
+ end
61
+
62
+ #
63
+ # Specify an association to be eager-loaded. For maximum memory savings, only SELECT the
64
+ # colums you actually need.
65
+ #
66
+ # @param assoc [Symbol] name of association
67
+ # @param scope [Proc] a scope to apply to the query (optional). It will be passed an
68
+ # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
69
+ # @param select [String] a custom SELECT statement, minus the SELECT (optional)
70
+ # @param use [Array<Module>] optional Module to include in the result class (single or array)
71
+ # @param as [Symbol] Load the association usign a different attribute name
72
+ # @param optimizer [Symbol] Only used for `through` associations. Options are :none (load all intermediate records) | :select (load all intermediate records but only SELECT the necessary columns)
73
+ # @yield a block where you may perform eager loading on *this* association (optional)
74
+ # @return [OccamsRecord::EagerLoaders::Base] the new loader. if @model is nil, nil will be returned.
75
+ #
76
+ def add(assoc, scope = nil, select: nil, use: nil, as: nil, optimizer: :select, &builder)
77
+ if @model
78
+ loader = build_loader(assoc, scope, select, use, as, optimizer, builder)
79
+ @loaders << loader
80
+ loader
81
+ else
82
+ @dynamic_loaders << [assoc, scope, select, use, as, optimizer, builder]
83
+ nil
84
+ end
85
+ end
86
+
87
+ #
88
+ # Performs all eager loading in this context (and in any nested ones).
89
+ #
90
+ # @param rows [Array<ActiveRecord::Base>] the parent rows to load child rows into
91
+ # @param query_logger [Array] optional query logger
92
+ #
93
+ def run!(rows, query_logger: nil)
94
+ raise "Cannot run eager loaders when @model has not been set!" if @dynamic_loaders.any? and @model.nil?
95
+ @loaders.each { |loader|
96
+ loader.run(rows, query_logger: query_logger)
97
+ }
98
+ nil
99
+ end
100
+
101
+ private
102
+
103
+ def build_loader(assoc, scope, select, use, as, optimizer, builder)
104
+ ref = @model.reflections[assoc.to_s]
105
+ ref ||= @model.subclasses.map(&:reflections).detect { |x| x.has_key? assoc.to_s }&.[](assoc.to_s)
106
+ raise "OccamsRecord: No assocation `:#{assoc}` on `#{@model.name}` or subclasses" if ref.nil?
107
+ scope ||= ->(q) { q.select select } if select
108
+ loader_class = !!ref.through_reflection ? EagerLoaders::Through : EagerLoaders.fetch!(ref)
109
+ loader_class.new(ref, scope, use: use, as: as, optimizer: optimizer, &builder)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -2,6 +2,8 @@ module OccamsRecord
2
2
  module EagerLoaders
3
3
  # Eager loader for polymorphic belongs tos
4
4
  class PolymorphicBelongsTo
5
+ include EagerLoaders::Builder
6
+
5
7
  # @return [String] association name
6
8
  attr_reader :name
7
9
 
@@ -11,13 +13,16 @@ module OccamsRecord
11
13
  # ActiveRecord::Relation on which you may call all the normal query hethods (select, where, etc) as well as any scopes you've defined on the model.
12
14
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
13
15
  # @param as [Symbol] Load the association usign a different attribute name
16
+ # @param optimizer [Symbol] Only used for `through` associations. A no op here.
14
17
  # @yield perform eager loading on *this* association (optional)
15
18
  #
16
- def initialize(ref, scope = nil, use: nil, as: nil, &eval_block)
17
- @ref, @scope, @use, @eval_block = ref, scope, use, eval_block
19
+ def initialize(ref, scope = nil, use: nil, as: nil, optimizer: nil, &builder)
20
+ @ref, @scope, @use = ref, scope, use
18
21
  @name = (as || ref.name).to_s
19
22
  @foreign_type = @ref.foreign_type.to_sym
20
23
  @foreign_key = @ref.foreign_key.to_sym
24
+ @eager_loaders = EagerLoaders::Context.new
25
+ instance_eval(&builder) if builder
21
26
  end
22
27
 
23
28
  #
@@ -28,7 +33,9 @@ module OccamsRecord
28
33
  #
29
34
  def run(rows, query_logger: nil)
30
35
  query(rows) { |scope|
31
- assoc_rows = Query.new(scope, use: @use, query_logger: query_logger, &@eval_block).run
36
+ eager_loaders = @eager_loaders.dup
37
+ eager_loaders.model = scope.klass
38
+ assoc_rows = Query.new(scope, use: @use, eager_loaders: eager_loaders, query_logger: query_logger).run
32
39
  merge! assoc_rows, rows
33
40
  }
34
41
  end
@@ -0,0 +1,107 @@
1
+ module OccamsRecord
2
+ module EagerLoaders
3
+ #
4
+ # Handles :through associations for has_many and has_one. Polymorphic associations are not supported.
5
+ #
6
+ class Through < Base
7
+ Link = Struct.new(:name, :macro, :ref, :next_ref)
8
+
9
+ #
10
+ # See documentation for OccamsRecord::EagerLoaders::Base.
11
+ #
12
+ def initialize(*args)
13
+ super
14
+
15
+ unless @ref.macro == :has_one or @ref.macro == :has_many
16
+ raise ArgumentError, "#{@ref.active_record.name}##{@ref.name} cannot be eager loaded because only `has_one` and `has_many` are supported for `through` associations"
17
+ end
18
+ if (polys = @ref.chain.select(&:polymorphic?)).any?
19
+ names = polys.map { |r| "#{r.active_record.name}##{r.name}" }
20
+ raise ArgumentError, "#{@ref.active_record.name}##{@ref.name} cannot be eager loaded because these `through` associations are polymorphic: #{names.join ', '}"
21
+ end
22
+ unless @optimizer == :none or @optimizer == :select
23
+ raise ArgumentError, "Unrecognized optimizer '#{@optimizer}'"
24
+ end
25
+
26
+ chain = @ref.chain.reverse
27
+ @chain = chain.each_with_index.map { |x, i|
28
+ Link.new(x.source_reflection.name, x.source_reflection.macro, x, chain[i + 1])
29
+ }
30
+ @loader = build_loader
31
+ end
32
+
33
+ # TODO make not hacky
34
+ def through_name
35
+ @loader.name
36
+ end
37
+
38
+ def run(rows, query_logger: nil)
39
+ results = @loader.run(rows, query_logger: query_logger)
40
+ attr_set = "#{name}="
41
+ results.each do |row|
42
+ row.send(attr_set, reduce(row))
43
+ end
44
+ results
45
+ end
46
+
47
+ private
48
+
49
+ def reduce(node, depth = 0)
50
+ link = @chain[depth]
51
+ case link&.macro
52
+ when nil
53
+ node
54
+ when :has_many
55
+ node.send(link.name).reduce(Set.new) { |a, child|
56
+ result = reduce(child, depth + 1)
57
+ case result
58
+ when Array then a + result
59
+ else a << result
60
+ end
61
+ }.to_a
62
+ when :has_one, :belongs_to
63
+ child = node.send(link.name)
64
+ reduce(child, depth + 1)
65
+ else
66
+ raise "Unsupported through chain link type '#{link.macro}'"
67
+ end
68
+ end
69
+
70
+ def build_loader
71
+ head = @chain[0]
72
+ links = @chain[1..-2]
73
+ tail = @chain[-1]
74
+
75
+ outer_loader = EagerLoaders.fetch!(head.ref).new(head.ref, optimized_select(head))
76
+
77
+ links.
78
+ reduce(outer_loader) { |loader, link|
79
+ loader.nest(link.ref.source_reflection.name, optimized_select(link))
80
+ }.
81
+ nest(tail.ref.source_reflection.name, @scope, use: @use, as: @as)
82
+
83
+ outer_loader
84
+ end
85
+
86
+ def optimized_select(link)
87
+ return nil unless @optimizer == :select
88
+
89
+ cols = case link.macro
90
+ when :belongs_to
91
+ [link.ref.association_primary_key]
92
+ when :has_one, :has_many
93
+ [link.ref.association_primary_key, link.ref.foreign_key]
94
+ else
95
+ raise "Unsupported through chain link type '#{link.macro}'"
96
+ end
97
+
98
+ case link.next_ref.source_reflection.macro
99
+ when :belongs_to
100
+ cols << link.next_ref.foreign_key
101
+ end
102
+
103
+ ->(q) { q.select(cols.join(", ")) }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -46,16 +46,14 @@ module OccamsRecord
46
46
  # @param scope [ActiveRecord::Relation]
47
47
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
48
48
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
49
- # @param eager_loaders [OccamsRecord::EagerLoaders::Base]
50
- # @yield will be eval'd on this instance. Can be used for eager loading. (optional)
49
+ # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
51
50
  #
52
- def initialize(scope, use: nil, query_logger: nil, eager_loaders: [], &eval_block)
51
+ def initialize(scope, use: nil, eager_loaders: nil, query_logger: nil)
53
52
  @model = scope.klass
54
53
  @scope = scope
55
- @eager_loaders = eager_loaders
54
+ @eager_loaders = eager_loaders || EagerLoaders::Context.new(@model)
56
55
  @use = use
57
56
  @query_logger = query_logger
58
- instance_eval(&eval_block) if eval_block
59
57
  end
60
58
 
61
59
  #
@@ -82,9 +80,9 @@ module OccamsRecord
82
80
  sql = block_given? ? yield(scope).to_sql : scope.to_sql
83
81
  @query_logger << sql if @query_logger
84
82
  result = model.connection.exec_query sql
85
- row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.map(&:name), model: model, modules: @use)
83
+ row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: model, modules: @use)
86
84
  rows = result.rows.map { |row| row_class.new row }
87
- eager_load! rows
85
+ @eager_loaders.run!(rows, query_logger: @query_logger)
88
86
  rows
89
87
  end
90
88
 
@@ -60,18 +60,16 @@ module OccamsRecord
60
60
  # @param sql [String] The SELECT statement to run. Binds should use Ruby's named string substitution.
61
61
  # @param binds [Hash] Bind values (Symbol keys)
62
62
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
63
- # @param eager_loaders [OccamsRecord::EagerLoaders::Base]
63
+ # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
64
64
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
65
65
  #
66
- def initialize(sql, binds, use: nil, eager_loaders: [], query_logger: nil, &eval_block)
66
+ def initialize(sql, binds, use: nil, eager_loaders: nil, query_logger: nil)
67
67
  @sql = sql
68
68
  @binds = binds
69
69
  @use = use
70
- @eager_loaders = eager_loaders
70
+ @eager_loaders = eager_loaders || EagerLoaders::Context.new
71
71
  @query_logger = query_logger
72
- @model = nil
73
- @conn = @model&.connection || ActiveRecord::Base.connection
74
- instance_eval(&eval_block) if eval_block
72
+ @conn = @eager_loaders.model&.connection || ActiveRecord::Base.connection
75
73
  end
76
74
 
77
75
  #
@@ -85,7 +83,7 @@ module OccamsRecord
85
83
  # @return [OccamsRecord::RawQuery] self
86
84
  #
87
85
  def model(klass)
88
- @model = klass
86
+ @eager_loaders.model = klass
89
87
  self
90
88
  end
91
89
 
@@ -98,9 +96,9 @@ module OccamsRecord
98
96
  _escaped_sql = escaped_sql
99
97
  @query_logger << _escaped_sql if @query_logger
100
98
  result = @conn.exec_query _escaped_sql
101
- row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.map(&:name), model: @model, modules: @use)
99
+ row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: @eager_loaders.model, modules: @use)
102
100
  rows = result.rows.map { |row| row_class.new row }
103
- eager_load! rows
101
+ @eager_loaders.run!(rows, query_logger: @query_logger)
104
102
  rows
105
103
  end
106
104
 
@@ -154,7 +152,7 @@ module OccamsRecord
154
152
  results = RawQuery.new(@sql, @binds.merge({
155
153
  batch_limit: of,
156
154
  batch_offset: offset,
157
- }), use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).model(@model).run
155
+ }), use: @use, query_logger: @query_logger, eager_loaders: @eager_loaders).run
158
156
 
159
157
  y.yield results if results.any?
160
158
  break if results.size < of
@@ -3,5 +3,5 @@
3
3
  #
4
4
  module OccamsRecord
5
5
  # Library version
6
- VERSION = '0.29.0'.freeze
6
+ VERSION = '0.31.0'.freeze
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: occams-record
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-05-02 00:00:00.000000000 Z
11
+ date: 2018-08-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -46,10 +46,13 @@ files:
46
46
  - lib/occams-record/eager_loaders/ad_hoc_one.rb
47
47
  - lib/occams-record/eager_loaders/base.rb
48
48
  - lib/occams-record/eager_loaders/belongs_to.rb
49
+ - lib/occams-record/eager_loaders/builder.rb
50
+ - lib/occams-record/eager_loaders/context.rb
49
51
  - lib/occams-record/eager_loaders/habtm.rb
50
52
  - lib/occams-record/eager_loaders/has_many.rb
51
53
  - lib/occams-record/eager_loaders/has_one.rb
52
54
  - lib/occams-record/eager_loaders/polymorphic_belongs_to.rb
55
+ - lib/occams-record/eager_loaders/through.rb
53
56
  - lib/occams-record/errors.rb
54
57
  - lib/occams-record/merge.rb
55
58
  - lib/occams-record/query.rb
@@ -76,7 +79,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
79
  version: '0'
77
80
  requirements: []
78
81
  rubyforge_project:
79
- rubygems_version: 2.7.3
82
+ rubygems_version: 2.7.6
80
83
  signing_key:
81
84
  specification_version: 4
82
85
  summary: The missing high-efficiency query API for ActiveRecord