pluck_map 0.6.2 → 2.0.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.
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