active_model_serializers_pg 0.0.2 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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