occams-record 1.8.0 → 1.9.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: 846af974c8bc267fe8e901995d0aa3341b018d135eeb267c11d9add447cd0155
4
- data.tar.gz: 763f8a038908cb9a990bcf305644661736dd9133e51a9d8fb8ece2b8ff018fd2
3
+ metadata.gz: e26a6e33886dfdb5cb2d3000e32c9de7e04bb7b26cc4434c5df7f1deb094ac58
4
+ data.tar.gz: 9cc37f3ab8beeef6e55d5adae2e1f76268acad3381b789f70c896a515abe8cfa
5
5
  SHA512:
6
- metadata.gz: 24e1c5dfb09cb35b2b7116cb40aafcb1eb215ac96e3c8bab7ff6558598b31e8abb77b49c2b711392567862b182e1aed705888e939f8b66f15285f2ca352dbc89
7
- data.tar.gz: 04f78d14765b6ae4d9ccee5f01633132ed1e39662071dfc39f6e33583e9cd3e6214841225eb35ce2f678472ce34bf45e27e23660114f28cede6a27cdb38613b0
6
+ metadata.gz: a8236d746e52a2890dfb83e838db153150c249ff0b5fb53a1bc949d386258b5047dc93e63a40d747e0f584ffbcaf3d069122962c23a2fc3691a4e162044927af
7
+ data.tar.gz: 1883cb2f7d7817561837b911828b56b540b5dff7845a943340c489786b73be9b3c24270ec2d32a5e7b9ecdb324a21f346948f183e13439e9339424ee9b593ad9
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Occams Record [![Build Status](https://travis-ci.org/jhollinger/occams-record.svg?branch=master)](https://travis-ci.org/jhollinger/occams-record)
1
+ # Occams Record
2
2
 
3
3
  > Do not multiply entities beyond necessity. -- Occam's Razor
4
4
 
@@ -130,6 +130,18 @@ orders = OccamsRecord
130
130
 
131
131
  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.
132
132
 
133
+ **Query params**
134
+
135
+ ```ruby
136
+ # Supported in all versions of OccamsRecord
137
+ OccamsRecord.sql("SELECT * FROM orders WHERE user_id = %{user_id}", {user_id: user.id}).run
138
+
139
+ # Supported in OccamsRecord 1.9+
140
+ OccamsRecord.sql("SELECT * FROM orders WHERE user_id = :user_id", {user_id: user.id}).run
141
+ OccamsRecord.sql("SELECT * FROM orders WHERE user_id = ?", [user.id]).run
142
+ OccamsRecord.sql("SELECT * FROM orders WHERE user_id = %s", [user.id]).run
143
+ ```
144
+
133
145
  **Batched loading with cursors**
134
146
 
135
147
  `find_each_with_cursor`, `find_in_batches_with_cursor`, and `cursor.open` are all available.
@@ -298,32 +310,50 @@ On the other hand, Active Record makes it *very* easy to forget to eager load as
298
310
 
299
311
  # Testing
300
312
 
301
- ```bash
302
- bundle install
303
-
304
- # test against SQLite
305
- bundle exec rake test
313
+ Tests are run with `appraisal` in Docker Compose using the `bin/test` or `bin/testall` scripts.
306
314
 
307
- # test against Postgres
308
- TEST_DATABASE_URL=postgresql://postgres@localhost:5432/occams_record bundle exec rake test
309
-
310
- # test against MySQL
311
- TEST_DATABASE_URL=mysql2://root:@127.0.0.1:3306/occams_record bundle exec rake test
315
+ ```bash
316
+ # Run tests against all supported ActiveRecord versions, Ruby versions, and databases
317
+ bin/testall
318
+
319
+ # Run tests for Ruby vX only
320
+ bin/testall ruby-3.1
321
+
322
+ # Run tests for ActiveRecord vX only
323
+ bin/testall ar-6.1
324
+
325
+ # Run tests against a specific database
326
+ bin/testall sqlite|postgres-14|mysql-8
327
+
328
+ # Run exactly one set of tests
329
+ bin/test ruby-3.1 ar-7.0 postgres-14
330
+
331
+ # If all tests complete successfully, you'll be rewarded by an ASCII Nyancat!
332
+
333
+ + o + o
334
+ + o + +
335
+ o +
336
+ o + + +
337
+ + o o + o
338
+ -_-_-_-_-_-_-_,------, o
339
+ _-_-_-_-_-_-_-| /\_/\
340
+ -_-_-_-_-_-_-~|__( ^ .^) + +
341
+ _-_-_-_-_-_-_-"" ""
342
+ + o o + o
343
+ + +
344
+ o o o o +
345
+ o +
346
+ + + o o +
312
347
  ```
313
348
 
314
- **Test against all supported ActiveRecord versions**
315
-
316
- ```bash
317
- bundle exec appraisal install
349
+ ## Testing without Docker
318
350
 
319
- # test against all supported AR versions (defaults to SQLite)
320
- bundle exec appraisal rake test
351
+ It's possible to run tests without Docker Compose, but you'll be limited by the Ruby version(s) and database(s) you have on your system.
321
352
 
322
- # test against a specific AR version
353
+ ```bash
354
+ bundle install
355
+ bundle exec appraisal ar-7.0 bundle install
323
356
  bundle exec appraisal ar-7.0 rake test
324
-
325
- # test against Postgres
326
- TEST_DATABASE_URL=postgresql://postgres@localhost:5432/occams_record bundle exec appraisal rake test
327
357
  ```
328
358
 
329
359
  # License
@@ -9,8 +9,12 @@ module OccamsRecord
9
9
  @conn, @sql, @binds = conn, sql, binds
10
10
  @use, @query_logger, @eager_loaders = use, query_logger, eager_loaders
11
11
 
12
+ unless binds.is_a? Hash
13
+ raise ArgumentError, "When using find_each/find_in_batches with raw SQL, binds MUST be a Hash. SQL statement: #{@sql}"
14
+ end
15
+
12
16
  unless @sql =~ /LIMIT\s+%\{batch_limit\}/i and @sql =~ /OFFSET\s+%\{batch_offset\}/i
13
- raise ArgumentError, "When using find_each/find_in_batches you must specify 'LIMIT %{batch_limit} OFFSET %{batch_offset}'. SQL statement: #{@sql}"
17
+ raise ArgumentError, "When using find_each/find_in_batches with raw SQL, you must specify 'LIMIT %{batch_limit} OFFSET %{batch_offset}'. SQL statement: #{@sql}"
14
18
  end
15
19
  end
16
20
 
@@ -0,0 +1,71 @@
1
+ module OccamsRecord
2
+ module BindsConverter
3
+ #
4
+ # A base class for converting a SQL string with Rails-style query params (?, :foo) to native Ruby format (%s, %{foo}).
5
+ #
6
+ # It works kind of like a tokenizer. Subclasses must 1) implement get_bind to return the converted bind
7
+ # from the current position and 2) pass the bind sigil (e.g. ?, :) to the parent constructor.
8
+ #
9
+ class Abstract
10
+ # @private
11
+ ESCAPE = "\\".freeze
12
+
13
+ def initialize(sql, bind_sigil)
14
+ @sql = sql
15
+ @end = sql.size - 1
16
+ @start_i, @i = 0, 0
17
+ @bind_sigil = bind_sigil
18
+ end
19
+
20
+ # @return [String] The converted SQL string
21
+ def to_s
22
+ sql = ""
23
+ each { |frag| sql << frag }
24
+ sql
25
+ end
26
+
27
+ protected
28
+
29
+ # Yields each SQL fragment and converted bind to the given block
30
+ def each
31
+ escape = false
32
+ until @i > @end
33
+ char = @sql[@i]
34
+ unescape = escape
35
+ case char
36
+ when @bind_sigil
37
+ if escape
38
+ @i += 1
39
+ elsif @i > @start_i
40
+ yield flush_sql
41
+ else
42
+ yield get_bind
43
+ end
44
+ when ESCAPE
45
+ if escape
46
+ @i += 1
47
+ elsif @i > @start_i
48
+ yield flush_sql
49
+ escape = true
50
+ @i += 1
51
+ @start_i = @i
52
+ else
53
+ escape = true
54
+ @i += 1
55
+ end
56
+ else
57
+ @i += 1
58
+ end
59
+ escape = false if unescape
60
+ end
61
+ yield flush_sql if @i > @start_i
62
+ end
63
+
64
+ def flush_sql
65
+ t = @sql[@start_i..@i - 1]
66
+ @start_i = @i
67
+ t
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,35 @@
1
+ module OccamsRecord
2
+ module BindsConverter
3
+ # @private
4
+ WORD = /\w/
5
+
6
+ #
7
+ # Converts Rails-style named binds (:foo) into native Ruby format (%{foo}).
8
+ #
9
+ class Named < Abstract
10
+ def initialize(sql)
11
+ super(sql, ":".freeze)
12
+ end
13
+
14
+ protected
15
+
16
+ def get_bind
17
+ old_i = @i
18
+ @i += 1
19
+ @start_i = @i
20
+
21
+ until @i > @end or @sql[@i] !~ WORD
22
+ @i += 1
23
+ end
24
+
25
+ if @i > @start_i
26
+ name = @sql[@start_i..@i - 1]
27
+ @start_i = @i
28
+ "%{#{name}}"
29
+ else
30
+ @sql[old_i]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ module OccamsRecord
2
+ module BindsConverter
3
+ #
4
+ # Converts Rails-style positional binds (?) into native Ruby format (%s).
5
+ #
6
+ class Positional < Abstract
7
+ def initialize(sql)
8
+ super(sql, "?".freeze)
9
+ end
10
+
11
+ protected
12
+
13
+ def get_bind
14
+ @i += 1
15
+ @start_i = @i
16
+ "%s".freeze
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ module OccamsRecord
2
+ #
3
+ # Classes and methods for converting from Rails-style binds (?, :foo) to native Ruby format (%s, %{foo}).
4
+ #
5
+ module BindsConverter
6
+ #
7
+ # Convert any Rails-style binds (?, :foo) to native Ruby format (%s, %{foo}).
8
+ #
9
+ # @param sql [String]
10
+ # @param binds [Hash|Array]
11
+ # @return [String] the converted SQL string
12
+ #
13
+ def self.convert(sql, binds)
14
+ converter =
15
+ case binds
16
+ when Hash then Named.new(sql)
17
+ when Array then Positional.new(sql)
18
+ else raise ArgumentError, "OccamsRecord: Unsupported SQL bind params '#{binds.inspect}'. Only Hash and Array are supported"
19
+ end
20
+ converter.to_s
21
+ end
22
+ end
23
+ end
@@ -203,9 +203,8 @@ module OccamsRecord
203
203
  # end
204
204
  #
205
205
  def execute(sql, binds = {})
206
- conn.execute(sql % binds.reduce({}) { |acc, (key, val)|
206
+ conn.execute(sql % binds.each_with_object({}) { |(key, val), acc|
207
207
  acc[key] = conn.quote(val)
208
- acc
209
208
  })
210
209
  end
211
210
 
@@ -51,12 +51,13 @@ module OccamsRecord
51
51
  #
52
52
  def run(rows, query_logger: nil, measurements: nil)
53
53
  fkey_binds = calc_fkey_binds rows
54
- assoc = if fkey_binds.all? { |_, vals| vals.any? }
55
- binds = @binds.merge(fkey_binds)
56
- RawQuery.new(@sql, binds, use: @use, eager_loaders: @eager_loaders, query_logger: query_logger, measurements: measurements).run
57
- else
58
- []
59
- end
54
+ assoc =
55
+ if fkey_binds.all? { |_, vals| vals.any? }
56
+ binds = @binds.merge(fkey_binds)
57
+ RawQuery.new(@sql, binds, use: @use, eager_loaders: @eager_loaders, query_logger: query_logger, measurements: measurements).run
58
+ else
59
+ []
60
+ end
60
61
  merge! assoc, rows
61
62
  nil
62
63
  end
@@ -69,17 +70,15 @@ module OccamsRecord
69
70
  # @param rows [Array<OccamsRecord::Results::Row>] Array of rows used to calculate the query.
70
71
  #
71
72
  def calc_fkey_binds(rows)
72
- @mapping.keys.reduce({}) { |a, fkey|
73
- a[fkey.to_s.pluralize.to_sym] = rows.reduce(Set.new) { |aa, row|
73
+ @mapping.keys.each_with_object({}) { |fkey, acc|
74
+ acc[fkey.to_s.pluralize.to_sym] = rows.each_with_object(Set.new) { |row, acc2|
74
75
  begin
75
76
  val = row.send fkey
76
- aa << val if val
77
+ acc2 << val if val
77
78
  rescue NoMethodError => e
78
79
  raise MissingColumnError.new(row, e.name)
79
80
  end
80
- aa
81
81
  }.to_a
82
- a
83
82
  }
84
83
  end
85
84
 
@@ -20,7 +20,6 @@ module OccamsRecord
20
20
  raise MissingColumnError.new(row, e.name)
21
21
  end
22
22
  }.compact.uniq
23
- ids.sort! if $occams_record_test
24
23
 
25
24
  q = base_scope.where(@ref.association_primary_key => ids)
26
25
  yield q if ids.any?
@@ -24,23 +24,21 @@ module OccamsRecord
24
24
  # @param join_rows [Array<Array<String>>] raw join'd ids from the db
25
25
  #
26
26
  def merge!(assoc_rows, rows, join_rows)
27
- joins_by_id = join_rows.reduce({}) { |a, join|
27
+ joins_by_id = join_rows.each_with_object({}) { |join, acc|
28
28
  id = join[0].to_s
29
- a[id] ||= []
30
- a[id] << join[1].to_s
31
- a
29
+ acc[id] ||= []
30
+ acc[id] << join[1].to_s
32
31
  }
33
32
 
34
33
  assoc_order_cache = {} # maintains the original order of assoc_rows
35
- assoc_rows_by_id = assoc_rows.each_with_index.reduce({}) { |a, (row, idx)|
34
+ assoc_rows_by_id = assoc_rows.each_with_index.each_with_object({}) { |(row, idx), acc|
36
35
  begin
37
36
  id = row.send(@ref.association_primary_key).to_s
38
37
  rescue NoMethodError => e
39
38
  raise MissingColumnError.new(row, e.name)
40
39
  end
41
40
  assoc_order_cache[id] = idx
42
- a[id] = row
43
- a
41
+ acc[id] = row
44
42
  }
45
43
 
46
44
  assign = "#{name}="
@@ -20,7 +20,6 @@ module OccamsRecord
20
20
  raise MissingColumnError.new(row, e.name)
21
21
  end
22
22
  }.compact.uniq
23
- ids.sort! if $occams_record_test
24
23
 
25
24
  q = base_scope.where(@ref.foreign_key => ids)
26
25
  q.where!(@ref.type => rows[0].class&.model_name) if @ref.options[:as]
@@ -85,7 +85,6 @@ module OccamsRecord
85
85
  next if type.nil? or type == ""
86
86
  model = type.constantize
87
87
  ids = rows_of_type.map(&@foreign_key).uniq
88
- ids.sort! if $occams_record_test
89
88
  q = base_scope(model).where(@ref.active_record_primary_key => ids)
90
89
  yield q if ids.any?
91
90
  end
@@ -20,7 +20,7 @@ module OccamsRecord
20
20
  raise ArgumentError, "#{@ref.active_record.name}##{@ref.name} cannot be eager loaded because these `through` associations are polymorphic: #{names.join ', '}"
21
21
  end
22
22
  unless @optimizer == :none or @optimizer == :select
23
- raise ArgumentError, "Unrecognized optimizer '#{@optimizer}'"
23
+ raise ArgumentError, "Unrecognized optimizer '#{@optimizer}' (valid options are :none, :select)"
24
24
  end
25
25
 
26
26
  chain = @ref.chain.reverse
@@ -31,7 +31,6 @@ module OccamsRecord
31
31
  @loader = build_loader
32
32
  end
33
33
 
34
- # TODO make not hacky
35
34
  def through_name
36
35
  @loader.name
37
36
  end
@@ -47,6 +46,7 @@ module OccamsRecord
47
46
 
48
47
  private
49
48
 
49
+ # starting at the top of the chain, recurse and return the leaf node(s)
50
50
  def reduce(node, depth = 0)
51
51
  link = @chain[depth]
52
52
  case link&.macro
@@ -69,38 +69,48 @@ module OccamsRecord
69
69
  end
70
70
  end
71
71
 
72
+ # build all the nested eager loaders
72
73
  def build_loader
73
74
  head = @chain[0]
74
75
  links = @chain[1..-2]
75
76
  tail = @chain[-1]
76
77
 
77
- outer_loader = EagerLoaders.fetch!(head.ref).new(head.ref, optimized_select(head), parent: tracer.parent)
78
+ outer_loader = EagerLoaders.fetch!(head.ref).new(head.ref, optimized_scope(head), parent: tracer.parent)
78
79
  outer_loader.tracer.through = true
79
80
 
80
81
  inner_loader = links.
81
82
  reduce(outer_loader) { |loader, link|
82
- nested_loader = loader.nest(link.ref.source_reflection.name, optimized_select(link))
83
+ nested_loader = loader.nest(link.ref.source_reflection.name, optimized_scope(link))
83
84
  nested_loader.tracer.through = true
84
85
  nested_loader
85
86
  }.
86
- nest(tail.ref.source_reflection.name, @scope, use: @use, as: @as, active_record_fallback: @active_record_fallback)
87
+ nest(tail.ref.source_reflection.name, @scopes, use: @use, as: @as, active_record_fallback: @active_record_fallback)
87
88
 
88
89
  @eager_loaders.each { |loader| inner_loader.eager_loaders << loader }
89
90
  inner_loader.tracer.name = tracer.name
90
91
  outer_loader
91
92
  end
92
93
 
93
- def optimized_select(link)
94
- return nil unless @optimizer == :select
94
+ def optimized_scope(link)
95
+ case @optimizer
96
+ when :select
97
+ optimized_select(link)
98
+ when :none
99
+ nil
100
+ end
101
+ end
95
102
 
96
- cols = case link.macro
97
- when :belongs_to
98
- [link.ref.association_primary_key]
99
- when :has_one, :has_many
100
- [link.ref.association_primary_key, link.ref.foreign_key]
101
- else
102
- raise "Unsupported through chain link type '#{link.macro}'"
103
- end
103
+ # only select the ids/foreign keys required to link the parent/child records
104
+ def optimized_select(link)
105
+ cols =
106
+ case link.macro
107
+ when :belongs_to
108
+ [link.ref.association_primary_key]
109
+ when :has_one, :has_many
110
+ [link.ref.association_primary_key, link.ref.foreign_key]
111
+ else
112
+ raise "Unsupported through chain link type '#{link.macro}'"
113
+ end
104
114
 
105
115
  case link.next_ref.source_reflection.macro
106
116
  when :belongs_to
@@ -35,14 +35,13 @@ module OccamsRecord
35
35
  # Optimized for merges where there's a single mapping key pair (which is the vast majority)
36
36
  if mapping.size == 1
37
37
  target_attr, assoc_attr = target_attrs[0], assoc_attrs[0]
38
- assoc_rows_by_ids = assoc_rows.reduce({}) { |a, assoc_row|
38
+ assoc_rows_by_ids = assoc_rows.each_with_object({}) { |assoc_row, acc|
39
39
  begin
40
40
  id = assoc_row.send assoc_attr
41
41
  rescue NoMethodError => e
42
42
  raise MissingColumnError.new(assoc_row, e.name)
43
43
  end
44
- a[id] ||= assoc_row
45
- a
44
+ acc[id] ||= assoc_row
46
45
  }
47
46
 
48
47
  target_rows.each do |row|
@@ -56,14 +55,13 @@ module OccamsRecord
56
55
 
57
56
  # Slower but works with any number of mapping key pairs
58
57
  else
59
- assoc_rows_by_ids = assoc_rows.reduce({}) { |a, assoc_row|
58
+ assoc_rows_by_ids = assoc_rows.each_with_object({}) { |assoc_row, acc|
60
59
  begin
61
60
  ids = assoc_attrs.map { |attr| assoc_row.send attr }
62
61
  rescue NoMethodError => e
63
62
  raise MissingColumnError.new(assoc_row, e.name)
64
63
  end
65
- a[ids] ||= assoc_row
66
- a
64
+ acc[ids] ||= assoc_row
67
65
  }
68
66
 
69
67
  target_rows.each do |row|
@@ -2,55 +2,37 @@ module OccamsRecord
2
2
  module Pluck
3
3
  private
4
4
 
5
- def pluck_results(results, cols, model: nil)
6
- if cols.size == 1
7
- pluck_results_single(results, cols[0].to_s, model: model)
5
+ def pluck_results(results, model: nil)
6
+ casters = TypeCaster.generate(results.columns, results.column_types, model: model)
7
+ if results[0]&.size == 1
8
+ pluck_results_single(results, casters)
8
9
  else
9
- pluck_results_multi(results, cols.map(&:to_s), model: model)
10
+ pluck_results_multi(results, casters)
10
11
  end
11
12
  end
12
13
 
13
14
  # returns an array of values
14
- def pluck_results_single(results, col, model: nil)
15
- enum = model&.defined_enums&.[](col)
16
- inv_enum = enum&.invert
17
- if enum
18
- results.map { |r|
19
- val = r[col]
20
- enum.has_key?(val) ? val : inv_enum[val]
15
+ def pluck_results_single(results, casters)
16
+ col = results.columns[0]
17
+ caster = casters[col]
18
+ if caster
19
+ results.map { |row|
20
+ val = row[col]
21
+ caster.(val)
21
22
  }
22
23
  else
23
- # micro-optimization for when there are no enums
24
- results.map { |r| r[col] }
24
+ results.map { |row| row[col] }
25
25
  end
26
26
  end
27
27
 
28
28
  # returns an array of arrays
29
- def pluck_results_multi(results, cols, model: nil)
30
- any_enums = false
31
- cols_with_enums = cols.map { |col|
32
- enum = model&.defined_enums&.[](col)
33
- any_enums ||= !!enum
34
- [col, enum, enum&.invert]
35
- }
36
-
37
- if any_enums
38
- results.map { |row|
39
- cols_with_enums.map { |(col, enum, inv_enum)|
40
- if enum
41
- val = row[col]
42
- enum.has_key?(val) ? val : inv_enum[val]
43
- else
44
- row[col]
45
- end
46
- }
29
+ def pluck_results_multi(results, casters)
30
+ results.map { |row|
31
+ row.map { |col, val|
32
+ caster = casters[col]
33
+ caster ? caster.(val) : val
47
34
  }
48
- else
49
- # micro-optimization for when there are no enums
50
- results.map { |row|
51
- cols.map { |col| row[col] }
52
- }
53
- end
35
+ }
54
36
  end
55
37
  end
56
38
  end
@@ -99,14 +99,15 @@ module OccamsRecord
99
99
  return [] if sql.blank? # return early in case ActiveRecord::QueryMethods#none was used
100
100
 
101
101
  @query_logger << "#{@eager_loaders.tracer}: #{sql}" if @query_logger
102
- result = if measure?
103
- record_start_time!
104
- measure!(model.table_name, sql) {
105
- model.connection.exec_query sql
106
- }
107
- else
108
- model.connection.exec_query sql
109
- end
102
+ result =
103
+ if measure?
104
+ record_start_time!
105
+ measure!(model.table_name, sql) {
106
+ model.connection.exec_query sql
107
+ }
108
+ else
109
+ model.connection.exec_query sql
110
+ end
110
111
  row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: model, modules: @use, tracer: @eager_loaders.tracer, active_record_fallback: @active_record_fallback)
111
112
  rows = result.rows.map { |row| row_class.new row }
112
113
  @eager_loaders.run!(rows, query_logger: @query_logger, measurements: @measurements)
@@ -238,19 +239,20 @@ module OccamsRecord
238
239
  # @return [Array]
239
240
  #
240
241
  def pluck(*cols)
241
- sql = (block_given? ? yield(scope).to_sql : scope).select(*cols).to_sql
242
+ sql = (block_given? ? yield(scope).to_sql : scope).unscope(:select).select(*cols).to_sql
242
243
  return [] if sql.blank? # return early in case ActiveRecord::QueryMethods#none was used
243
244
 
244
245
  @query_logger << "#{@eager_loaders.tracer}: #{sql}" if @query_logger
245
- result = if measure?
246
- record_start_time!
247
- measure!(model.table_name, sql) {
248
- model.connection.exec_query sql
249
- }
250
- else
251
- model.connection.exec_query sql
252
- end
253
- pluck_results result, cols, model: @model
246
+ result =
247
+ if measure?
248
+ record_start_time!
249
+ measure!(model.table_name, sql) {
250
+ model.connection.exec_query sql
251
+ }
252
+ else
253
+ model.connection.exec_query sql
254
+ end
255
+ pluck_results(result, model: @model)
254
256
  end
255
257
  end
256
258
  end
@@ -1,7 +1,7 @@
1
1
  module OccamsRecord
2
2
  #
3
3
  # Starts building a OccamsRecord::RawQuery. Pass it a raw SQL statement, optionally followed by
4
- # a Hash of binds. While this doesn't offer an additional performance boost, it's a nice way to
4
+ # a Hash or Array of binds. While this doesn't offer an additional performance boost, it's a nice way to
5
5
  # write safe, complicated SQL by hand while also supporting eager loading.
6
6
  #
7
7
  # results = OccamsRecord.sql("
@@ -39,10 +39,10 @@ module OccamsRecord
39
39
  # It is possible to coerce the SQLite adapter into returning native types for everything IF they're columns of a table
40
40
  # that you have an AR model for. e.g. if you're selecting from the widgets, table: `OccamsRecord.sql("...").model(Widget)...`.
41
41
  #
42
- # MySQL ?
42
+ # MySQL Mostly native Ruby types, but more testing is needed.
43
43
  #
44
- # @param sql [String] The SELECT statement to run. Binds should use Ruby's named string substitution.
45
- # @param binds [Hash] Bind values (Symbol keys)
44
+ # @param sql [String] The SELECT statement to run. Binds may be Rails-style (?, :foo) or Ruby-style (%s, %{foo}).
45
+ # @param binds [Hash] Bind values as Hash (with Symbol keys) or an Array
46
46
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
47
47
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
48
48
  # @return [OccamsRecord::RawQuery]
@@ -58,7 +58,7 @@ module OccamsRecord
58
58
  class RawQuery
59
59
  # @return [String]
60
60
  attr_reader :sql
61
- # @return [Hash]
61
+ # @return [Hash|Array]
62
62
  attr_reader :binds
63
63
 
64
64
  include OccamsRecord::Batches::CursorHelpers
@@ -70,8 +70,8 @@ module OccamsRecord
70
70
  #
71
71
  # Initialize a new query.
72
72
  #
73
- # @param sql [String] The SELECT statement to run. Binds should use Ruby's named string substitution.
74
- # @param binds [Hash] Bind values (Symbol keys)
73
+ # @param sql [String] The SELECT statement to run. Binds may be Rails-style (?, :foo) or Ruby-style (%s, %{foo}).
74
+ # @param binds [Hash] Bind values as Hash (with Symbol keys) or an Array
75
75
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
76
76
  # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
77
77
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
@@ -79,7 +79,7 @@ module OccamsRecord
79
79
  # @param connection
80
80
  #
81
81
  def initialize(sql, binds, use: nil, eager_loaders: nil, query_logger: nil, measurements: nil, connection: nil)
82
- @sql = sql
82
+ @sql = BindsConverter.convert(sql, binds)
83
83
  @binds = binds
84
84
  @use = use
85
85
  @eager_loaders = eager_loaders || EagerLoaders::Context.new
@@ -110,14 +110,15 @@ module OccamsRecord
110
110
  def run
111
111
  _escaped_sql = escaped_sql
112
112
  @query_logger << _escaped_sql if @query_logger
113
- result = if measure?
114
- record_start_time!
115
- measure!(table_name, _escaped_sql) {
116
- conn.exec_query _escaped_sql
117
- }
118
- else
119
- conn.exec_query _escaped_sql
120
- end
113
+ result =
114
+ if measure?
115
+ record_start_time!
116
+ measure!(table_name, _escaped_sql) {
117
+ conn.exec_query _escaped_sql
118
+ }
119
+ else
120
+ conn.exec_query _escaped_sql
121
+ end
121
122
  row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: @eager_loaders.model, modules: @use, tracer: @eager_loaders.tracer)
122
123
  rows = result.rows.map { |row| row_class.new row }
123
124
  @eager_loaders.run!(rows, query_logger: @query_logger, measurements: @measurements)
@@ -211,25 +212,29 @@ module OccamsRecord
211
212
  end
212
213
 
213
214
  #
214
- # Returns the specified column(s) as an array of values.
215
+ # Returns the column(s) you've SELECT as an array of values.
215
216
  #
216
- # If more than one column is given, the result will be an array of arrays.
217
+ # If you're selecting multiple columns, you'll get back an array of arrays.
218
+ # Otherwise you'll get an array of the single column's values.
217
219
  #
218
- # @param cols [Array] one or more column names as Symbols or Strings. Also accepts SQL functions, e.g. "LENGTH(name)".
220
+ # @param *args DEPRECATED
219
221
  # @return [Array]
220
222
  #
221
- def pluck(*cols)
223
+ def pluck(*args)
224
+ $stderr.puts "OccamsRecord: passing arguments to OccamsRecord.sql(\"...\").pluck is deprecated and will be removed in a future version. Called from #{caller[0]}" if args.any?
225
+
222
226
  _escaped_sql = escaped_sql
223
227
  @query_logger << _escaped_sql if @query_logger
224
- result = if measure?
225
- record_start_time!
226
- measure!(table_name, _escaped_sql) {
227
- conn.exec_query _escaped_sql
228
- }
229
- else
230
- conn.exec_query _escaped_sql
231
- end
232
- pluck_results result, cols, model: @eager_loaders.model
228
+ result =
229
+ if measure?
230
+ record_start_time!
231
+ measure!(table_name, _escaped_sql) {
232
+ conn.exec_query _escaped_sql
233
+ }
234
+ else
235
+ conn.exec_query _escaped_sql
236
+ end
237
+ pluck_results(result, model: @eager_loaders.model)
233
238
  end
234
239
 
235
240
  private
@@ -237,14 +242,23 @@ module OccamsRecord
237
242
  # Returns the SQL as a String with all variables escaped
238
243
  def escaped_sql
239
244
  return sql if binds.empty?
240
- sql % binds.reduce({}) { |a, (col, val)|
241
- a[col.to_sym] = if val.is_a? Array
242
- val.map { |x| conn.quote x }.join(', ')
243
- else
244
- conn.quote val
245
- end
246
- a
247
- }
245
+ escaped_binds =
246
+ if binds.is_a? Array
247
+ binds.map { |val| quote val }
248
+ else
249
+ binds.each_with_object({}) { |(col, val), acc|
250
+ acc[col.to_sym] = quote val
251
+ }
252
+ end
253
+ sql % escaped_binds
254
+ end
255
+
256
+ def quote(val)
257
+ if val.is_a? Array
258
+ val.map { |x| conn.quote x }.join(', ')
259
+ else
260
+ conn.quote val
261
+ end
248
262
  end
249
263
 
250
264
  def table_name
@@ -1,13 +1,6 @@
1
1
  module OccamsRecord
2
2
  # Classes and methods for handing query results.
3
3
  module Results
4
- # ActiveRecord's internal type casting API changes from version to version.
5
- CASTER = case ActiveRecord::VERSION::MAJOR
6
- when 4 then :type_cast_from_database
7
- when 5, 6, 7 then :deserialize
8
- else raise "OccamsRecord::Results::CASTER does yet support this version of ActiveRecord"
9
- end
10
-
11
4
  #
12
5
  # Dynamically build a class for a specific set of result rows. It inherits from OccamsRecord::Results::Row, and optionall prepends
13
6
  # user-defined modules.
@@ -35,61 +28,31 @@ module OccamsRecord
35
28
  self.table_name = model ? model.table_name : nil
36
29
  self.eager_loader_trace = tracer
37
30
  self.active_record_fallback = active_record_fallback
38
- self.primary_key = if model&.primary_key and (pkey = model.primary_key.to_s) and columns.include?(pkey)
39
- pkey
40
- end
31
+ self.primary_key =
32
+ if model&.primary_key and (pkey = model.primary_key.to_s) and columns.include?(pkey)
33
+ pkey
34
+ end
41
35
 
42
36
  # Build getters & setters for associations. (We need setters b/c they're set AFTER the row is initialized
43
37
  attr_accessor(*association_names)
44
38
 
45
39
  # Build a getter for each attribute returned by the query. The values will be type converted on demand.
46
- model_column_types = model ? model.attributes_builder.types : nil
40
+ casters = TypeCaster.generate(column_names, column_types, model: model)
47
41
  self.columns.each_with_index do |col, idx|
48
- #
49
- # NOTE there's lots of variation between DB adapters and AR versions here. Some notes:
50
- # * Postgres AR < 6.1 `column_types` will contain entries for every column.
51
- # * Postgres AR >= 6.1 `column_types` only contains entries for "exotic" types. Columns with "common" types have already been converted by the PG adapter.
52
- # * SQLite `column_types` will always be empty. Some types will have already been convered by the SQLite adapter, but others will depend on
53
- # `model_column_types` for converstion. See test/raw_query_test.rb#test_common_types for examples.
54
- # * MySQL ?
55
- #
56
- type = column_types[col] || model_column_types&.[](col)
42
+ caster = casters[col]
57
43
 
58
- #
59
- # NOTE is also some variation in when enum values are mapped in different AR versions.
60
- # In >=5.0, <=7.0, ActiveRecord::Result objects contain the human-readable values. In 4.2 and
61
- # pre-release versions of 7.1, they instead have the RAW values (e.g. integers) which we must map ourselves.
62
- #
63
- enum = model&.defined_enums&.[](col)
64
- inv_enum = enum&.invert
65
-
66
- case type&.type
67
- when nil
68
- if enum
69
- define_method(col) {
70
- val = @raw_values[idx]
71
- enum.has_key?(val) ? val : inv_enum[val]
72
- }
73
- else
74
- define_method(col) { @raw_values[idx] }
75
- end
76
- when :datetime
77
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx])&.in_time_zone }
78
- when :boolean
79
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx]) }
80
- define_method("#{col}?") { !!send(col) }
44
+ if caster
45
+ define_method(col) {
46
+ @cast_values[idx] = caster.(@raw_values[idx]) unless @cast_values.has_key?(idx)
47
+ @cast_values[idx]
48
+ }
81
49
  else
82
- if enum
83
- define_method(col) {
84
- @cast_values[idx] ||= (
85
- val = type.send(CASTER, @raw_values[idx])
86
- enum.has_key?(val) ? val : inv_enum[val]
87
- )
88
- }
89
- else
90
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx]) }
91
- end
50
+ define_method(col) {
51
+ @raw_values[idx]
52
+ }
92
53
  end
54
+
55
+ define_method("#{col}?") { send(col).present? }
93
56
  end
94
57
  end
95
58
  end
@@ -69,21 +69,20 @@ module OccamsRecord
69
69
  # @return [Hash] a Hash with String or Symbol keys
70
70
  #
71
71
  def to_h(symbolize_names: false, recursive: false)
72
- hash = self.class.columns.reduce({}) { |a, col_name|
72
+ hash = self.class.columns.each_with_object({}) { |col_name, acc|
73
73
  key = symbolize_names ? col_name.to_sym : col_name
74
- a[key] = send col_name
75
- a
74
+ acc[key] = send col_name
76
75
  }
77
76
 
78
- recursive ? self.class.associations.reduce(hash) { |a, assoc_name|
77
+ recursive ? self.class.associations.each_with_object(hash) { |assoc_name, acc|
79
78
  key = symbolize_names ? assoc_name.to_sym : assoc_name
80
79
  assoc = send assoc_name
81
- a[key] = if assoc.is_a? Array
82
- assoc.map { |x| x.to_h(symbolize_names: symbolize_names, recursive: true) }
83
- elsif assoc
84
- assoc.to_h(symbolize_names: symbolize_names, recursive: true)
85
- end
86
- a
80
+ acc[key] =
81
+ if assoc.is_a? Array
82
+ assoc.map { |x| x.to_h(symbolize_names: symbolize_names, recursive: true) }
83
+ elsif assoc
84
+ assoc.to_h(symbolize_names: symbolize_names, recursive: true)
85
+ end
87
86
  } : hash
88
87
  end
89
88
 
@@ -0,0 +1,62 @@
1
+ module OccamsRecord
2
+ module TypeCaster
3
+ # @private
4
+ CASTER =
5
+ case ActiveRecord::VERSION::MAJOR
6
+ when 4 then :type_cast_from_database
7
+ when 5, 6, 7 then :deserialize
8
+ else raise "OccamsRecord::TypeCaster::CASTER does yet support this version of ActiveRecord"
9
+ end
10
+
11
+ #
12
+ # Returns a Hash containing type converters (a Proc) for each column. The Proc's accept a value and return a converted value, mapping enum values from the model if necessary.
13
+ #
14
+ # NOTE Some columns may have no Proc (particularly if you're using SQLite and running a raw SQL query).
15
+ #
16
+ # @param column_names [Array<String>] the column names in the result set (ActiveRecord::Result#columns). The order MUST match the order returned by the query.
17
+ # @param column_types [Hash] Column name => type (ActiveRecord::Result#column_types)
18
+ # @param model [ActiveRecord::Base] the AR model representing the table (it holds column & type info as well as enums).
19
+ # @return [Hash<Proc>] a Hash of casting Proc's keyed by column
20
+ #
21
+ def self.generate(column_names, column_types, model: nil)
22
+ column_names.each_with_object({}) { |col, memo|
23
+ #
24
+ # NOTE there's lots of variation between DB adapters and AR versions here. Some notes:
25
+ # * Postgres AR < 6.1 `column_types` will contain entries for every column.
26
+ # * Postgres AR >= 6.1 `column_types` only contains entries for "exotic" types. Columns with "common" types have already been converted by the PG adapter.
27
+ # * SQLite `column_types` will always be empty. Some types will have already been convered by the SQLite adapter, but others will depend on
28
+ # `model_column_types` for converstion. See test/raw_query_test.rb#test_common_types for examples.
29
+ # * MySQL ?
30
+ #
31
+ type = column_types[col] || model&.attributes_builder&.types&.[](col)
32
+
33
+ #
34
+ # NOTE is also some variation in when enum values are mapped in different AR versions.
35
+ # In >=5.0, <=7.0, ActiveRecord::Result objects *usually* contain the human-readable values. In 4.2 and
36
+ # pre-release versions of 7.1, they instead have the RAW values (e.g. integers) which we must map ourselves.
37
+ #
38
+ enum = model&.defined_enums&.[](col)
39
+ inv_enum = enum&.invert
40
+
41
+ memo[col] =
42
+ case type&.type
43
+ when nil
44
+ if enum
45
+ ->(val) { enum.has_key?(val) ? val : inv_enum[val] }
46
+ end
47
+ when :datetime
48
+ ->(val) { type.send(CASTER, val)&.in_time_zone }
49
+ else
50
+ if enum
51
+ ->(val) {
52
+ val = type.send(CASTER, val)
53
+ enum.has_key?(val) ? val : inv_enum[val]
54
+ }
55
+ else
56
+ ->(val) { type.send(CASTER, val) }
57
+ end
58
+ end
59
+ }
60
+ end
61
+ end
62
+ end
@@ -3,5 +3,5 @@
3
3
  #
4
4
  module OccamsRecord
5
5
  # @private
6
- VERSION = "1.8.0".freeze
6
+ VERSION = "1.9.0".freeze
7
7
  end
data/lib/occams-record.rb CHANGED
@@ -3,6 +3,7 @@ require 'occams-record/version'
3
3
  require 'occams-record/merge'
4
4
  require 'occams-record/measureable'
5
5
  require 'occams-record/eager_loaders/eager_loaders'
6
+ require 'occams-record/type_caster'
6
7
  require 'occams-record/results/results'
7
8
  require 'occams-record/results/row'
8
9
  require 'occams-record/pluck'
@@ -13,6 +14,11 @@ require 'occams-record/batches/offset_limit/scoped'
13
14
  require 'occams-record/batches/offset_limit/raw_query'
14
15
  require 'occams-record/batches/cursor_helpers'
15
16
 
17
+ require 'occams-record/binds_converter'
18
+ require 'occams-record/binds_converter/abstract'
19
+ require 'occams-record/binds_converter/named'
20
+ require 'occams-record/binds_converter/positional'
21
+
16
22
  require 'occams-record/query'
17
23
  require 'occams-record/raw_query'
18
24
 
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: 1.8.0
4
+ version: 1.9.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: 2023-07-01 00:00:00.000000000 Z
11
+ date: 2023-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -30,20 +30,6 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '7.1'
33
- - !ruby/object:Gem::Dependency
34
- name: appraisal
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
- type: :development
41
- prerelease: false
42
- version_requirements: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: '0'
47
33
  description: A faster, lower-memory, fuller-featured querying API for ActiveRecord
48
34
  that returns results as unadorned, read-only objects.
49
35
  email: jordan.hollinger@gmail.com
@@ -56,6 +42,10 @@ files:
56
42
  - lib/occams-record/batches/cursor_helpers.rb
57
43
  - lib/occams-record/batches/offset_limit/raw_query.rb
58
44
  - lib/occams-record/batches/offset_limit/scoped.rb
45
+ - lib/occams-record/binds_converter.rb
46
+ - lib/occams-record/binds_converter/abstract.rb
47
+ - lib/occams-record/binds_converter/named.rb
48
+ - lib/occams-record/binds_converter/positional.rb
59
49
  - lib/occams-record/cursor.rb
60
50
  - lib/occams-record/eager_loaders/ad_hoc_base.rb
61
51
  - lib/occams-record/eager_loaders/ad_hoc_many.rb
@@ -79,6 +69,7 @@ files:
79
69
  - lib/occams-record/raw_query.rb
80
70
  - lib/occams-record/results/results.rb
81
71
  - lib/occams-record/results/row.rb
72
+ - lib/occams-record/type_caster.rb
82
73
  - lib/occams-record/ugly.rb
83
74
  - lib/occams-record/version.rb
84
75
  homepage: https://jhollinger.github.io/occams-record/