active_model_serializers_pg 0.0.3 → 0.0.4

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: 63d2090a2d7e9e33d8565156023d5c3dc75ea74c77baade84c5c0727d3ab917f
4
+ data.tar.gz: d1abc37c7a6dade9d9bacb7584b5e630f81652a87d28849dacc6bcbb5291c7d9
5
5
  SHA512:
6
- metadata.gz: 32d433d676644d637de1cfda49d340f35221a6f1a08a5b8b74f605018e5f4169a45ec50bfcafe2c2fc20cb95e53cdaa98123e2b25b3246cafb276c2e68926301
7
- data.tar.gz: d21913bd9baa516e016adcea39c28f4ce05446753553676485fd75cca1fbce1e36727df16bca2d6004b999ba6850d9ec14dc6bf29ac2f1c25660ee3b84dd31ae
6
+ metadata.gz: ec68d79eacdcbb257753fa6636236b406e2a57012aa68aba4d8f1ff564bd072653cfc6da4daa07ebe17bce5be0913d6e93da7cadccf6c4d637467d4026cdc21c
7
+ data.tar.gz: 0a7726480564582f54afcc91b8f7666ceb8416a772a8b34623b252723bb36b545a19491894b07b06372cbbfc5313473e8b12f80899e4c7d6705bdaef10ac8235
@@ -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
@@ -92,7 +109,7 @@ end
92
109
  # I think 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
 
@@ -145,6 +163,15 @@ class JsonThing
145
163
  ]
146
164
  end
147
165
 
166
+ # Checks for alias_attribute and gets to the real attribute name.
167
+ def unaliased(field_name)
168
+ ret = field_name
169
+ while field_name = @ar_class.attribute_aliases[field_name.to_s]
170
+ ret = field_name
171
+ end
172
+ ret
173
+ end
174
+
148
175
  # TODO: tests
149
176
  def self.json_key(k)
150
177
  # TODO: technically the serializer could have an option overriding the default:
@@ -179,10 +206,38 @@ class JsonThing
179
206
  if parent.nil?
180
207
  't'
181
208
  else
182
- "cte_#{Digest::SHA256.hexdigest(full_name).first(10)}"
209
+ "cte_#{_cte_name_human_part}_#{Digest::SHA256.hexdigest(full_name).first(10)}"
183
210
  end
184
211
  end
185
212
 
213
+ # Each thing has bother a `cte_foo` CTE and a `jbs_foo` CTE.
214
+ # (jbs stands for "JSONBs" and is meant to take 3 chars like `cte`.)
215
+ # The former is just the relevant records,
216
+ # and the second builds the JSON object for each record.
217
+ # We need to split things into phases like this
218
+ # because of the JSON:API `relationships` item,
219
+ # which can contain references in *both directions*.
220
+ # In that case Postgres will object to our circular dependency.
221
+ # But with two phases, every jbs_* CTE only depends on cte_* CTEs.
222
+ def _jbs_name
223
+ if parent.nil?
224
+ 't'
225
+ else
226
+ "jbs_#{_cte_name_human_part}_#{Digest::SHA256.hexdigest(full_name).first(10)}"
227
+ end
228
+ end
229
+
230
+ # Gets a more informative name for the CTE based on the include key.
231
+ # This makes reading the big SQL query easier, especially for debugging.
232
+ # Note that Postgres's max identifier length is 63 chars (unless you compile yourself),
233
+ # and we are spending 4+4+11=19 chars elsewhere on `rel_cte_XXX_1234567890`.
234
+ # So this method can't return more than 63-19=44 chars.
235
+ #
236
+ # Since we quote the CTE names, we don't actually need to remove dots in the name!
237
+ def _cte_name_human_part
238
+ @cte_name_human_part ||= full_name[0, 44]
239
+ end
240
+
186
241
  def _sql_method(field)
187
242
  m = "#{field}__sql".to_sym
188
243
  if ar_class.respond_to?(m)
@@ -397,7 +452,7 @@ class JsonApiPgSql
397
452
  elsif resource.has_sql_method?(field)
398
453
  resource.sql_method(field)
399
454
  else
400
- %Q{"#{resource.table_name}"."#{field}"}
455
+ %Q{"#{resource.table_name}"."#{resource.unaliased(field)}"}
401
456
  end
402
457
  end
403
458
 
@@ -423,7 +478,7 @@ class JsonApiPgSql
423
478
  refl = resource.reflection
424
479
  <<~EOQ
425
480
  '#{resource.json_key}',
426
- jsonb_build_object(#{refl.include_data ? %Q{'data', rel_#{resource.cte_name}.j} : ''}
481
+ jsonb_build_object(#{refl.include_data ? %Q{'data', "rel_#{resource.cte_name}".j} : ''}
427
482
  #{refl.include_data && refl.links.any? ? ',' : ''}
428
483
  #{refl.links.any? ? %Q{'links', jsonb_build_object(#{select_resource_relationship_links(resource, refl)})} : ''})
429
484
  EOQ
@@ -521,39 +576,51 @@ class JsonApiPgSql
521
576
  # e.g. buyer and seller for User.
522
577
  # But we could group those and union just them, or even better do a DISTINCT ON (id).
523
578
  # Since we don't get the id here that could be another CTE.
524
- "UNION SELECT j FROM #{th.cte_name}"
579
+ %Q{UNION SELECT j FROM "#{th.jbs_name}"}
525
580
  }
526
581
  end
527
582
 
528
583
  def include_cte_join_condition(resource)
529
584
  parent = resource.parent
530
585
  if resource.belongs_to?
531
- %Q{#{parent.cte_name}."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
586
+ %Q{"#{parent.cte_name}"."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"}
532
587
  elsif resource.has_many? or resource.has_one?
533
- %Q{#{parent.cte_name}."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
588
+ %Q{"#{parent.cte_name}"."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"}
534
589
  else
535
590
  raise "not supported relationship: #{resource.full_name}"
536
591
  end
537
592
  end
538
593
 
594
+ # See note in _jbs_name method for why we split each thing into two CTEs.
539
595
  def include_cte(resource)
540
596
  # Sometimes options[:fields] has plural keys and sometimes singular,
541
597
  # so try both:
542
598
  parent = resource.parent
543
599
  <<~EOQ
544
600
  SELECT DISTINCT ON ("#{resource.table_name}"."#{resource.primary_key}")
545
- "#{resource.table_name}".*,
546
- #{select_resource(resource)} AS j
601
+ "#{resource.table_name}".*
547
602
  FROM "#{resource.table_name}"
548
- JOIN #{parent.cte_name}
603
+ JOIN "#{parent.cte_name}"
549
604
  ON #{include_cte_join_condition(resource)}
550
- #{join_resource_relationships(resource)}
551
605
  ORDER BY "#{resource.table_name}"."#{resource.primary_key}"
552
606
  EOQ
553
607
  end
554
608
 
609
+ # See note in _jbs_name method for why we split each thing into two CTEs.
610
+ def include_jbs(resource)
611
+ <<~EOQ
612
+ SELECT "#{resource.table_name}".*,
613
+ #{select_resource(resource)} AS j
614
+ FROM "#{resource.cte_name}" AS "#{resource.table_name}"
615
+ #{join_resource_relationships(resource)}
616
+ EOQ
617
+ end
618
+
555
619
  def includes
556
- @instance_options[:include] || []
620
+ @includes ||= (@instance_options[:include] || []).sort_by do |inc|
621
+ # Sort these by length so we never have bad foreign references in the CTEs:
622
+ inc.size
623
+ end
557
624
  end
558
625
 
559
626
  # Takes a dotted field name (not including the base resource)
@@ -572,13 +639,25 @@ class JsonApiPgSql
572
639
  # Be careful: inc might have dots:
573
640
  th = get_json_thing_from_base(inc)
574
641
  <<~EOQ
575
- #{th.cte_name} AS (
642
+ "#{th.cte_name}" AS (
576
643
  #{include_cte(th)}
577
644
  ),
578
645
  EOQ
579
646
  }.join("\n")
580
647
  end
581
648
 
649
+ def include_jbsses
650
+ includes.map { |inc|
651
+ # Be careful: inc might have dots:
652
+ th = get_json_thing_from_base(inc)
653
+ <<~EOQ
654
+ "#{th.jbs_name}" AS (
655
+ #{include_jbs(th)}
656
+ ),
657
+ EOQ
658
+ }.join("\n")
659
+ end
660
+
582
661
  def base_resource
583
662
  @json_things[:base]
584
663
  end
@@ -687,14 +766,15 @@ class JsonApiPgSql
687
766
  #{join_resource_relationships(base_resource)}
688
767
  ),
689
768
  #{include_ctes}
690
- all_ctes AS (
769
+ #{include_jbsses}
770
+ all_jbsses AS (
691
771
  SELECT '{}'::jsonb AS j
692
772
  WHERE 1=0
693
773
  #{include_selects.join("\n")}
694
774
  ),
695
775
  inc AS (
696
776
  SELECT COALESCE(jsonb_agg(j), '[]') AS j
697
- FROM all_ctes
777
+ FROM all_jbsses
698
778
  )
699
779
  SELECT jsonb_build_object('data', t2.j
700
780
  #{maybe_included})
@@ -1,3 +1,3 @@
1
1
  module ActiveModelSerializersPg
2
- VERSION = '0.0.3'
2
+ VERSION = '0.0.4'
3
3
  end
@@ -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
@@ -195,7 +230,29 @@ describe 'ArraySerializer' do
195
230
  }.to_json
196
231
  expect(json_data).to eq json_expected
197
232
  end
233
+ end
234
+
235
+ context 'with aliased attribute' do
236
+ let(:relation) { Tag.first }
237
+ let(:controller) { TagsController.new }
238
+ let(:options) { { serializer: TagWithAliasedNameSerializer } }
198
239
 
240
+ before do
241
+ @note = Note.create content: 'Test', name: 'Title'
242
+ @tag = Tag.create name: 'My tag', note: @note, popular: true
243
+ end
244
+
245
+ it 'generates the proper json output' do
246
+ json_expected = {
247
+ data: {
248
+ id: @tag.id.to_s,
249
+ type: 'tags',
250
+ attributes: { 'aliased_name' => 'My tag' },
251
+ relationships: { note: { data: {id: @note.id.to_s, type: 'notes'} } }
252
+ }
253
+ }.to_json
254
+ expect(json_data).to eq json_expected
255
+ end
199
256
  end
200
257
 
201
258
  context 'serialize single record with custom serializer' do
data/spec/spec_helper.rb CHANGED
@@ -126,6 +126,7 @@ end
126
126
  class Tag < ActiveRecord::Base
127
127
  belongs_to :note
128
128
  alias :aliased_note :note
129
+ alias_attribute :aliased_name, :name
129
130
  end
130
131
 
131
132
  class SortedTag < Tag
@@ -164,6 +165,11 @@ class TagWithAliasedNoteSerializer < ActiveModel::Serializer
164
165
  has_one :aliased_note
165
166
  end
166
167
 
168
+ class TagWithAliasedNameSerializer < ActiveModel::Serializer
169
+ attributes :aliased_name
170
+ has_one :note
171
+ end
172
+
167
173
  class User < ActiveRecord::Base
168
174
  has_many :offers, foreign_key: :created_by_id, inverse_of: :created_by
169
175
  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.4
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: 2019-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: active_model_serializers