active_model_serializers_pg 0.0.3 → 0.0.4

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: 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