occams-record 1.4.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []