occams-record 1.4.0 → 1.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,10 +58,11 @@ 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
65
+ include OccamsRecord::Pluck
65
66
  include EagerLoaders::Builder
66
67
  include Enumerable
67
68
  include Measureable
@@ -69,8 +70,8 @@ module OccamsRecord
69
70
  #
70
71
  # Initialize a new query.
71
72
  #
72
- # @param sql [String] The SELECT statement to run. Binds should use Ruby's named string substitution.
73
- # @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
74
75
  # @param use [Array<Module>] optional Module to include in the result class (single or array)
75
76
  # @param eager_loaders [OccamsRecord::EagerLoaders::Context]
76
77
  # @param query_logger [Array] (optional) an array into which all queries will be inserted for logging/debug purposes
@@ -78,7 +79,7 @@ module OccamsRecord
78
79
  # @param connection
79
80
  #
80
81
  def initialize(sql, binds, use: nil, eager_loaders: nil, query_logger: nil, measurements: nil, connection: nil)
81
- @sql = sql
82
+ @sql = BindsConverter.convert(sql, binds)
82
83
  @binds = binds
83
84
  @use = use
84
85
  @eager_loaders = eager_loaders || EagerLoaders::Context.new
@@ -109,15 +110,16 @@ module OccamsRecord
109
110
  def run
110
111
  _escaped_sql = escaped_sql
111
112
  @query_logger << _escaped_sql if @query_logger
112
- result = if measure?
113
- record_start_time!
114
- measure!(table_name, _escaped_sql) {
115
- conn.exec_query _escaped_sql
116
- }
117
- else
118
- conn.exec_query _escaped_sql
119
- end
120
- row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: @eager_loaders.model, modules: @use)
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
122
+ row_class = OccamsRecord::Results.klass(result.columns, result.column_types, @eager_loaders.names, model: @eager_loaders.model, modules: @use, tracer: @eager_loaders.tracer)
121
123
  rows = result.rows.map { |row| row_class.new row }
122
124
  @eager_loaders.run!(rows, query_logger: @query_logger, measurements: @measurements)
123
125
  yield_measurements!
@@ -209,19 +211,54 @@ module OccamsRecord
209
211
  )
210
212
  end
211
213
 
214
+ #
215
+ # Returns the column(s) you've SELECT as an array of values.
216
+ #
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.
219
+ #
220
+ # @param *args DEPRECATED
221
+ # @return [Array]
222
+ #
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
+
226
+ _escaped_sql = escaped_sql
227
+ @query_logger << _escaped_sql if @query_logger
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)
238
+ end
239
+
212
240
  private
213
241
 
214
242
  # Returns the SQL as a String with all variables escaped
215
243
  def escaped_sql
216
244
  return sql if binds.empty?
217
- sql % binds.reduce({}) { |a, (col, val)|
218
- a[col.to_sym] = if val.is_a? Array
219
- val.map { |x| conn.quote x }.join(', ')
220
- else
221
- conn.quote val
222
- end
223
- a
224
- }
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
225
262
  end
226
263
 
227
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.
@@ -17,9 +10,14 @@ module OccamsRecord
17
10
  # @param association_names [Array<String>] names of associations that will be eager loaded into the results.
18
11
  # @param model [ActiveRecord::Base] the AR model representing the table (it holds column & type info).
19
12
  # @param modules [Array<Module>] (optional)
13
+ # @param tracer [OccamsRecord::EagerLoaders::Tracer] the eager loaded that loaded this class of records
14
+ # @param active_record_fallback [Symbol] If passed, missing methods will be forwarded to an ActiveRecord instance. Options are :lazy (allow lazy loading in the AR record) or :strict (require strict loading)
20
15
  # @return [OccamsRecord::Results::Row] a class customized for this result set
21
16
  #
22
- def self.klass(column_names, column_types, association_names = [], model: nil, modules: nil)
17
+ def self.klass(column_names, column_types, association_names = [], model: nil, modules: nil, tracer: nil, active_record_fallback: nil)
18
+ raise ArgumentError, "Invalid active_record_fallback option :#{active_record_fallback}. Valid options are :lazy, :strict" if active_record_fallback and !%i(lazy strict).include?(active_record_fallback)
19
+ raise ArgumentError, "Option active_record_fallback is not allowed when no model is present" if active_record_fallback and model.nil?
20
+
23
21
  Class.new(Results::Row) do
24
22
  Array(modules).each { |mod| prepend mod } if modules
25
23
 
@@ -28,36 +26,33 @@ module OccamsRecord
28
26
  self._model = model
29
27
  self.model_name = model ? model.name : nil
30
28
  self.table_name = model ? model.table_name : nil
31
- self.primary_key = if model&.primary_key and (pkey = model.primary_key.to_s) and columns.include?(pkey)
32
- pkey
33
- end
29
+ self.eager_loader_trace = tracer
30
+ self.active_record_fallback = active_record_fallback
31
+ self.primary_key =
32
+ if model&.primary_key and (pkey = model.primary_key.to_s) and columns.include?(pkey)
33
+ pkey
34
+ end
34
35
 
35
36
  # Build getters & setters for associations. (We need setters b/c they're set AFTER the row is initialized
36
37
  attr_accessor(*association_names)
37
38
 
38
39
  # Build a getter for each attribute returned by the query. The values will be type converted on demand.
39
- model_column_types = model ? model.attributes_builder.types : nil
40
+ casters = TypeCaster.generate(column_names, column_types, model: model)
40
41
  self.columns.each_with_index do |col, idx|
41
- #
42
- # NOTE there's lots of variation between DB adapters and AR versions here. Some notes:
43
- # * Postgres AR < 6.1 `column_types` will contain entries for every column.
44
- # * Postgres AR >= 6.1 `column_types` only contains entries for "exotic" types. Columns with "common" types have already been converted by the PG adapter.
45
- # * SQLite `column_types` will always be empty. Some types will have already been convered by the SQLite adapter, but others will depend on
46
- # `model_column_types` for converstion. See test/raw_query_test.rb#test_common_types for examples.
47
- # * MySQL ?
48
- #
49
- type = column_types[col] || model_column_types&.[](col)
50
- case type&.type
51
- when nil
52
- define_method(col) { @raw_values[idx] }
53
- when :datetime
54
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx])&.in_time_zone }
55
- when :boolean
56
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx]) }
57
- define_method("#{col}?") { !!send(col) }
42
+ caster = casters[col]
43
+
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
+ }
58
49
  else
59
- define_method(col) { @cast_values[idx] ||= type.send(CASTER, @raw_values[idx]) }
50
+ define_method(col) {
51
+ @raw_values[idx]
52
+ }
60
53
  end
54
+
55
+ define_method("#{col}?") { send(col).present? }
61
56
  end
62
57
  end
63
58
  end
@@ -19,6 +19,10 @@ module OccamsRecord
19
19
  attr_accessor :table_name
20
20
  # Name of primary key column (nil if column wasn't in the SELECT)
21
21
  attr_accessor :primary_key
22
+ # A trace of how this record was loaded (for debugging)
23
+ attr_accessor :eager_loader_trace
24
+ # If present, missing methods will be forwarded to the ActiveRecord model. :lazy allows lazy loading in AR, :strict doesn't
25
+ attr_accessor :active_record_fallback
22
26
  end
23
27
  self.columns = []
24
28
  self.associations = []
@@ -36,7 +40,7 @@ module OccamsRecord
36
40
  #
37
41
  # Hash-like accessor for attributes and associations.
38
42
  #
39
- # @param attr [String|Symbol\
43
+ # @param attr [String|Symbol]
40
44
  # @return [Object]
41
45
  #
42
46
  def [](attr)
@@ -65,21 +69,20 @@ module OccamsRecord
65
69
  # @return [Hash] a Hash with String or Symbol keys
66
70
  #
67
71
  def to_h(symbolize_names: false, recursive: false)
68
- hash = self.class.columns.reduce({}) { |a, col_name|
72
+ hash = self.class.columns.each_with_object({}) { |col_name, acc|
69
73
  key = symbolize_names ? col_name.to_sym : col_name
70
- a[key] = send col_name
71
- a
74
+ acc[key] = send col_name
72
75
  }
73
76
 
74
- recursive ? self.class.associations.reduce(hash) { |a, assoc_name|
77
+ recursive ? self.class.associations.each_with_object(hash) { |assoc_name, acc|
75
78
  key = symbolize_names ? assoc_name.to_sym : assoc_name
76
79
  assoc = send assoc_name
77
- a[key] = if assoc.is_a? Array
78
- assoc.map { |x| x.to_h(symbolize_names: symbolize_names, recursive: true) }
79
- elsif assoc
80
- assoc.to_h(symbolize_names: symbolize_names, recursive: true)
81
- end
82
- 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
83
86
  } : hash
84
87
  end
85
88
 
@@ -110,19 +113,24 @@ module OccamsRecord
110
113
  IDS_SUFFIX = /_ids$/
111
114
  def method_missing(name, *args, &block)
112
115
  model = self.class._model
113
- return super if args.any? or !block.nil? or model.nil?
116
+ ex = NoMethodError.new("Undefined method `#{name}' for #{self.inspect}. Occams Record trace: #{self.class.eager_loader_trace}", name, args)
117
+ raise ex if model.nil?
114
118
 
115
119
  name_str = name.to_s
116
120
  assoc = name_str.sub(IDS_SUFFIX, "").pluralize
117
- if name_str =~ IDS_SUFFIX and can_define_ids_reader? assoc
121
+ no_args = args.empty? && block.nil?
122
+
123
+ if no_args and name_str =~ IDS_SUFFIX and can_define_ids_reader? assoc
118
124
  define_ids_reader! assoc
119
125
  send name
120
- elsif model.reflections.has_key? name_str
126
+ elsif no_args and model.reflections.has_key? name_str
121
127
  raise MissingEagerLoadError.new(self, name)
122
- elsif model.columns_hash.has_key? name_str
128
+ elsif no_args and model.columns_hash.has_key? name_str
123
129
  raise MissingColumnError.new(self, name)
130
+ elsif self.class.active_record_fallback
131
+ active_record_fallback(name, *args, &block)
124
132
  else
125
- super
133
+ raise ex
126
134
  end
127
135
  end
128
136
 
@@ -141,6 +149,15 @@ module OccamsRecord
141
149
 
142
150
  private
143
151
 
152
+ def active_record_fallback(name, *args, &block)
153
+ @active_record_fallback ||= Ugly::active_record(self.class._model, self).tap { |record|
154
+ record.strict_loading! if self.class.active_record_fallback == :strict
155
+ }
156
+ @active_record_fallback.send(name, *args, &block)
157
+ rescue NoMethodError => e
158
+ raise NoMethodError.new("#{e.message}. Occams Record trace: #{self.class.eager_loader_trace}.active_record_fallback(#{self.class._model.name})", name, args)
159
+ end
160
+
144
161
  def can_define_ids_reader?(assoc)
145
162
  model = self.class._model
146
163
  self.class.associations.include?(assoc) &&
@@ -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.4.0".freeze
6
+ VERSION = "1.9.0".freeze
7
7
  end
data/lib/occams-record.rb CHANGED
@@ -3,8 +3,10 @@ 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'
9
+ require 'occams-record/pluck'
8
10
  require 'occams-record/cursor'
9
11
  require 'occams-record/errors'
10
12
 
@@ -12,6 +14,11 @@ require 'occams-record/batches/offset_limit/scoped'
12
14
  require 'occams-record/batches/offset_limit/raw_query'
13
15
  require 'occams-record/batches/cursor_helpers'
14
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
+
15
22
  require 'occams-record/query'
16
23
  require 'occams-record/raw_query'
17
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.4.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Hollinger
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-25 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
@@ -70,20 +60,23 @@ files:
70
60
  - lib/occams-record/eager_loaders/has_one.rb
71
61
  - lib/occams-record/eager_loaders/polymorphic_belongs_to.rb
72
62
  - lib/occams-record/eager_loaders/through.rb
63
+ - lib/occams-record/eager_loaders/tracer.rb
73
64
  - lib/occams-record/errors.rb
74
65
  - lib/occams-record/measureable.rb
75
66
  - lib/occams-record/merge.rb
67
+ - lib/occams-record/pluck.rb
76
68
  - lib/occams-record/query.rb
77
69
  - lib/occams-record/raw_query.rb
78
70
  - lib/occams-record/results/results.rb
79
71
  - lib/occams-record/results/row.rb
72
+ - lib/occams-record/type_caster.rb
80
73
  - lib/occams-record/ugly.rb
81
74
  - lib/occams-record/version.rb
82
75
  homepage: https://jhollinger.github.io/occams-record/
83
76
  licenses:
84
77
  - MIT
85
78
  metadata: {}
86
- post_install_message:
79
+ post_install_message:
87
80
  rdoc_options: []
88
81
  require_paths:
89
82
  - lib
@@ -98,8 +91,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
91
  - !ruby/object:Gem::Version
99
92
  version: '0'
100
93
  requirements: []
101
- rubygems_version: 3.0.3.1
102
- signing_key:
94
+ rubygems_version: 3.4.1
95
+ signing_key:
103
96
  specification_version: 4
104
97
  summary: The missing high-efficiency query API for ActiveRecord
105
98
  test_files: []