pluck_map 0.6.2 → 2.0.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: d6713a4fb63be352fb7c8d54bbbdb7a559fc4b523093f3cff0a72b9507803a8e
4
- data.tar.gz: e7f9e9caea6745daeb1beab73c711fd87656309fd3221422ad027072d6fcd8d7
3
+ metadata.gz: 597612beb6033e9f03ae69a121aa0a91eab830e400917e3dc8e1f4b42052545b
4
+ data.tar.gz: 0bac2743a30f9795a6bfa572ef2faf7ee230aea78b75b9d0b0ac41399fd37077
5
5
  SHA512:
6
- metadata.gz: ca03d1e1f7e82934ee018c93ac980b7f35c8d9805782de196cb1941b36992880ef19f193d8d9055ac03a3999cf58168503e65ccd74fd10a59d806178c2d8950f
7
- data.tar.gz: 48822bd995380e8a97e5166c94f890b010823b55227884c66f06e6d4d36b6baf22c9b577aa3acf9670d2cca186c4dac3ccb6ee165b8c27e7861f89d31c4ba5ce
6
+ metadata.gz: 5dab317224c87b728585ebfd45267be0780854fe0a383b31cabcf88781c58900845af6d3dcf760f2beb35199b58fc7636f3a55dad125ead09579414cdd867fa2
7
+ data.tar.gz: 2cc8bfb68996ca3a350a0cd3324e9d56309485975622b72c0a13584c523f47a7bf1f90ca59b16fee0afc145c4283da12cf3504f04e2b8cfb71c43b42eb9ba26a
@@ -1,9 +1,13 @@
1
1
  language: ruby
2
- rvm: 2.5.5
2
+ rvm: 2.6.3
3
+
4
+ # we need MySQL 5.7+ to support JSON aggregation
5
+ dist: xenial
3
6
 
4
- # Rails 6 requires Postgres 9.3+
5
7
  addons:
6
- postgresql: "9.3"
8
+ # Rails 6 requires Postgres 9.3+
9
+ # We need Postgres 9.4+ to support JSON aggregation
10
+ postgresql: "9.4"
7
11
 
8
12
  services:
9
13
  - postgresql
@@ -17,6 +21,7 @@ matrix:
17
21
  - gemfile: gemfiles/rails_5.0.gemfile
18
22
  - gemfile: gemfiles/rails_5.1.gemfile
19
23
  - gemfile: gemfiles/rails_5.2.gemfile
24
+ - gemfile: gemfiles/rails_6.0.gemfile
20
25
  - gemfile: gemfiles/rails_edge.gemfile
21
26
  allow_failures:
22
27
  - gemfile: gemfiles/rails_edge.gemfile
data/Appraisals CHANGED
@@ -19,6 +19,11 @@ appraise "rails-5.2" do
19
19
  gem "sqlite3", "~> 1.3.6"
20
20
  end
21
21
 
22
+ appraise "rails-6.0" do
23
+ gem "activerecord", "~> 6.0.0"
24
+ gem "sqlite3", "~> 1.4.0"
25
+ end
26
+
22
27
  appraise "rails-edge" do
23
28
  gem "rails", git: "https://github.com/rails/rails.git", branch: "master", require: "activerecord"
24
29
  gem "sqlite3", "~> 1.4.0"
@@ -1,3 +1,22 @@
1
+ ## Unreleased
2
+
3
+ * BREAKING: `define` returns a subclass of `PluckMap::Presenter` instead of an instance (@boblail)
4
+ * FEATURE: `define` also creates structs to correspond to each presenter (@boblail)
5
+
6
+ ## v1.0.0 (2019 Jul 17)
7
+
8
+ * FIX: Respect default_scopes for relationships (@boblail)
9
+
10
+ ## v1.0.0.rc2 (2019 Jun 17)
11
+
12
+ * FEATURE: Add structured attributes to allow nesting attributes as a hash (@kobsy)
13
+
14
+ ## v1.0.0.rc1 (2019 May 12)
15
+
16
+ * BREAKING: Remove deprecated features/methods (@boblail)
17
+ * FEATURE: Optimize `to_json` when a presenter doesn't need to process values in Ruby (@boblail)
18
+ * FEATURE: Add `has_many` and `has_one` DSL for presenting nested resources (@boblail)
19
+
1
20
  ## v0.6.2 (2019 Jun 13)
2
21
 
3
22
  * FIX: Allow presenting a subclass of the presenter's model (@kobsy)
data/README.md CHANGED
@@ -8,13 +8,14 @@ This library provides a DSL for presenting ActiveRecord::Relations without insta
8
8
 
9
9
  ### Table of Contents
10
10
 
11
- - [Why PluckMap?](https://github.com/boblail/pluck_map#why-pluckmap)
11
+ - [Why PluckMap?](#why-pluckmap)
12
12
  - Usage
13
- - [Defining attributes to present](https://github.com/boblail/pluck_map#defining-attributes-to-present)
14
- - [Presenting Records](https://github.com/boblail/pluck_map#presenting-records)
15
- - [Installation](https://github.com/boblail/pluck_map#installation)
16
- - [Requirements](https://github.com/boblail/pluck_map#requirements)
17
- - [Development & Contributing](https://github.com/boblail/pluck_map#development)
13
+ - [Defining attributes to present](#defining-attributes-to-present)
14
+ - [Relationships](#relationships)
15
+ - [Presenting Records](#presenting-records)
16
+ - [Installation](#installation)
17
+ - [Requirements](#requirements)
18
+ - [Development & Contributing](#development)
18
19
 
19
20
 
20
21
  ## Why PluckMap?
@@ -207,6 +208,93 @@ presenter = PluckMap[Person].define do
207
208
  end
208
209
  ```
209
210
 
211
+ ### Structured attributes
212
+
213
+ You can also nest attributes by passing a block to the attribute method:
214
+
215
+ ```ruby
216
+ presenter = PluckMap[Person].define do
217
+ parent do
218
+ id select: :parent_id
219
+ type "Parent"
220
+ end
221
+ end
222
+ ```
223
+
224
+ ### Relationships
225
+
226
+ PluckMap can also describe nested data. There are two special methods in the `define` block that introduce child resources:
227
+
228
+ 1. `has_one` will treat the resource as a nested object or null
229
+ 2. `has_many` will treat the resource as an array of nested objects (which may be empty)
230
+
231
+ The first argument to either of these methods is the name of an association on the presented model.
232
+
233
+ You can use either of these methods with any kind of ActiveRecord relation (`belongs_to`, `has_one`, `has_many`, `has_and_belongs_to_many`), although it generally makes more sense to use `has_one` with Rails' singular associations and `has_many` with Rails' plural associations.
234
+
235
+ #### `has_one`
236
+
237
+ In the example below, assume
238
+
239
+ ```ruby
240
+ class Book < ActiveRecord::Base
241
+ belongs_to :author
242
+ end
243
+ ```
244
+
245
+ This presenter :point_down: selects the title of every book as well as its author's name:
246
+
247
+ ```ruby
248
+ presenter = PluckMap[Book].define do
249
+ title
250
+ has_one :author do
251
+ name
252
+ end
253
+ end
254
+ ```
255
+
256
+ (We can also write it using block variables, if that's easier to read.)
257
+
258
+ ```ruby
259
+ presenter = PluckMap[Book].define do |book|
260
+ book.title
261
+ book.has_one :author do |author|
262
+ author.name
263
+ end
264
+ end
265
+ ```
266
+
267
+ Attributes defined for a relationship support all the same features as [attributes defined at the root level](#defining-attributes-to-present).
268
+
269
+
270
+ #### `has_many`
271
+
272
+ We can present the reverse of the above example with `has_many`. This example will select a list of authors and, for each, a list of the books they wrote:
273
+
274
+ ```ruby
275
+ presenter = PluckMap[Author].define do
276
+ name
277
+ has_many :books do
278
+ title
279
+ end
280
+ end
281
+ ```
282
+
283
+ #### scopes
284
+
285
+ An optional second argument to both `has_one` and `has_many` is a scope block that you can use to modify the query that would select the associated records. You can use any of ActiveRecord's standard [querying methods](https://guides.rubyonrails.org/active_record_querying.html) inside the scope block.
286
+
287
+ In this example, we've altered our last presenter to ensure that books are listed alphabetically:
288
+
289
+ ```ruby
290
+ presenter = PluckMap[Author].define do
291
+ name
292
+ has_many :books, -> { order(title: :asc) } do
293
+ title
294
+ end
295
+ end
296
+ ```
297
+
210
298
 
211
299
  ### Presenting Records
212
300
 
@@ -299,9 +387,11 @@ The gem's only runtime requirement is:
299
387
 
300
388
  It supports these databases out of the box:
301
389
 
302
- - PostgreSQL
303
- - MySQL
304
- - SQLite
390
+ - PostgreSQL 9.4+
391
+ - MySQL 5.7.22+
392
+ - SQLite 3.10.0+
393
+
394
+ (Note: the versions given above are when certain JSON aggregate functions were introduced in each supported database. `PluckMap`'s core behavior will work with earlier versions of the database above but certain features like optimizations to `to_json` and relationships require the specified versions.)
305
395
 
306
396
 
307
397
 
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.0.0"
6
+ gem "sqlite3", "~> 1.4.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,102 @@
1
+ require "active_record"
2
+
3
+ # ActiveRecord::Associations::AssociationScope assumes that values
4
+ # for Owner's fields will be concrete values that need to be type-cast.
5
+ #
6
+ # But our AbstractOwner returns field references (Arel::Attributes::Attribute)
7
+ # and we need them to bypass type-casting.
8
+ #
9
+ module PluckMap
10
+ module AssociationScope
11
+ def self.[](version)
12
+ const_get "Rails#{version.to_s.delete(".")}"
13
+ end
14
+
15
+ def self.create
16
+ case ActiveRecord.version.segments.take(2)
17
+ when [4,2] then self[4.2].create
18
+ when [5,0] then self[5.0].create
19
+ else self::Current.create
20
+ end
21
+ end
22
+
23
+
24
+ # Rails 5.1+
25
+ class Current < ActiveRecord::Associations::AssociationScope
26
+ def apply_scope(scope, table, key, value)
27
+ if value.is_a?(Arel::Attributes::Attribute)
28
+ scope.where!(table[key].eq(value))
29
+ else
30
+ super
31
+ end
32
+ end
33
+
34
+ def scope(association)
35
+ if ActiveRecord.version.version < "5.2"
36
+ super(association, association.reflection.active_record.connection)
37
+ else
38
+ super
39
+ end
40
+ end
41
+ end
42
+
43
+
44
+ # In Rails 5.0, `apply_scope` isn't extracted from `last_chain_scope`
45
+ # and `next_chain_scope` so we have to override the entire methods to
46
+ # extract `apply_scope` and bypass type-casting.
47
+ #
48
+ # Refer to https://github.com/rails/rails/blob/v5.0.7.2/activerecord/lib/active_record/associations/association_scope.rb#L61-L94
49
+ #
50
+ class Rails50 < Current
51
+ def last_chain_scope(scope, table, reflection, owner, association_klass)
52
+ join_keys = reflection.join_keys(association_klass)
53
+ key = join_keys.key
54
+ foreign_key = join_keys.foreign_key
55
+
56
+ value = transform_value(owner[foreign_key])
57
+ scope = apply_scope(scope, table, key, value)
58
+
59
+ if reflection.type
60
+ polymorphic_type = transform_value(owner.class.base_class.name)
61
+ scope = scope.where(table.name => { reflection.type => polymorphic_type })
62
+ end
63
+
64
+ scope
65
+ end
66
+
67
+ def next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection)
68
+ join_keys = reflection.join_keys(association_klass)
69
+ key = join_keys.key
70
+ foreign_key = join_keys.foreign_key
71
+
72
+ constraint = table[key].eq(foreign_table[foreign_key])
73
+
74
+ if reflection.type
75
+ value = transform_value(next_reflection.klass.base_class.name)
76
+ scope = apply_scope(scope, table, reflection.type, value)
77
+ end
78
+
79
+ scope = scope.joins(join(foreign_table, constraint))
80
+ end
81
+
82
+ def apply_scope(scope, table, key, value)
83
+ if value.is_a?(Arel::Attributes::Attribute)
84
+ scope.where(table[key].eq(value))
85
+ else
86
+ scope.where(table.name => { key => value })
87
+ end
88
+ end
89
+ end
90
+
91
+
92
+ class Rails42 < Current
93
+ def bind(_, _, _, value, _)
94
+ if value.is_a?(Arel::Attributes::Attribute)
95
+ value
96
+ else
97
+ super
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -14,16 +14,14 @@ module PluckMap
14
14
  @value = options[:value]
15
15
  @selects = []
16
16
  else
17
- raise ArgumentError, "You must select at least one column" if selects.empty?
17
+ raise ArgumentError, "You must select at least one column" if @selects.empty?
18
18
  raise ArgumentError, "You must define a block if you are going to select " <<
19
- "more than one expression from the database" if selects.length > 1 && !block
19
+ "more than one expression from the database" if @selects.length > 1 && !@block
20
20
 
21
- @selects = @selects.map do |select|
21
+ @selects.each do |select|
22
22
  if select.is_a?(String) && !select.is_a?(Arel::Nodes::SqlLiteral)
23
- puts "DEPRECATION WARNING: Passing raw SQL as a String to :select is deprecated. Known-safe values can be passed by wrapping them in Arel.sql()."
24
- Arel.sql(select)
25
- else
26
- select
23
+ raise ArgumentError, "#{select.inspect} is not a valid value for :select. " <<
24
+ "If a string of raw SQL is safe, wrap it in Arel.sql()."
27
25
  end
28
26
  end
29
27
  end
@@ -33,10 +31,21 @@ module PluckMap
33
31
  block.call(*object)
34
32
  end
35
33
 
34
+ def value?
35
+ defined?(@value)
36
+ end
37
+
36
38
  def will_map?
37
39
  !block.nil?
38
40
  end
39
41
 
42
+ def nested?
43
+ false
44
+ end
45
+
46
+ def preload!(results)
47
+ end
48
+
40
49
  # When the PluckMapPresenter performs the query, it will
41
50
  # receive an array of rows. Each row will itself be an
42
51
  # array of values.
@@ -44,18 +53,22 @@ module PluckMap
44
53
  # This method constructs a Ruby expression that will
45
54
  # extract the appropriate values from each row that
46
55
  # correspond to this Attribute.
47
- def to_ruby(selects = nil)
48
- if selects
49
- puts "DEPRECATION WARNING: PluckMap::Attribute#to_ruby no longer requires an argument. Replace `attribute.to_ruby(keys)` with `attribute.to_ruby`."
50
- end
51
-
52
- return @value.inspect if defined?(@value)
56
+ def to_ruby
57
+ return @value.inspect if value?
53
58
  return "values[#{indexes[0]}]" if indexes.length == 1 && !block
54
59
  ruby = "values.values_at(#{indexes.join(", ")})"
55
60
  ruby = "invoke(:\"#{id}\", #{ruby})" if block
56
61
  ruby
57
62
  end
58
63
 
64
+ def exec(values)
65
+ return @value if value?
66
+ return values[indexes[0]] if indexes.length == 1 && !block
67
+ _values = values.values_at(*indexes)
68
+ _values = apply(_values) if block
69
+ _values
70
+ end
71
+
59
72
 
60
73
 
61
74
  def values
@@ -1,5 +1,7 @@
1
1
  require "pluck_map/attribute"
2
+ require "pluck_map/structured_attribute"
2
3
  require "pluck_map/attributes"
4
+ require "pluck_map/relationships"
3
5
 
4
6
  module PluckMap
5
7
  class AttributeBuilder < BasicObject
@@ -12,7 +14,7 @@ module PluckMap
12
14
  else
13
15
  builder.instance_eval(&block)
14
16
  end
15
- Attributes.new(attributes)
17
+ Attributes.new(attributes, model)
16
18
  end
17
19
 
18
20
  def initialize(attributes, model)
@@ -20,12 +22,27 @@ module PluckMap
20
22
  @model = model
21
23
  end
22
24
 
23
- def method_missing(attribute_name, *args)
25
+ def method_missing(attribute_name, *args, &block)
24
26
  options = args.extract_options!
25
27
  options[:value] = args.first unless args.empty?
26
- @attributes.push Attribute.new(attribute_name, @model, options)
28
+ @attributes.push block.nil? ? Attribute.new(attribute_name, @model, options) :
29
+ StructuredAttribute.new(attribute_name, @model, block, options)
27
30
  :attribute_added
28
31
  end
29
32
 
33
+ def has_many(name, *args, &block)
34
+ options = args.extract_options!
35
+ options[:scope_block] = args.first unless args.empty?
36
+ @attributes.push Relationships.many(@model, name, block, options)
37
+ :relationship_added
38
+ end
39
+
40
+ def has_one(name, *args, &block)
41
+ options = args.extract_options!
42
+ options[:scope_block] = args.first unless args.empty?
43
+ @attributes.push Relationships.one(@model, name, block, options)
44
+ :relationship_added
45
+ end
46
+
30
47
  end
31
48
  end
@@ -2,9 +2,10 @@ module PluckMap
2
2
  class Attributes
3
3
  include Enumerable
4
4
 
5
- attr_reader :selects
5
+ attr_reader :selects, :model
6
6
 
7
- def initialize(attributes)
7
+ def initialize(attributes, model)
8
+ @model = model
8
9
  @_attributes = attributes.freeze
9
10
  @_attributes_by_id = {}
10
11
  @selects = []
@@ -36,16 +37,31 @@ module PluckMap
36
37
 
37
38
 
38
39
 
40
+ def ids
41
+ _attributes_by_id.keys
42
+ end
43
+
39
44
  def by_id
40
45
  _attributes_by_id
41
46
  end
42
47
 
48
+ def to_json_array
49
+ PluckMap::BuildJsonArray.new(*selects.map do |select|
50
+ select = model.arel_table[select] if select.is_a?(Symbol)
51
+ select
52
+ end)
53
+ end
54
+
43
55
 
44
56
 
45
57
  def will_map?
46
58
  _attributes.any?(&:will_map?)
47
59
  end
48
60
 
61
+ def nested?
62
+ _attributes.any?(&:nested?)
63
+ end
64
+
49
65
 
50
66
 
51
67
  def ==(other)
@@ -0,0 +1,4 @@
1
+ module PluckMap
2
+ class UnsupportedAttributeError < ArgumentError
3
+ end
4
+ end
@@ -1,4 +1,5 @@
1
1
  require "pluck_map/presenter"
2
+ require "pluck_map/struct"
2
3
 
3
4
  module PluckMap
4
5
  class ModelContext
@@ -8,7 +9,27 @@ module PluckMap
8
9
 
9
10
  def define(&block)
10
11
  attributes = PluckMap::AttributeBuilder.build(model: @model, &block)
11
- PluckMap::Presenter.new(@model, attributes)
12
+ define_class!(@model, attributes)
13
+ end
14
+
15
+ private
16
+
17
+ def define_class!(model, attributes)
18
+ # Create a new subclass of PluckMap::Presenter
19
+ klass = Class.new(PluckMap::Presenter)
20
+
21
+ # Partially apply initialize with the parameters passed to this method
22
+ klass.define_method(:initialize) do |query|
23
+ super(model, attributes, query)
24
+ end
25
+
26
+ # Generate a Struct constant in the namespace of the new subclass
27
+ struct = ::Struct.new(*attributes.ids, keyword_init: true)
28
+ struct.extend PluckMap::Struct::ClassMethods
29
+ struct.instance_variable_set :@presenter, klass
30
+ klass.const_set :Struct, struct
31
+
32
+ klass
12
33
  end
13
34
  end
14
35
  end
@@ -0,0 +1,40 @@
1
+ require "arel"
2
+
3
+ module PluckMap
4
+ class BuildJsonObject < Arel::Nodes::Node
5
+ include Arel::AliasPredication
6
+
7
+ attr_reader :args
8
+
9
+ def initialize(*args)
10
+ @args = args
11
+ end
12
+ end
13
+
14
+ class BuildJsonArray < Arel::Nodes::Node
15
+ include Arel::AliasPredication
16
+
17
+ attr_reader :args
18
+
19
+ def initialize(*args)
20
+ @args = args
21
+ end
22
+ end
23
+
24
+ class JsonArrayAggregate < Arel::Nodes::Node
25
+ attr_reader :arg
26
+
27
+ def initialize(arg)
28
+ @arg = arg
29
+ end
30
+ end
31
+
32
+ class JsonSubqueryAggregate < Arel::Nodes::Node
33
+ attr_reader :scope, :select
34
+
35
+ def initialize(scope, select)
36
+ @scope = scope
37
+ @select = select
38
+ end
39
+ end
40
+ end
@@ -1,45 +1,35 @@
1
1
  require "pluck_map/attribute_builder"
2
+ require "pluck_map/errors"
3
+ require "pluck_map/nodes"
2
4
  require "pluck_map/presenters"
5
+ require "pluck_map/visitors"
3
6
  require "active_record"
4
7
 
5
8
  module PluckMap
6
9
  class Presenter
7
10
  include CsvPresenter, HashPresenter, JsonPresenter
8
11
 
9
- attr_reader :model, :attributes
12
+ attr_reader :model, :attributes, :query
10
13
 
11
- def initialize(model = nil, attributes = nil, &block)
12
- if block_given?
13
- puts "DEPRECATION WARNING: `PluckMap::Presenter.new` will be deprecated. Use `PluckMap[Model].define` instead."
14
- @attributes = PluckMap::AttributeBuilder.build(model: nil, &block)
15
- else
16
- @model = model
17
- @attributes = attributes
18
- end
19
-
20
- if respond_to?(:define_presenters!, true)
21
- puts "DEPRECATION WARNING: `define_presenters!` is deprecated; instead mix in a module that implements your presenter method (e.g. `to_h`). Optionally have the method redefine itself the first time it is called."
22
- # because overridden `define_presenters!` will probably call `super`
23
- PluckMap::Presenter.class_eval 'protected def define_presenters!; end'
24
- define_presenters!
14
+ def initialize(model, attributes, query)
15
+ unless query.model <= model
16
+ raise ArgumentError, "Query for #{query.model} but #{model} expected"
25
17
  end
26
- end
27
18
 
28
- def no_map?
29
- puts "DEPRECATION WARNING: `PluckMap::Presenter#no_map?` is deprecated. You can replace it with `!attributes.will_map?`"
30
- !attributes.will_map?
19
+ @model = model
20
+ @attributes = attributes
21
+ @query = query
31
22
  end
32
23
 
33
24
  protected
34
25
 
35
- def pluck(query)
36
- unless model.nil? || query.model <= model
37
- raise ArgumentError, "Query for #{query.model} but #{model} expected"
38
- end
39
-
26
+ def pluck
40
27
  # puts "\e[95m#{query.select(*selects).to_sql}\e[0m"
41
28
  results = benchmark("pluck(#{query.table_name})") { query.pluck(*selects) }
42
29
  return results unless block_given?
30
+ attributes.each do |attribute|
31
+ attribute.preload!(results)
32
+ end
43
33
  benchmark("map(#{query.table_name})") { yield results }
44
34
  end
45
35
 
@@ -81,6 +71,7 @@ module PluckMap
81
71
 
82
72
  # On Rails 4.2, `pluck` can't accept Arel nodes
83
73
  select = Arel.sql(select.to_sql) if ActiveRecord.version.segments.take(2) == [4,2] && select.respond_to?(:to_sql)
74
+
84
75
  select
85
76
  }
86
77
  end
@@ -89,10 +80,5 @@ module PluckMap
89
80
  attributes.by_id
90
81
  end
91
82
 
92
- def keys
93
- puts "DEPRECATION WARNING: PluckMap::Presenter#keys is deprecated; use #selects instead"
94
- selects
95
- end
96
-
97
83
  end
98
84
  end
@@ -1,9 +1,21 @@
1
+ require "pluck_map/errors"
2
+
1
3
  module PluckMap
2
4
  module CsvPresenter
3
5
 
4
- def to_csv(query)
6
+ def self.included(base)
7
+ def base.to_csv(query, **kargs)
8
+ new(query).to_csv(**kargs)
9
+ end
10
+ end
11
+
12
+ def to_csv
13
+ if attributes.nested?
14
+ raise PluckMap::UnsupportedAttributeError, "to_csv can not be used to present nested attributes"
15
+ end
16
+
5
17
  define_to_csv!
6
- to_csv(query)
18
+ to_csv
7
19
  end
8
20
 
9
21
  private def define_to_csv!
@@ -11,8 +23,8 @@ module PluckMap
11
23
 
12
24
  headers = CSV.generate_line(attributes.map(&:name))
13
25
  ruby = <<-RUBY
14
- def to_csv(query)
15
- pluck(query) do |results|
26
+ def to_csv
27
+ pluck do |results|
16
28
  rows = [#{headers.inspect}]
17
29
  results.each_with_object(rows) do |values, rows|
18
30
  values = Array(values)
@@ -1,15 +1,21 @@
1
1
  module PluckMap
2
2
  module HashPresenter
3
3
 
4
- def to_h(query)
4
+ def self.included(base)
5
+ def base.to_h(query, **kargs)
6
+ new(query).to_h(**kargs)
7
+ end
8
+ end
9
+
10
+ def to_h
5
11
  define_to_h!
6
- to_h(query)
12
+ to_h
7
13
  end
8
14
 
9
15
  private def define_to_h!
10
16
  ruby = <<-RUBY
11
- def to_h(query)
12
- pluck(query) do |results|
17
+ def to_h
18
+ pluck do |results|
13
19
  results.map { |values| values = Array(values); { #{attributes.map { |attribute| "#{attribute.name.inspect} => #{attribute.to_ruby}" }.join(", ")} } }
14
20
  end
15
21
  end
@@ -3,12 +3,88 @@ require "json"
3
3
  module PluckMap
4
4
  module JsonPresenter
5
5
 
6
- def to_json(query, json: default_json, **)
7
- json.dump(to_h(query))
6
+ def self.included(base)
7
+ def base.to_json(query, **kargs)
8
+ new(query).to_json(**kargs)
9
+ end
10
+ end
11
+
12
+ def to_json(json: default_json, **)
13
+ if attributes.will_map?
14
+ to_json__default(json: json)
15
+ else
16
+ to_json__optimized
17
+ end
8
18
  end
9
19
 
10
20
  private
11
21
 
22
+ def to_json__default(json: default_json, **)
23
+ json.dump(to_h)
24
+ end
25
+
26
+ def to_json__optimized(**)
27
+ define_to_json__optimized!
28
+ to_json__optimized
29
+ end
30
+
31
+ def define_to_json__optimized!
32
+ sql = compile(to_json_object(attributes).as("object"))
33
+
34
+ ruby = <<-RUBY
35
+ private def to_json__optimized(**)
36
+ sql = wrap_aggregate(query.select(Arel.sql(#{sql.inspect})))
37
+ query.connection.select_value(sql)
38
+ end
39
+ RUBY
40
+ # puts "\e[34m#{ruby}\e[0m" # <-- helps debugging PluckMapPresenter
41
+ class_eval ruby, __FILE__, __LINE__ - ruby.length
42
+ end
43
+
44
+ def to_json_object(attributes)
45
+ args = []
46
+ attributes.each do |attribute|
47
+ args << Arel::Nodes::Quoted.new(attribute.name)
48
+ args << send(:"prepare_#{attribute.class.name.gsub("::", "_")}", attribute)
49
+ end
50
+ PluckMap::BuildJsonObject.new(*args)
51
+ end
52
+
53
+ def prepare_PluckMap_Attribute(attribute)
54
+ return Arel::Nodes::Quoted.new(attribute.value) if attribute.value?
55
+ arg = attribute.selects[0]
56
+ arg = attribute.model.arel_table[arg] if arg.is_a?(Symbol)
57
+ arg
58
+ end
59
+
60
+ def prepare_PluckMap_StructuredAttribute(attribute)
61
+ to_json_object(attribute.attributes)
62
+ end
63
+
64
+ def prepare_PluckMap_Relationships_Many(attribute)
65
+ PluckMap::JsonSubqueryAggregate.new(attribute.scope, to_json_object(attribute.attributes))
66
+ end
67
+
68
+ def prepare_PluckMap_Relationships_One(attribute)
69
+ Arel.sql("(#{attribute.scope.select(to_json_object(attribute.attributes)).to_sql})")
70
+ end
71
+
72
+ def wrap_aggregate(subquery)
73
+ "SELECT #{compile(aggregate(Arel.sql("d.object")))} FROM (#{subquery.to_sql}) AS d"
74
+ end
75
+
76
+ def aggregate(object)
77
+ PluckMap::JsonArrayAggregate.new(object)
78
+ end
79
+
80
+ def compile(node)
81
+ visitor.compile(node)
82
+ end
83
+
84
+ def visitor
85
+ model.connection.visitor
86
+ end
87
+
12
88
  def default_json
13
89
  if defined?(MultiJson)
14
90
  MultiJson
@@ -0,0 +1,89 @@
1
+ require "pluck_map/association_scope"
2
+ require "pluck_map/relationships/base"
3
+ require "pluck_map/relationships/many"
4
+ require "pluck_map/relationships/one"
5
+ require "pluck_map/relationships/polymorphic_one"
6
+
7
+ module PluckMap
8
+ module Relationships
9
+ class << self
10
+
11
+ def one(model, name, block, options)
12
+ reflection = reflection_for(model, name)
13
+ if reflection.polymorphic?
14
+ Relationships::PolymorphicOne.new(name, reflection, block, options)
15
+ else
16
+ Relationships::One.new(name, scope_for_reflection(reflection), block, options)
17
+ end
18
+ end
19
+
20
+ def many(model, name, block, options)
21
+ Relationships::Many.new(name, scope(model, name), block, options)
22
+ end
23
+
24
+ private
25
+
26
+ def scope(model, name)
27
+ scope_for_reflection(reflection_for(model, name))
28
+ end
29
+
30
+ def scope_for_reflection(reflection)
31
+ scope_for(association_for(reflection))
32
+ end
33
+
34
+ def reflection_for(model, name)
35
+ # Use `_reflections.fetch(name)` instead of `reflect_on_association(name)`
36
+ # because they have different behavior when it comes to HasAndBelongsToMany
37
+ # associations.
38
+ #
39
+ # `reflect_on_association` will return a HasAndBelongsToManyReflection
40
+ # while `_reflections.fetch(name)` will return a ThroughReflection that
41
+ # wraps a HasAndBelongsToManyReflection.
42
+ #
43
+ # ActiveRecord::Associations::AssociationScope expects the latter.
44
+ #
45
+ model._reflections.fetch(name.to_s) do
46
+ raise ArgumentError, "#{name} is not an association on #{model}"
47
+ end
48
+ end
49
+
50
+ def association_for(reflection)
51
+ owner = AbstractOwner.new(reflection)
52
+ reflection.association_class.new(owner, reflection)
53
+ end
54
+
55
+ def scope_for(association)
56
+ default_scope_for(association).merge(AssociationScope.create.scope(association))
57
+ end
58
+
59
+ def default_scope_for(association)
60
+ association.klass.all
61
+ end
62
+
63
+ # ActiveRecord constructs an Association from a Reflection and an
64
+ # Owner. It expects Owner to be an instance of an ActiveRecord object
65
+ # and uses `[]` to access specific values for fields on the record.
66
+ #
67
+ # e.g. WHERE books.author_id = 7
68
+ #
69
+ # We want to create a subquery that will reference those fields
70
+ # but not their specific values.
71
+ #
72
+ # e.g. WHERE books.author_id = authors.id
73
+ #
74
+ # So we create an object that serves the purpose of Owner but returns
75
+ # appropriate selectors.
76
+ #
77
+ AbstractOwner = Struct.new(:reflection) do
78
+ def class
79
+ reflection.active_record
80
+ end
81
+
82
+ def [](value)
83
+ self.class.arel_table[value]
84
+ end
85
+ end
86
+
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,26 @@
1
+ require "pluck_map/structured_attribute"
2
+
3
+ module PluckMap
4
+ module Relationships
5
+ class Base < StructuredAttribute
6
+ attr_reader :scope
7
+
8
+ def initialize(attribute_name, scope, block, options)
9
+ @scope = scope
10
+ @scope = @scope.instance_exec(&options[:scope_block]) if options[:scope_block]
11
+ super(attribute_name, scope.klass, block, options)
12
+ end
13
+
14
+ protected
15
+
16
+ def build_select
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def build_map
21
+ raise NotImplementedError
22
+ end
23
+
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ require "active_record/version"
2
+ require "pluck_map/attribute"
3
+
4
+ module PluckMap
5
+ module Relationships
6
+ class Many < Base
7
+ protected
8
+
9
+ def build_select
10
+ node = PluckMap::JsonSubqueryAggregate.new(scope, attributes.to_json_array)
11
+
12
+ # On Rails 4.2, `pluck` can't accept Arel nodes
13
+ if ActiveRecord.version.segments.take(2) == [4,2]
14
+ Arel.sql(scope.connection.visitor.compile(node))
15
+ else
16
+ node
17
+ end
18
+ end
19
+
20
+ def build_map
21
+ lambda do |results|
22
+ return [] if results.nil?
23
+ results = JSON.parse(results) if results.is_a?(String)
24
+ results.map do |values|
25
+ attributes.each_with_object({}) do |attribute, hash|
26
+ hash[attribute.name] = attribute.exec(values)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,24 @@
1
+ require "pluck_map/attribute"
2
+
3
+ module PluckMap
4
+ module Relationships
5
+ class One < Base
6
+ protected
7
+
8
+ def build_select
9
+ Arel.sql("(#{scope.select(attributes.to_json_array).to_sql})")
10
+ end
11
+
12
+ def build_map
13
+ lambda do |values|
14
+ return nil if values.nil?
15
+ values = JSON.parse(values) if values.is_a?(String)
16
+ attributes.each_with_object({}) do |attribute, hash|
17
+ hash[attribute.name] = attribute.exec(values)
18
+ end
19
+ end
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ require "pluck_map/attribute"
2
+
3
+ module PluckMap
4
+ module Relationships
5
+ class PolymorphicOne < Attribute
6
+
7
+ def initialize(attribute_name, reflection, block, options)
8
+ @reflection = reflection
9
+ @attributes_block = block
10
+ @scope_block = options[:scope_block]
11
+
12
+ options = options.slice(:as).merge(
13
+ select: [ reflection.foreign_type.to_sym, reflection.foreign_key.to_sym ],
14
+ map: ->(*args) { @preloads[args] })
15
+
16
+ super(attribute_name, reflection.active_record, options)
17
+ end
18
+
19
+ def nested?
20
+ true
21
+ end
22
+
23
+ def preload!(results)
24
+ ids_by_type = Hash.new { |hash, key| hash[key] = [] }
25
+
26
+ results.each do |values|
27
+ type, id = values.values_at(*indexes)
28
+ ids_by_type[type].push(id)
29
+ end
30
+
31
+ @preloads = Hash.new
32
+ ids_by_type.each do |type, ids|
33
+ klass = type.constantize
34
+ scope = klass.where(id: ids)
35
+ scope = scope.instance_exec(&@scope_block) if @scope_block
36
+
37
+ presenter = PluckMap[klass].define do |q|
38
+ q.__id select: klass.primary_key.to_sym
39
+
40
+ if @attributes_block.arity == 1
41
+ @attributes_block.call(q)
42
+ else
43
+ q.instance_eval(&@attributes_block)
44
+ end
45
+ end
46
+
47
+ presenter.to_h(scope).each do |h|
48
+ id = h.delete(:__id)
49
+ @preloads[[type, id]] = h
50
+ end
51
+ end
52
+
53
+ nil
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,13 @@
1
+ module PluckMap
2
+ module Struct
3
+ module ClassMethods
4
+ def presenter
5
+ @presenter || superclass.presenter
6
+ end
7
+
8
+ def load(relation)
9
+ presenter.to_h(relation).map { |values| new(**values) }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ require "pluck_map/attribute"
2
+
3
+ module PluckMap
4
+ class StructuredAttribute < Attribute
5
+ attr_reader :attributes
6
+
7
+ def initialize(attribute_name, model, block, options={})
8
+ @attributes = AttributeBuilder.build(model: model, &block)
9
+ options = options.slice(:as).merge(select: build_select, map: build_map)
10
+ super(attribute_name, model, options)
11
+ end
12
+
13
+ def will_map?
14
+ attributes.any?(&:will_map?)
15
+ end
16
+
17
+ def nested?
18
+ true
19
+ end
20
+
21
+ protected
22
+
23
+ def build_select
24
+ attributes.selects
25
+ end
26
+
27
+ def build_map
28
+ lambda do |*values|
29
+ return nil if values.none?
30
+ attributes.each_with_object({}) do |attribute, hash|
31
+ hash[attribute.name] = attribute.exec(values)
32
+ end
33
+ end
34
+ end
35
+
36
+ end
37
+ end
@@ -1,3 +1,3 @@
1
1
  module PluckMapPresenter
2
- VERSION = "0.6.2"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -0,0 +1,4 @@
1
+ require "arel"
2
+ require "pluck_map/visitors/sqlite"
3
+ require "pluck_map/visitors/postgresql"
4
+ require "pluck_map/visitors/mysql"
@@ -0,0 +1,36 @@
1
+ require "arel/visitors/mysql"
2
+
3
+ module Arel
4
+ module Visitors
5
+ class MySQL
6
+ def visit_PluckMap_BuildJsonObject(o, collector)
7
+ collector << "json_object("
8
+ visit o.args, collector
9
+ collector << ")"
10
+ end
11
+
12
+ def visit_PluckMap_BuildJsonArray(o, collector)
13
+ collector << "json_array("
14
+ visit o.args, collector
15
+ collector << ")"
16
+ end
17
+
18
+ def visit_PluckMap_JsonArrayAggregate(o, collector)
19
+ collector << "json_arrayagg("
20
+ visit o.arg, collector
21
+ collector << ")"
22
+ end
23
+
24
+ def visit_PluckMap_JsonSubqueryAggregate(o, collector)
25
+ interior = compile(o.select)
26
+ if o.scope.order_values.present?
27
+ interior = "#{interior} ORDER BY #{compile(o.scope.order_values)}"
28
+ end
29
+ interior = "CAST(CONCAT('[',GROUP_CONCAT(#{interior}),']') AS JSON)"
30
+ sql = o.scope.reorder(nil).select(Arel.sql(interior)).to_sql
31
+
32
+ collector << "COALESCE((#{sql}), json_array())"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+ require "arel/visitors/postgresql"
2
+
3
+ module Arel
4
+ module Visitors
5
+ class PostgreSQL
6
+ def visit_PluckMap_BuildJsonObject(o, collector)
7
+ collector << "json_build_object("
8
+ visit o.args, collector
9
+ collector << ")"
10
+ end
11
+
12
+ def visit_PluckMap_BuildJsonArray(o, collector)
13
+ collector << "json_build_array("
14
+ visit o.args, collector
15
+ collector << ")"
16
+ end
17
+
18
+ def visit_PluckMap_JsonArrayAggregate(o, collector)
19
+ collector << "json_agg("
20
+ visit o.arg, collector
21
+ collector << ")"
22
+ end
23
+
24
+ def visit_PluckMap_JsonSubqueryAggregate(o, collector)
25
+ sql = o.scope.select(o.select.as("object")).to_sql
26
+ collector << "COALESCE((SELECT json_agg(d.object) FROM (#{sql}) AS d), json_build_array())"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ require "arel/visitors/sqlite"
2
+
3
+ module Arel
4
+ module Visitors
5
+ class SQLite
6
+ def visit_PluckMap_BuildJsonObject(o, collector)
7
+ collector << "json_object("
8
+ visit o.args, collector
9
+ collector << ")"
10
+ end
11
+
12
+ def visit_PluckMap_BuildJsonArray(o, collector)
13
+ collector << "json_array("
14
+ visit o.args, collector
15
+ collector << ")"
16
+ end
17
+
18
+ def visit_PluckMap_JsonArrayAggregate(o, collector)
19
+ collector << "json_group_array(json("
20
+ visit o.arg, collector
21
+ collector << "))"
22
+ end
23
+
24
+ def visit_PluckMap_JsonSubqueryAggregate(o, collector)
25
+ sql = o.scope.select(o.select.as("object")).to_sql
26
+ collector << "COALESCE((SELECT json_group_array(json(d.object)) FROM (#{sql}) AS d), json_array())"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -17,11 +17,14 @@ Gem::Specification.new do |spec|
17
17
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
18
  spec.require_paths = ["lib"]
19
19
 
20
+ # uses `keyword_init` argument of `Struct.new`
21
+ spec.required_ruby_version = '>= 2.5.0'
22
+
20
23
  spec.add_dependency "activerecord", ">= 4.2"
21
24
 
22
25
  spec.add_development_dependency "appraisal"
23
26
  spec.add_development_dependency "bundler"
24
- spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "rake"
25
28
  spec.add_development_dependency "minitest-reporters"
26
29
  spec.add_development_dependency "minitest-reporters-turn_reporter"
27
30
  spec.add_development_dependency "sqlite3"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pluck_map
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bob Lail
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-06-14 00:00:00.000000000 Z
11
+ date: 2020-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -56,16 +56,16 @@ dependencies:
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '10.0'
61
+ version: '0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '10.0'
68
+ version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: minitest-reporters
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -255,19 +255,34 @@ files:
255
255
  - gemfiles/rails_5.0.gemfile
256
256
  - gemfiles/rails_5.1.gemfile
257
257
  - gemfiles/rails_5.2.gemfile
258
+ - gemfiles/rails_6.0.gemfile
258
259
  - gemfiles/rails_edge.gemfile
259
260
  - lib/pluck_map.rb
261
+ - lib/pluck_map/association_scope.rb
260
262
  - lib/pluck_map/attribute.rb
261
263
  - lib/pluck_map/attribute_builder.rb
262
264
  - lib/pluck_map/attributes.rb
265
+ - lib/pluck_map/errors.rb
263
266
  - lib/pluck_map/model_context.rb
267
+ - lib/pluck_map/nodes.rb
264
268
  - lib/pluck_map/null_logger.rb
265
269
  - lib/pluck_map/presenter.rb
266
270
  - lib/pluck_map/presenters.rb
267
271
  - lib/pluck_map/presenters/to_csv.rb
268
272
  - lib/pluck_map/presenters/to_h.rb
269
273
  - lib/pluck_map/presenters/to_json.rb
274
+ - lib/pluck_map/relationships.rb
275
+ - lib/pluck_map/relationships/base.rb
276
+ - lib/pluck_map/relationships/many.rb
277
+ - lib/pluck_map/relationships/one.rb
278
+ - lib/pluck_map/relationships/polymorphic_one.rb
279
+ - lib/pluck_map/struct.rb
280
+ - lib/pluck_map/structured_attribute.rb
270
281
  - lib/pluck_map/version.rb
282
+ - lib/pluck_map/visitors.rb
283
+ - lib/pluck_map/visitors/mysql.rb
284
+ - lib/pluck_map/visitors/postgresql.rb
285
+ - lib/pluck_map/visitors/sqlite.rb
271
286
  - pluck_map.gemspec
272
287
  homepage: https://github.com/boblail/pluck_map
273
288
  licenses: []
@@ -280,15 +295,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
280
295
  requirements:
281
296
  - - ">="
282
297
  - !ruby/object:Gem::Version
283
- version: '0'
298
+ version: 2.5.0
284
299
  required_rubygems_version: !ruby/object:Gem::Requirement
285
300
  requirements:
286
301
  - - ">="
287
302
  - !ruby/object:Gem::Version
288
303
  version: '0'
289
304
  requirements: []
290
- rubyforge_project:
291
- rubygems_version: 2.7.6
305
+ rubygems_version: 3.1.4
292
306
  signing_key:
293
307
  specification_version: 4
294
308
  summary: A DSL for presenting ActiveRecord::Relations without instantiating ActiveRecord