active_model_serializers_pg 0.0.2 → 0.0.7

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: 4bdfe8c6e1f9a0fbca6e444a7594474d43d9415008c86f6fe30964088fc8c1f7
4
- data.tar.gz: 0e5a9f884cda2663bf2f36051ad42367381ea5f61798048cd4ae774c19c76265
3
+ metadata.gz: 709fe0bce48637cbd10e3f8a4cc66d9332da0a36ea2158153711c2f49bcdc82f
4
+ data.tar.gz: bbc3e6c16daf9b8c28ce8d105d1dc355c35b1188b935051447c6b5fab96acda0
5
5
  SHA512:
6
- metadata.gz: 44df1b79df5306e323a62c6fd0a6d0b52e6f2da6da8d6a6eb494fb440a9e09909ecd476cf563fa873a42bddedc40dbdc0efefb95cf13982ca69684b11c93050d
7
- data.tar.gz: d08476c02adb38cb0978910b0d2448c0f09019ba52592132105612846e51d883d5ab0f26c6ad0f3eaf66c3757f3a93ab59831bfb6652459d47cdf8de37ba342d
6
+ metadata.gz: c323f581ecea8145344d4fe95df91a76d548762de320a43513eb7081842d30b28e81fb33a4adc3cca173964d17ce5ba7c384e27043cf9aca68e0ca161b9b83b9
7
+ data.tar.gz: 076e6dad498e0d0533fe63f4a396099c61557c7b2a05a17ebe910f2caca61b11d4cf005351570e7a4579447e9c427ff7298b759f35a15bfe27749144bee44292
data/README.md CHANGED
@@ -31,6 +31,13 @@ Or install it yourself as:
31
31
 
32
32
  gem install active_model_serializers_pg
33
33
 
34
+ ### Migrations
35
+
36
+ This gem depends on a SQL function to dasherize hstore/json/jsonb columns, so you must add its migration to your project and run it, like this:
37
+
38
+ rails g active_model_serializers_pg
39
+ rake db:migrate
40
+
34
41
  ## Usage
35
42
 
36
43
  You can enable in-Postgres serialization for everything by putting this in a Rails initializer:
@@ -52,8 +59,10 @@ Here are some other details we support:
52
59
  - `belongs_to`, `has_one`, and `has_many` associations.
53
60
  - If you serialize an `enum` you get the string values, not integers.
54
61
  - You can serialize an `alias`'d association.
62
+ - You can serialize an `alias_attribute`'d column.
55
63
  - We preserve SQL ordering from a model's `default_scope`.
56
64
  - We preserve SQL ordering attached to an association.
65
+ - When dasherizing we also dasherize json/jsonb/hstore contents (like standard AMS).
57
66
 
58
67
  ### Methods in Serializers and Models
59
68
 
data/Rakefile CHANGED
@@ -72,9 +72,17 @@ namespace :db do
72
72
  task :migrate => :load_db_settings do
73
73
  ActiveRecord::Base.establish_connection
74
74
 
75
+ ActiveRecord::Base.connection.execute "CREATE EXTENSION hstore"
76
+
75
77
  ActiveRecord::Base.connection.create_table :people, force: true do |t|
76
78
  t.string "first_name"
77
79
  t.string "last_name"
80
+ t.json "options"
81
+ t.jsonb "prefs"
82
+ t.hstore "settings"
83
+ t.json "selfies", array: true
84
+ t.jsonb "portraits", array: true
85
+ t.hstore "landscapes", array: true
78
86
  t.datetime "created_at"
79
87
  t.datetime "updated_at"
80
88
  end
@@ -131,6 +139,8 @@ namespace :db do
131
139
  t.datetime "updated_at"
132
140
  end
133
141
 
142
+ ActiveRecord::Base.connection.execute File.read(File.expand_path('../lib/generators/active_record/templates/jsonb_dasherize.sql', __FILE__))
143
+
134
144
  puts 'Database migrated'
135
145
  end
136
146
  end
@@ -25,6 +25,8 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency 'actionpack', '> 4.0'
26
26
  spec.add_development_dependency 'rake'
27
27
  spec.add_development_dependency 'rspec'
28
+ spec.add_development_dependency 'rspec-rails'
29
+ spec.add_development_dependency 'generator_spec'
28
30
  spec.add_development_dependency 'bourne', '~> 1.3.0'
29
31
  spec.add_development_dependency 'database_cleaner'
30
32
  spec.add_development_dependency 'dotenv'
@@ -2,4 +2,4 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem "activerecord", "~>5.0.0"
5
+ gem "rails", "~>5.0.0"
@@ -2,4 +2,4 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem "activerecord", "~>5.1.0"
5
+ gem "rails", "~>5.1.0"
@@ -2,4 +2,4 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec :path => '..'
4
4
 
5
- gem "activerecord", "~>5.2.0"
5
+ gem "rails", "~>5.2.0"
@@ -8,7 +8,7 @@ module ActiveModel
8
8
  class Serializer
9
9
  class CollectionSerializer
10
10
  def element_serializer
11
- options[:serializer]
11
+ options && options[:serializer]
12
12
  end
13
13
  end
14
14
  end
@@ -23,7 +23,16 @@ module ActiveModelSerializers
23
23
  end
24
24
 
25
25
  def to_json(options={})
26
- connection.select_value serializer_sql
26
+ if relation.nil?
27
+ ret = { data: [] }
28
+ if includes.any?
29
+ # TODO: Can included ever be non-empty when the main data is empty?
30
+ ret[:included] = []
31
+ end
32
+ ret.to_json
33
+ else
34
+ connection.select_value serializer_sql
35
+ end
27
36
  end
28
37
 
29
38
  def relation
@@ -32,6 +41,10 @@ module ActiveModelSerializers
32
41
 
33
42
  private
34
43
 
44
+ def includes
45
+ instance_options && instance_options[:include] || []
46
+ end
47
+
35
48
  def connection
36
49
  @connection ||= relation.connection
37
50
  end
@@ -42,8 +55,12 @@ module ActiveModelSerializers
42
55
  when ActiveRecord::Relation
43
56
  o
44
57
  when Array
45
- # TODO: determine what class it is, even if the array is empty
46
- o.first.class.where(id: o.map(&:id))
58
+ o2 = o.first
59
+ if o2.nil?
60
+ nil
61
+ else
62
+ o2.class.where(id: o.map(&:id))
63
+ end
47
64
  when ActiveRecord::Base
48
65
  o.class.where(id: o.id)
49
66
  else
@@ -60,7 +77,7 @@ module ActiveModelSerializers
60
77
  end
61
78
 
62
79
  def self.warn_about_collection_serializer
63
- msg = "You are using an ordinary AMS CollectionSerializer with the json_api_pg adapter, which probably means Rails is pointlessly loading all your ActiveRecord instances *and* running the build JSON-building query in Postgres."
80
+ msg = "You are using an ordinary AMS CollectionSerializer with the json_api_pg adapter, which probably means Rails is pointlessly loading all your ActiveRecord instances *and* running the JSON-building query in Postgres."
64
81
  if Object.const_defined? 'Rails'
65
82
  Rails.logger.warn msg
66
83
  else
@@ -88,11 +105,11 @@ end
88
105
  # i.e. how you got here, not how you'd leave:
89
106
  # "Reflection" seems to be the internal ActiveRecord lingo
90
107
  # for a belongs_to or has_many relationship.
91
- # (The public documentation calls these "associations",
92
- # I think think older versions of Rails even used that internally,
108
+ # (The public documentation calls these "associations".
109
+ # I think older versions of Rails even used that internally,
93
110
  # but nowadays the method names use "reflection".)
94
111
  class JsonThing
95
- attr_reader :ar_class, :full_name, :name, :serializer, :serializer_options, :json_key, :json_type, :reflection, :parent, :cte_name
112
+ attr_reader :ar_class, :full_name, :name, :serializer, :serializer_options, :json_key, :json_type, :reflection, :parent, :cte_name, :jbs_name
96
113
  delegate :table_name, :primary_key, to: :ar_class
97
114
  delegate :foreign_key, :belongs_to?, :has_many?, :has_one?, to: :reflection
98
115
 
@@ -113,6 +130,7 @@ class JsonThing
113
130
  @parent = parent_json_thing
114
131
 
115
132
  @cte_name = _cte_name
133
+ @jbs_name = _jbs_name
116
134
  @sql_methods = {}
117
135
  end
118
136
 
@@ -123,26 +141,17 @@ class JsonThing
123
141
  JsonThing.new(refl.klass, "#{full_name}.#{reflection_name}", nil, serializer_options, refl, self)
124
142
  end
125
143
 
126
- # Gets the attributes (i.e. scalar fields) on the AR class
127
- # as a Set of symbols.
128
- # TODO: tests
129
- def declared_attributes
130
- @declared_attributes ||= Set.new(@ar_class.attribute_types.keys.map(&:to_sym))
131
- end
132
-
133
144
  def enum?(field)
134
145
  @ar_class.attribute_types[field.to_s].is_a? ActiveRecord::Enum::EnumType
135
146
  end
136
147
 
137
- # Gets the reflections (aka associations) of the AR class
138
- # as a Hash from symbol to a subclass of ActiveRecord::Reflection.
139
- # TODO: tests
140
- def declared_reflections
141
- @declared_reflections ||= Hash[
142
- @ar_class.reflections.map{|k, v|
143
- [k.to_sym, v]
144
- }
145
- ]
148
+ # Checks for alias_attribute and gets to the real attribute name.
149
+ def unaliased(field_name)
150
+ ret = field_name
151
+ while field_name = @ar_class.attribute_aliases[field_name.to_s]
152
+ ret = field_name
153
+ end
154
+ ret
146
155
  end
147
156
 
148
157
  # TODO: tests
@@ -179,10 +188,38 @@ class JsonThing
179
188
  if parent.nil?
180
189
  't'
181
190
  else
182
- "cte_#{Digest::SHA256.hexdigest(full_name).first(10)}"
191
+ "cte_#{_cte_name_human_part}_#{Digest::SHA256.hexdigest(full_name).first(10)}"
183
192
  end
184
193
  end
185
194
 
195
+ # Each thing has both a `cte_foo` CTE and a `jbs_foo` CTE.
196
+ # (jbs stands for "JSONBs" and is meant to take 3 chars like `cte`.)
197
+ # The former is just the relevant records,
198
+ # and the second builds the JSON object for each record.
199
+ # We need to split things into phases like this
200
+ # because of the JSON:API `relationships` item,
201
+ # which can contain references in *both directions*.
202
+ # In that case Postgres will object to our circular dependency.
203
+ # But with two phases, every jbs_* CTE only depends on cte_* CTEs.
204
+ def _jbs_name
205
+ if parent.nil?
206
+ 't'
207
+ else
208
+ "jbs_#{_cte_name_human_part}_#{Digest::SHA256.hexdigest(full_name).first(10)}"
209
+ end
210
+ end
211
+
212
+ # Gets a more informative name for the CTE based on the include key.
213
+ # This makes reading the big SQL query easier, especially for debugging.
214
+ # Note that Postgres's max identifier length is 63 chars (unless you compile yourself),
215
+ # and we are spending 4+4+11=19 chars elsewhere on `rel_cte_XXX_1234567890`.
216
+ # So this method can't return more than 63-19=44 chars.
217
+ #
218
+ # Since we quote the CTE names, we don't actually need to remove dots in the name!
219
+ def _cte_name_human_part
220
+ @cte_name_human_part ||= full_name[0, 44]
221
+ end
222
+
186
223
  def _sql_method(field)
187
224
  m = "#{field}__sql".to_sym
188
225
  if ar_class.respond_to?(m)
@@ -321,6 +358,15 @@ end
321
358
  class JsonApiPgSql
322
359
  attr_reader :base_serializer, :base_relation
323
360
 
361
+ def self.json_column_type
362
+ # These classes may not exist, depending on the Rails version:
363
+ @@json_column_type = if Rails::VERSION::STRING >= '5.2'
364
+ 'ActiveRecord::Type::Json'
365
+ else
366
+ 'ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json'
367
+ end.constantize
368
+ end
369
+
324
370
  def initialize(base_serializer, base_relation, instance_options, options)
325
371
  @base_relation = base_relation
326
372
  @instance_options = instance_options
@@ -397,10 +443,54 @@ class JsonApiPgSql
397
443
  elsif resource.has_sql_method?(field)
398
444
  resource.sql_method(field)
399
445
  else
400
- %Q{"#{resource.table_name}"."#{field}"}
446
+ field = resource.unaliased(field)
447
+ # Standard AMS dasherizes json/jsonb/hstore columns,
448
+ # so we have to do the same:
449
+ if ActiveModelSerializers.config.key_transform == :dash
450
+ cl = resource.ar_class.attribute_types[field.to_s]
451
+ if column_is_jsonb? cl
452
+ %Q{jsonb_dasherize("#{resource.table_name}"."#{field}")}
453
+ elsif column_is_jsonb_array? cl
454
+ # TODO: Could be faster:
455
+ # If we made the jsonb_dasherize function smarter so it could handle jsonb[],
456
+ # we wouldn't have to build a json object from the array then cast to jsonb[].
457
+ %Q{jsonb_dasherize(array_to_json("#{resource.table_name}"."#{field}")::jsonb)}
458
+ elsif column_is_castable_to_jsonb? cl
459
+ # Fortunately we can cast hstore to jsonb,
460
+ # which gives us a solution that works whether or not the hstore extension is installed.
461
+ # Defining an hstore_dasherize function would work only if the extension were present.
462
+ %Q{jsonb_dasherize("#{resource.table_name}"."#{field}"::jsonb)}
463
+ elsif column_is_castable_to_jsonb_array? cl
464
+ # TODO: Could be faster (see above):
465
+ %Q{jsonb_dasherize(array_to_json("#{resource.table_name}"."#{field}"::jsonb[])::jsonb)}
466
+ else
467
+ %Q{"#{resource.table_name}"."#{field}"}
468
+ end
469
+ else
470
+ %Q{"#{resource.table_name}"."#{field}"}
471
+ end
401
472
  end
402
473
  end
403
474
 
475
+ def column_is_jsonb?(column_class)
476
+ column_class.is_a? ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb
477
+ end
478
+
479
+ def column_is_jsonb_array?(column_class)
480
+ column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) and
481
+ column_class.subtype.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb)
482
+ end
483
+
484
+ def column_is_castable_to_jsonb?(column_class)
485
+ column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Hstore) or
486
+ column_class.is_a?(self.class.json_column_type)
487
+ end
488
+
489
+ def column_is_castable_to_jsonb_array?(column_class)
490
+ column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) and
491
+ column_is_castable_to_jsonb?(column_class.subtype)
492
+ end
493
+
404
494
  def select_resource_relationship_links(resource, reflection)
405
495
  reflection.links.map {|link_name, link_parts|
406
496
  <<~EOQ
@@ -423,7 +513,7 @@ class JsonApiPgSql
423
513
  refl = resource.reflection
424
514
  <<~EOQ
425
515
  '#{resource.json_key}',
426
- jsonb_build_object(#{refl.include_data ? %Q{'data', rel_#{resource.cte_name}.j} : ''}
516
+ jsonb_build_object(#{refl.include_data ? %Q{'data', "rel_#{resource.cte_name}".j} : ''}
427
517
  #{refl.include_data && refl.links.any? ? ',' : ''}
428
518
  #{refl.links.any? ? %Q{'links', jsonb_build_object(#{select_resource_relationship_links(resource, refl)})} : ''})
429
519
  EOQ
@@ -521,39 +611,49 @@ class JsonApiPgSql
521
611
  # e.g. buyer and seller for User.
522
612
  # But we could group those and union just them, or even better do a DISTINCT ON (id).
523
613
  # Since we don't get the id here that could be another CTE.
524
- "UNION SELECT j FROM #{th.cte_name}"
614
+ %Q{UNION SELECT j FROM "#{th.jbs_name}"}
525
615
  }
526
616
  end
527
617
 
528
618
  def include_cte_join_condition(resource)
529
619
  parent = resource.parent
530
620
  if resource.belongs_to?
531
- %Q{#{parent.cte_name}."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
621
+ %Q{"#{parent.cte_name}"."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
532
622
  elsif resource.has_many? or resource.has_one?
533
- %Q{#{parent.cte_name}."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
623
+ %Q{"#{parent.cte_name}"."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
534
624
  else
535
625
  raise "not supported relationship: #{resource.full_name}"
536
626
  end
537
627
  end
538
628
 
629
+ # See note in _jbs_name method for why we split each thing into two CTEs.
539
630
  def include_cte(resource)
540
- # Sometimes options[:fields] has plural keys and sometimes singular,
541
- # so try both:
542
631
  parent = resource.parent
543
632
  <<~EOQ
544
633
  SELECT DISTINCT ON ("#{resource.table_name}"."#{resource.primary_key}")
545
- "#{resource.table_name}".*,
546
- #{select_resource(resource)} AS j
634
+ "#{resource.table_name}".*
547
635
  FROM "#{resource.table_name}"
548
- JOIN #{parent.cte_name}
636
+ JOIN "#{parent.cte_name}"
549
637
  ON #{include_cte_join_condition(resource)}
550
- #{join_resource_relationships(resource)}
551
638
  ORDER BY "#{resource.table_name}"."#{resource.primary_key}"
552
639
  EOQ
553
640
  end
554
641
 
642
+ # See note in _jbs_name method for why we split each thing into two CTEs.
643
+ def include_jbs(resource)
644
+ <<~EOQ
645
+ SELECT "#{resource.table_name}".*,
646
+ #{select_resource(resource)} AS j
647
+ FROM "#{resource.cte_name}" AS "#{resource.table_name}"
648
+ #{join_resource_relationships(resource)}
649
+ EOQ
650
+ end
651
+
555
652
  def includes
556
- @instance_options[:include] || []
653
+ @includes ||= (@instance_options[:include] || []).sort_by do |inc|
654
+ # Sort these by length so we never have bad foreign references in the CTEs:
655
+ inc.size
656
+ end
557
657
  end
558
658
 
559
659
  # Takes a dotted field name (not including the base resource)
@@ -572,13 +672,25 @@ class JsonApiPgSql
572
672
  # Be careful: inc might have dots:
573
673
  th = get_json_thing_from_base(inc)
574
674
  <<~EOQ
575
- #{th.cte_name} AS (
675
+ "#{th.cte_name}" AS (
576
676
  #{include_cte(th)}
577
677
  ),
578
678
  EOQ
579
679
  }.join("\n")
580
680
  end
581
681
 
682
+ def include_jbsses
683
+ includes.map { |inc|
684
+ # Be careful: inc might have dots:
685
+ th = get_json_thing_from_base(inc)
686
+ <<~EOQ
687
+ "#{th.jbs_name}" AS (
688
+ #{include_jbs(th)}
689
+ ),
690
+ EOQ
691
+ }.join("\n")
692
+ end
693
+
582
694
  def base_resource
583
695
  @json_things[:base]
584
696
  end
@@ -609,7 +721,7 @@ class JsonApiPgSql
609
721
  resource.serializer._attributes.select{|f|
610
722
  if ms.include? "include_#{f}?".to_sym
611
723
  ser = resource.serializer.new(nil, @options)
612
- ser.send("include_#{f}?".to_sym) # TODO: call the method
724
+ ser.send("include_#{f}?".to_sym)
613
725
  else
614
726
  true
615
727
  end
@@ -623,7 +735,7 @@ class JsonApiPgSql
623
735
  resource.serializer._reflections.keys.select{|f|
624
736
  if ms.include? "include_#{f}?".to_sym
625
737
  ser = resource.serializer.new(nil, @options)
626
- ser.send("include_#{f}?".to_sym) # TODO: call the method
738
+ ser.send("include_#{f}?".to_sym)
627
739
  else
628
740
  true
629
741
  end
@@ -656,6 +768,9 @@ class JsonApiPgSql
656
768
 
657
769
  def _attribute_fields_for(resource)
658
770
  attrs = Set.new(serializer_attributes(resource))
771
+ # JSON:API always excludes the `id`
772
+ # even if it's part of the serializer:
773
+ attrs = attrs - [resource.primary_key.to_sym]
659
774
  fields_for(resource).select { |f| attrs.include? f }.to_a
660
775
  end
661
776
 
@@ -687,14 +802,15 @@ class JsonApiPgSql
687
802
  #{join_resource_relationships(base_resource)}
688
803
  ),
689
804
  #{include_ctes}
690
- all_ctes AS (
805
+ #{include_jbsses}
806
+ all_jbsses AS (
691
807
  SELECT '{}'::jsonb AS j
692
808
  WHERE 1=0
693
809
  #{include_selects.join("\n")}
694
810
  ),
695
811
  inc AS (
696
812
  SELECT COALESCE(jsonb_agg(j), '[]') AS j
697
- FROM all_ctes
813
+ FROM all_jbsses
698
814
  )
699
815
  SELECT jsonb_build_object('data', t2.j
700
816
  #{maybe_included})
@@ -15,6 +15,7 @@ module ActiveModelSerializersPg
15
15
  include Enumerable
16
16
  # PATCHED: implement this below so we don't need @serializers
17
17
  # delegate :each, to: :@serializers
18
+ delegate :each, to: :serializers
18
19
 
19
20
  attr_reader :object, :root
20
21
 
@@ -22,8 +23,13 @@ module ActiveModelSerializersPg
22
23
  @object = resources
23
24
  @options = options
24
25
  @root = options[:root]
25
- # PATCHED: We don't want to iterate unless we have to:
26
- # @serializers = serializers_from_resources
26
+ # PATCHED: We don't want to materialize a Relation by iterating unless we have to.
27
+ # On the other hand, if we don't have a serializer we *do* want to `throw :no_serializer`
28
+ # right away. That should only happen for basic types (like a String or Hash),
29
+ # so we act lazy when we have a Relation, and eager otherwise:
30
+ unless resources.is_a? ActiveRecord::Relation
31
+ @serializers = serializers_from_resources
32
+ end
27
33
  end
28
34
 
29
35
  # PATCH: Give ourselves access to the serializer for the individual elements:
@@ -76,25 +82,17 @@ module ActiveModelSerializersPg
76
82
  object.respond_to?(:size)
77
83
  end
78
84
 
79
- # PATCHED: Add a replacement to `each` so we don't change the interface:
80
- def each
81
- Rails.logger.debug caller.join("\n")
82
- Enumerator.new do |y|
83
- serializers_from_resources.each do |ser|
84
- y.yield ser
85
- end
86
- end
87
- end
88
-
89
85
  protected
90
86
 
91
- attr_reader :serializers, :options
87
+ attr_reader :options
88
+
89
+ def serializers
90
+ @serializers ||= serializers_from_resources
91
+ end
92
92
 
93
93
  private
94
94
 
95
95
  def serializers_from_resources
96
- puts "options here"
97
- pp options
98
96
  serializer_context_class = options.fetch(:serializer_context_class, ActiveModel::Serializer)
99
97
  object.map do |resource|
100
98
  serializer_from_resource(resource, serializer_context_class, options)
@@ -1,3 +1,3 @@
1
1
  module ActiveModelSerializersPg
2
- VERSION = '0.0.2'
2
+ VERSION = '0.0.7'
3
3
  end
@@ -0,0 +1,20 @@
1
+ require 'rails/generators'
2
+
3
+ module ActiveModelSerializersPg
4
+ module Generators
5
+ class ActiveModelSerializersPgGenerator < Rails::Generators::NamedBase
6
+ Rails::Generators::ResourceHelpers
7
+
8
+ # The ORM generator assumes you're passing a name argument,
9
+ # but we don't need one, so we give it a default value:
10
+ argument :name, type: :string, default: "ignored"
11
+ source_root File.expand_path("../templates", __FILE__)
12
+
13
+ namespace :active_model_serializers_pg
14
+ hook_for :orm, required: true, name: "ignored"
15
+
16
+ desc "Creates an active_model_serialiers_pg database migration"
17
+
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module ActiveRecord
4
+ module Generators
5
+ class ActiveModelSerializersPgGenerator < ActiveRecord::Generators::Base
6
+ argument :name, type: :string, default: "ignored"
7
+ source_root File.expand_path("../templates", __FILE__)
8
+
9
+ def write_migration
10
+ migration_template "migration.rb", "#{migration_path}/ams_pg_create_dasherize_functions.rb"
11
+ end
12
+
13
+ private
14
+
15
+ def read_sql(funcname)
16
+ File.read(File.join(File.expand_path('../templates', __FILE__), "#{funcname}.sql"))
17
+ end
18
+
19
+ def migration_exists?(table_name)
20
+ Dir.glob("#{File.join destination_root, migration_path}/[0-9]*_*.rb").grep(/\d+_ams_pg_create_dasherize_functions.rb$/).first
21
+ end
22
+
23
+ def migration_path
24
+ if Rails.version >= '5.0.3'
25
+ db_migrate_path
26
+ else
27
+ @migration_path ||= File.join "db", "migrate"
28
+ end
29
+ end
30
+
31
+ def migration_version
32
+ if Rails.version.start_with? '5'
33
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
34
+ end
35
+ end
36
+
37
+ def jsonb_dasherize
38
+ read_sql('jsonb_dasherize')
39
+ end
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ CREATE FUNCTION jsonb_dasherize(j jsonb)
2
+ RETURNS jsonb
3
+ IMMUTABLE
4
+ AS
5
+ $$
6
+ DECLARE
7
+ t text;
8
+ ret jsonb;
9
+ BEGIN
10
+ t := jsonb_typeof(j);
11
+ IF t = 'object' THEN
12
+ SELECT COALESCE(jsonb_object_agg(REPLACE(k, '_', '-'), jsonb_dasherize(v)), '{}')
13
+ INTO ret
14
+ FROM jsonb_each(j) AS t(k, v);
15
+ RETURN ret;
16
+ ELSIF t = 'array' THEN
17
+ SELECT COALESCE(jsonb_agg(jsonb_dasherize(elem)), '[]')
18
+ INTO ret
19
+ FROM jsonb_array_elements(j) AS t(elem);
20
+ RETURN ret;
21
+ ELSIF t IS NULL THEN
22
+ -- This should never happen internally
23
+ -- (thankfully, lest jsonb_set return NULL and destroy everything),
24
+ -- but only from a passed-in NULL.
25
+ RETURN NULL;
26
+ ELSE
27
+ -- string/number/null:
28
+ RETURN j;
29
+ END IF;
30
+ END;
31
+ $$
32
+ LANGUAGE plpgsql;
@@ -0,0 +1,11 @@
1
+ class AmsPgCreateDasherizeFunctions < ActiveRecord::Migration<%= migration_version %>
2
+
3
+ def up
4
+ execute %q{<%= jsonb_dasherize %>}
5
+ end
6
+
7
+ def down
8
+ execute "DROP FUNCTION jsonb_dasherize(jsonb)"
9
+ end
10
+
11
+ end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+ require 'generators/active_model_serializers_pg/active_model_serializers_pg_generator'
3
+
4
+ describe ActiveModelSerializersPg::Generators::ActiveModelSerializersPgGenerator, type: :generator do
5
+ destination File.expand_path "../../../tmp", __FILE__
6
+
7
+ before :each do
8
+ prepare_destination
9
+ end
10
+
11
+ it "creates the migration file" do
12
+ run_generator
13
+ expect(destination_root).to have_structure {
14
+ directory "db" do
15
+ directory "migrate" do
16
+ migration "ams_pg_create_dasherize_functions" do
17
+ contains "class AmsPgCreateDasherizeFunctions"
18
+ end
19
+ end
20
+ end
21
+ }
22
+ end
23
+ end
@@ -25,7 +25,7 @@ describe 'ArraySerializer' do
25
25
  {
26
26
  id: @note.id.to_s,
27
27
  type: 'notes',
28
- attributes: {id: @note.id, name: 'Title'},
28
+ attributes: {name: 'Title'},
29
29
  relationships: {tags: {data: [{id: @tag.id.to_s, type: 'tags'}]}},
30
30
  }
31
31
  ]
@@ -65,7 +65,7 @@ describe 'ArraySerializer' do
65
65
  {
66
66
  id: person.id.to_s,
67
67
  type: 'people',
68
- attributes: { id: person.id, full_name: 'Test User', attendance_name: 'User, Test' },
68
+ attributes: { full_name: 'Test User', attendance_name: 'User, Test' },
69
69
  }
70
70
  ]
71
71
  }.to_json
@@ -80,7 +80,7 @@ describe 'ArraySerializer' do
80
80
  {
81
81
  id: person.id.to_s,
82
82
  type: 'people',
83
- attributes: {id: person.id, full_name: 'Test User', attendance_name: 'ADMIN User, Test'}
83
+ attributes: {full_name: 'Test User', attendance_name: 'ADMIN User, Test'}
84
84
  }
85
85
  ]
86
86
  }.to_json
@@ -113,6 +113,42 @@ describe 'ArraySerializer' do
113
113
  end
114
114
  end
115
115
 
116
+ context 'serialize an array instead of a relation' do
117
+ let(:relation) { [Note.where(name: 'Title').first] }
118
+ let(:controller) { NotesController.new }
119
+ let(:options) { }
120
+
121
+ before do
122
+ @note = Note.create content: 'Test', name: 'Title'
123
+ @tag = Tag.create name: 'My tag', note: @note, popular: true
124
+ end
125
+
126
+ it 'generates the proper json output' do
127
+ json_expected = {
128
+ data: [
129
+ {
130
+ id: @note.id.to_s,
131
+ type: 'notes',
132
+ attributes: { name: 'Title', content: 'Test' },
133
+ relationships: { tags: { data: [{id: @tag.id.to_s, type: 'tags'}] } }
134
+ }
135
+ ]
136
+ }.to_json
137
+ expect(json_data).to eq json_expected
138
+ end
139
+ end
140
+
141
+ context 'serialize an empty array' do
142
+ let(:relation) { [] }
143
+ let(:controller) { NotesController.new }
144
+ let(:options) { }
145
+
146
+ it 'generates the proper json output' do
147
+ json_expected = { data: [] }.to_json
148
+ expect(json_data).to eq json_expected
149
+ end
150
+ end
151
+
116
152
  context 'serialize singular record' do
117
153
  let(:relation) { Note.where(name: 'Title').first }
118
154
  let(:controller) { NotesController.new }
@@ -134,7 +170,6 @@ describe 'ArraySerializer' do
134
170
  }.to_json
135
171
  expect(json_data).to eq json_expected
136
172
  end
137
-
138
173
  end
139
174
 
140
175
  context 'with dasherized keys and types' do
@@ -174,6 +209,65 @@ describe 'ArraySerializer' do
174
209
  end
175
210
  end
176
211
 
212
+ context 'with dasherized json columns' do
213
+ let(:relation) { Person.all }
214
+ let(:controller) { PeopleController.new }
215
+ let(:person) {
216
+ Person.create first_name: 'Test',
217
+ last_name: 'User',
218
+ options: { 'foo_foo': 'baz', bar: [{ 'jar_jar': 'binks' }] },
219
+ prefs: { 'foo_foo': 'baz', bar: [{ 'jar_jar': 'binks' }] },
220
+ settings: { 'foo_foo': 'bar' },
221
+ selfies: [ { 'photo_resolution': '200x200' } ],
222
+ portraits: [ { 'photo_resolution': '150x200' } ],
223
+ landscapes: [ { 'photo_resolution': '200x150' } ]
224
+ }
225
+ let(:options) { { each_serializer: PersonWithJsonSerializer } }
226
+
227
+ before do
228
+ @old_key_setting = ActiveModelSerializers.config.key_transform
229
+ ActiveModelSerializers.config.key_transform = :dash
230
+ end
231
+
232
+ after do
233
+ ActiveModelSerializers.config.key_transform = @old_key_setting
234
+ end
235
+
236
+ it 'generates the proper json output' do
237
+ json_expected = {
238
+ data: [
239
+ {
240
+ id: person.id.to_s,
241
+ type: 'people',
242
+ attributes: {
243
+ prefs: {
244
+ bar: [{ 'jar-jar' => 'binks'}],
245
+ 'foo-foo' => 'baz',
246
+ },
247
+ options: {
248
+ bar: [{ 'jar-jar' => 'binks'}],
249
+ 'foo-foo' => 'baz',
250
+ },
251
+ selfies: [
252
+ { 'photo-resolution' => '200x200' },
253
+ ],
254
+ settings: {
255
+ 'foo-foo' => 'bar'
256
+ },
257
+ portraits: [
258
+ { 'photo-resolution' => '150x200' },
259
+ ],
260
+ landscapes: [
261
+ { 'photo-resolution' => '200x150' },
262
+ ],
263
+ },
264
+ }
265
+ ]
266
+ }.to_json
267
+ expect(json_data).to eq json_expected
268
+ end
269
+ end
270
+
177
271
  context 'with aliased association' do
178
272
  let(:relation) { Tag.first }
179
273
  let(:controller) { TagsController.new }
@@ -195,7 +289,29 @@ describe 'ArraySerializer' do
195
289
  }.to_json
196
290
  expect(json_data).to eq json_expected
197
291
  end
292
+ end
293
+
294
+ context 'with aliased attribute' do
295
+ let(:relation) { Tag.first }
296
+ let(:controller) { TagsController.new }
297
+ let(:options) { { serializer: TagWithAliasedNameSerializer } }
298
+
299
+ before do
300
+ @note = Note.create content: 'Test', name: 'Title'
301
+ @tag = Tag.create name: 'My tag', note: @note, popular: true
302
+ end
198
303
 
304
+ it 'generates the proper json output' do
305
+ json_expected = {
306
+ data: {
307
+ id: @tag.id.to_s,
308
+ type: 'tags',
309
+ attributes: { 'aliased_name' => 'My tag' },
310
+ relationships: { note: { data: {id: @note.id.to_s, type: 'notes'} } }
311
+ }
312
+ }.to_json
313
+ expect(json_data).to eq json_expected
314
+ end
199
315
  end
200
316
 
201
317
  context 'serialize single record with custom serializer' do
@@ -213,7 +329,7 @@ describe 'ArraySerializer' do
213
329
  data: {
214
330
  id: @note.id.to_s,
215
331
  type: 'notes',
216
- attributes: { id: @note.id, name: 'Title' },
332
+ attributes: { name: 'Title' },
217
333
  relationships: { tags: { data: [{id: @tag.id.to_s, type: 'tags'}] } }
218
334
  }
219
335
  }.to_json
@@ -367,7 +483,7 @@ describe 'ArraySerializer' do
367
483
  {
368
484
  id: tag.id.to_s,
369
485
  type: 'tags',
370
- attributes: { id: tag.id, name: 'My tag' },
486
+ attributes: { name: 'My tag' },
371
487
  relationships: { note: { data: { id: note.id.to_s, type: 'notes' } } },
372
488
  }
373
489
  ],
@@ -405,7 +521,7 @@ describe 'ArraySerializer' do
405
521
  {
406
522
  id: reviewer.id.to_s,
407
523
  type: 'users',
408
- attributes: { id: reviewer.id, name: 'Peter' },
524
+ attributes: { name: 'Peter' },
409
525
  relationships: {
410
526
  offers: { data: [] },
411
527
  reviewed_offers: { data: [{id: offer.id.to_s, type: 'offers'}] },
@@ -414,7 +530,7 @@ describe 'ArraySerializer' do
414
530
  {
415
531
  id: user.id.to_s,
416
532
  type: 'users',
417
- attributes: { id: user.id, name: 'John' },
533
+ attributes: { name: 'John' },
418
534
  relationships: {
419
535
  offers: { data: [{id: offer.id.to_s, type: 'offers'}] },
420
536
  reviewed_offers: { data: [] },
@@ -465,7 +581,7 @@ describe 'ArraySerializer' do
465
581
  {
466
582
  id: tag.id.to_s,
467
583
  type: 'tag_with_notes',
468
- attributes: { id: tag.id, name: 'My tag' },
584
+ attributes: { name: 'My tag' },
469
585
  relationships: { note: { data: { id: note.id.to_s, type: 'notes' } } },
470
586
  }
471
587
  ]
@@ -524,7 +640,7 @@ describe 'ArraySerializer' do
524
640
  {
525
641
  id: @user.id.to_s,
526
642
  type: 'users',
527
- attributes: {id: @user.id, name: 'John', mobile: '51111111'},
643
+ attributes: {name: 'John', mobile: '51111111'},
528
644
  relationships: {
529
645
  offers: {data: []},
530
646
  address: {data: {id: address.id.to_s, type: 'addresses'}},
@@ -544,7 +660,7 @@ describe 'ArraySerializer' do
544
660
  {
545
661
  id: @user.id.to_s,
546
662
  type: 'users',
547
- attributes: {id: @user.id, name: 'John'},
663
+ attributes: {name: 'John'},
548
664
  relationships: {
549
665
  offers: {data: []},
550
666
  reviewed_offers: {data: []},
@@ -572,7 +688,7 @@ describe 'ArraySerializer' do
572
688
  {
573
689
  id: note.id.to_s,
574
690
  type: 'notes',
575
- attributes: {id: note.id},
691
+ attributes: {},
576
692
  relationships: {
577
693
  sorted_tags: {
578
694
  data: [
@@ -608,7 +724,7 @@ describe 'ArraySerializer' do
608
724
  {
609
725
  id: note.id.to_s,
610
726
  type: 'notes',
611
- attributes: {id: note.id},
727
+ attributes: {},
612
728
  relationships: {
613
729
  custom_sorted_tags: {
614
730
  data: [
@@ -1,11 +1,12 @@
1
- require 'active_record'
2
- require 'action_controller'
1
+ require 'rails/all'
3
2
  require 'rspec'
3
+ require 'rspec/rails'
4
4
  require 'bourne'
5
5
  require 'database_cleaner'
6
6
  require 'active_model_serializers'
7
7
  require 'action_controller/serialization'
8
8
  require 'active_model_serializers_pg'
9
+ require 'generator_spec'
9
10
  if ENV['TEST_UNPATCHED_AMS']
10
11
  ActiveModelSerializers.config.adapter = :json_api
11
12
  else
@@ -24,6 +25,17 @@ end
24
25
  require 'dotenv'
25
26
  Dotenv.load
26
27
 
28
+ # Need this or Rails.application is nil just below:
29
+ module TestApp
30
+ class Application < ::Rails::Application
31
+ config.root = File.dirname(__FILE__)
32
+ end
33
+ end
34
+
35
+ # Need this line or `hook_for` in our generators is ignored,
36
+ # so our migration generator doesn't run:
37
+ Rails.application.load_generators
38
+
27
39
  ActiveRecord::Base.establish_connection(ENV['DATABASE_URL'])
28
40
 
29
41
  class TestController < ActionController::Base
@@ -56,6 +68,16 @@ class PersonSerializer < ActiveModel::Serializer
56
68
  end
57
69
  end
58
70
 
71
+ class PersonWithJsonSerializer < ActiveModel::Serializer
72
+ attributes :id,
73
+ :options, # json
74
+ :prefs, # jsonb
75
+ :settings, # hstore
76
+ :selfies, # json[]
77
+ :portraits, # jsonb[]
78
+ :landscapes # hstore[]
79
+ end
80
+
59
81
  class Note < ActiveRecord::Base
60
82
  has_many :tags
61
83
  has_many :sorted_tags
@@ -126,6 +148,7 @@ end
126
148
  class Tag < ActiveRecord::Base
127
149
  belongs_to :note
128
150
  alias :aliased_note :note
151
+ alias_attribute :aliased_name, :name
129
152
  end
130
153
 
131
154
  class SortedTag < Tag
@@ -164,6 +187,11 @@ class TagWithAliasedNoteSerializer < ActiveModel::Serializer
164
187
  has_one :aliased_note
165
188
  end
166
189
 
190
+ class TagWithAliasedNameSerializer < ActiveModel::Serializer
191
+ attributes :aliased_name
192
+ has_one :note
193
+ end
194
+
167
195
  class User < ActiveRecord::Base
168
196
  has_many :offers, foreign_key: :created_by_id, inverse_of: :created_by
169
197
  has_many :reviewed_offers, foreign_key: :reviewed_by_id, inverse_of: :reviewed_by, class_name: 'Offer'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_model_serializers_pg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul A. Jungwirth
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-10-15 00:00:00.000000000 Z
11
+ date: 2020-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: active_model_serializers
@@ -94,6 +94,34 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec-rails
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: generator_spec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
97
125
  - !ruby/object:Gem::Dependency
98
126
  name: bourne
99
127
  requirement: !ruby/object:Gem::Requirement
@@ -172,6 +200,11 @@ files:
172
200
  - lib/active_model_serializers_pg.rb
173
201
  - lib/active_model_serializers_pg/collection_serializer.rb
174
202
  - lib/active_model_serializers_pg/version.rb
203
+ - lib/generators/active_model_serializers_pg/active_model_serializers_pg_generator.rb
204
+ - lib/generators/active_record/active_model_serializers_pg_generator.rb
205
+ - lib/generators/active_record/templates/jsonb_dasherize.sql
206
+ - lib/generators/active_record/templates/migration.rb
207
+ - spec/generators/main_generator_spec.rb
175
208
  - spec/serializer_spec.rb
176
209
  - spec/spec_helper.rb
177
210
  homepage: https://github.com/pjungwir/active_model_serializers_pg
@@ -193,10 +226,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
193
226
  - !ruby/object:Gem::Version
194
227
  version: '0'
195
228
  requirements: []
196
- rubygems_version: 3.0.1
229
+ rubygems_version: 3.0.8
197
230
  signing_key:
198
231
  specification_version: 4
199
232
  summary: Harness the power of PostgreSQL when crafting JSON reponses
200
233
  test_files:
234
+ - spec/generators/main_generator_spec.rb
201
235
  - spec/serializer_spec.rb
202
236
  - spec/spec_helper.rb