active_model_serializers_pg 0.0.3 → 0.0.8

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: c61aa96dfaadcdbb0304b0a8ea50b05468ad7474c6d3b740419c8bf525f28c01
4
- data.tar.gz: 1d5dfd08ff5168add108de4854246f48ed564b0548d8a66155068d26d41f4805
3
+ metadata.gz: ee3c698a1508537d359551eb125a45c38bd0921dfd059ae49422dd7d114aa7f2
4
+ data.tar.gz: 677ac55b0c35a51ba03b042b34ed3fd560b57d223b6687ca020eaa3b131aabda
5
5
  SHA512:
6
- metadata.gz: 32d433d676644d637de1cfda49d340f35221a6f1a08a5b8b74f605018e5f4169a45ec50bfcafe2c2fc20cb95e53cdaa98123e2b25b3246cafb276c2e68926301
7
- data.tar.gz: d21913bd9baa516e016adcea39c28f4ce05446753553676485fd75cca1fbce1e36727df16bca2d6004b999ba6850d9ec14dc6bf29ac2f1c25660ee3b84dd31ae
6
+ metadata.gz: 82f91e03d0b6440e6b6002909b51e346c5e21293659ca0fc80b976df30bcfc089a294b1c4f42a7ca20c01b20d612203ead218ec3c268d5bd5bb4832652df923d
7
+ data.tar.gz: d906a81cde8076d6150f6466263619f5283d3f5e1e9bbcedaef938f8f5633e2fdd2d1366ef3ca18343669773ef09809cc837df3cc99719ecaefbdae8a94ff2a7
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
 
@@ -110,7 +119,7 @@ To work on active\_model\_serializers\_pg locally, follow these steps:
110
119
  4. Run `bundle exec rake db:create`, this will create the test database.
111
120
  5. Run `bundle exec rake db:migrate`, this will set up the database tables required
112
121
  by the test.
113
- 6. Run `bundle exec rake test:all` to run tests against all supported versions of Active Record (currently 5.0.x, 5.1.x, 5.2.x).
122
+ 6. Run `bundle exec rake test:all` to run tests against all supported versions of Active Record (currently 5.0.x, 5.1.x, 5.2.x, 6.0.x).
114
123
  You can also say `BUNDLE_GEMFILE=gemfiles/Gemfile.activerecord-5.2.x bundle exec rspec spec` to run against a specific version (and select specific tests).
115
124
 
116
125
  Commands for building/releasing/installing:
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
@@ -141,7 +151,7 @@ namespace :test do
141
151
  # Escape current bundler environment
142
152
  Bundler.with_clean_env do
143
153
  # Currently only supports Active Record v5.0-v5.2
144
- %w(5.0.x 5.1.x 5.2.x).each do |version|
154
+ %w(5.0.x 5.1.x 5.2.x 6.0.x).each do |version|
145
155
  sh "BUNDLE_GEMFILE='gemfiles/Gemfile.activerecord-#{version}' bundle install --quiet"
146
156
  sh "BUNDLE_GEMFILE='gemfiles/Gemfile.activerecord-#{version}' bundle exec rspec spec"
147
157
  end
@@ -19,12 +19,14 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ["lib"]
20
20
 
21
21
  spec.add_runtime_dependency 'active_model_serializers', '~> 0.10.8'
22
- spec.add_runtime_dependency 'activerecord', '~> 5.0'
22
+ spec.add_runtime_dependency 'activerecord', '>= 5.0'
23
23
 
24
24
  spec.add_development_dependency 'bundler', '~> 1.3'
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"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec :path => '..'
4
+
5
+ gem "rails", "~>6.0.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)}"
192
+ end
193
+ end
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)}"
183
209
  end
184
210
  end
185
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
@@ -456,11 +546,8 @@ class JsonApiPgSql
456
546
  # TODO: preserve the whole custom relation, not just ordering
457
547
  p = refl.ar_class.new
458
548
  ordering = nil
459
- ActiveSupport::Deprecation.silence do
460
- # TODO: Calling `orders` prints deprecation warnings, so find another way:
461
- ordering = p.send(refl.name).orders
462
- ordering = child_resource.ar_class.default_scoped.orders if ordering.empty?
463
- end
549
+ ordering = p.send(refl.name).arel.orders
550
+ ordering = child_resource.ar_class.default_scoped.arel.orders if ordering.empty?
464
551
  ordering = ordering.map{|o|
465
552
  case o
466
553
  # TODO: The gsub is pretty awful....
@@ -521,39 +608,49 @@ class JsonApiPgSql
521
608
  # e.g. buyer and seller for User.
522
609
  # But we could group those and union just them, or even better do a DISTINCT ON (id).
523
610
  # Since we don't get the id here that could be another CTE.
524
- "UNION SELECT j FROM #{th.cte_name}"
611
+ %Q{UNION SELECT j FROM "#{th.jbs_name}"}
525
612
  }
526
613
  end
527
614
 
528
615
  def include_cte_join_condition(resource)
529
616
  parent = resource.parent
530
617
  if resource.belongs_to?
531
- %Q{#{parent.cte_name}."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
618
+ %Q{"#{parent.cte_name}"."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
532
619
  elsif resource.has_many? or resource.has_one?
533
- %Q{#{parent.cte_name}."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
620
+ %Q{"#{parent.cte_name}"."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
534
621
  else
535
622
  raise "not supported relationship: #{resource.full_name}"
536
623
  end
537
624
  end
538
625
 
626
+ # See note in _jbs_name method for why we split each thing into two CTEs.
539
627
  def include_cte(resource)
540
- # Sometimes options[:fields] has plural keys and sometimes singular,
541
- # so try both:
542
628
  parent = resource.parent
543
629
  <<~EOQ
544
630
  SELECT DISTINCT ON ("#{resource.table_name}"."#{resource.primary_key}")
545
- "#{resource.table_name}".*,
546
- #{select_resource(resource)} AS j
631
+ "#{resource.table_name}".*
547
632
  FROM "#{resource.table_name}"
548
- JOIN #{parent.cte_name}
633
+ JOIN "#{parent.cte_name}"
549
634
  ON #{include_cte_join_condition(resource)}
550
- #{join_resource_relationships(resource)}
551
635
  ORDER BY "#{resource.table_name}"."#{resource.primary_key}"
552
636
  EOQ
553
637
  end
554
638
 
639
+ # See note in _jbs_name method for why we split each thing into two CTEs.
640
+ def include_jbs(resource)
641
+ <<~EOQ
642
+ SELECT "#{resource.table_name}".*,
643
+ #{select_resource(resource)} AS j
644
+ FROM "#{resource.cte_name}" AS "#{resource.table_name}"
645
+ #{join_resource_relationships(resource)}
646
+ EOQ
647
+ end
648
+
555
649
  def includes
556
- @instance_options[:include] || []
650
+ @includes ||= (@instance_options[:include] || []).sort_by do |inc|
651
+ # Sort these by length so we never have bad foreign references in the CTEs:
652
+ inc.size
653
+ end
557
654
  end
558
655
 
559
656
  # Takes a dotted field name (not including the base resource)
@@ -572,13 +669,25 @@ class JsonApiPgSql
572
669
  # Be careful: inc might have dots:
573
670
  th = get_json_thing_from_base(inc)
574
671
  <<~EOQ
575
- #{th.cte_name} AS (
672
+ "#{th.cte_name}" AS (
576
673
  #{include_cte(th)}
577
674
  ),
578
675
  EOQ
579
676
  }.join("\n")
580
677
  end
581
678
 
679
+ def include_jbsses
680
+ includes.map { |inc|
681
+ # Be careful: inc might have dots:
682
+ th = get_json_thing_from_base(inc)
683
+ <<~EOQ
684
+ "#{th.jbs_name}" AS (
685
+ #{include_jbs(th)}
686
+ ),
687
+ EOQ
688
+ }.join("\n")
689
+ end
690
+
582
691
  def base_resource
583
692
  @json_things[:base]
584
693
  end
@@ -609,7 +718,7 @@ class JsonApiPgSql
609
718
  resource.serializer._attributes.select{|f|
610
719
  if ms.include? "include_#{f}?".to_sym
611
720
  ser = resource.serializer.new(nil, @options)
612
- ser.send("include_#{f}?".to_sym) # TODO: call the method
721
+ ser.send("include_#{f}?".to_sym)
613
722
  else
614
723
  true
615
724
  end
@@ -623,7 +732,7 @@ class JsonApiPgSql
623
732
  resource.serializer._reflections.keys.select{|f|
624
733
  if ms.include? "include_#{f}?".to_sym
625
734
  ser = resource.serializer.new(nil, @options)
626
- ser.send("include_#{f}?".to_sym) # TODO: call the method
735
+ ser.send("include_#{f}?".to_sym)
627
736
  else
628
737
  true
629
738
  end
@@ -656,6 +765,9 @@ class JsonApiPgSql
656
765
 
657
766
  def _attribute_fields_for(resource)
658
767
  attrs = Set.new(serializer_attributes(resource))
768
+ # JSON:API always excludes the `id`
769
+ # even if it's part of the serializer:
770
+ attrs = attrs - [resource.primary_key.to_sym]
659
771
  fields_for(resource).select { |f| attrs.include? f }.to_a
660
772
  end
661
773
 
@@ -687,14 +799,15 @@ class JsonApiPgSql
687
799
  #{join_resource_relationships(base_resource)}
688
800
  ),
689
801
  #{include_ctes}
690
- all_ctes AS (
802
+ #{include_jbsses}
803
+ all_jbsses AS (
691
804
  SELECT '{}'::jsonb AS j
692
805
  WHERE 1=0
693
806
  #{include_selects.join("\n")}
694
807
  ),
695
808
  inc AS (
696
809
  SELECT COALESCE(jsonb_agg(j), '[]') AS j
697
- FROM all_ctes
810
+ FROM all_jbsses
698
811
  )
699
812
  SELECT jsonb_build_object('data', t2.j
700
813
  #{maybe_included})
@@ -1,3 +1,3 @@
1
1
  module ActiveModelSerializersPg
2
- VERSION = '0.0.3'
2
+ VERSION = '0.0.8'
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: [
data/spec/spec_helper.rb CHANGED
@@ -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.3
4
+ version: 0.0.8
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-16 00:00:00.000000000 Z
11
+ date: 2021-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: active_model_serializers
@@ -28,14 +28,14 @@ dependencies:
28
28
  name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '5.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - "~>"
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '5.0'
41
41
  - !ruby/object:Gem::Dependency
@@ -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
@@ -168,10 +196,16 @@ files:
168
196
  - gemfiles/Gemfile.activerecord-5.0.x
169
197
  - gemfiles/Gemfile.activerecord-5.1.x
170
198
  - gemfiles/Gemfile.activerecord-5.2.x
199
+ - gemfiles/Gemfile.activerecord-6.0.x
171
200
  - lib/active_model_serializers/adapter/json_api_pg.rb
172
201
  - lib/active_model_serializers_pg.rb
173
202
  - lib/active_model_serializers_pg/collection_serializer.rb
174
203
  - lib/active_model_serializers_pg/version.rb
204
+ - lib/generators/active_model_serializers_pg/active_model_serializers_pg_generator.rb
205
+ - lib/generators/active_record/active_model_serializers_pg_generator.rb
206
+ - lib/generators/active_record/templates/jsonb_dasherize.sql
207
+ - lib/generators/active_record/templates/migration.rb
208
+ - spec/generators/main_generator_spec.rb
175
209
  - spec/serializer_spec.rb
176
210
  - spec/spec_helper.rb
177
211
  homepage: https://github.com/pjungwir/active_model_serializers_pg
@@ -193,10 +227,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
193
227
  - !ruby/object:Gem::Version
194
228
  version: '0'
195
229
  requirements: []
196
- rubygems_version: 3.0.1
230
+ rubygems_version: 3.0.3
197
231
  signing_key:
198
232
  specification_version: 4
199
233
  summary: Harness the power of PostgreSQL when crafting JSON reponses
200
234
  test_files:
235
+ - spec/generators/main_generator_spec.rb
201
236
  - spec/serializer_spec.rb
202
237
  - spec/spec_helper.rb