panko_serializer 0.8.5 → 1.0.0

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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -4
  3. data/lib/panko/array_serializer.rb +55 -16
  4. data/lib/panko/code_gen/active_record/access_classifier.rb +65 -0
  5. data/lib/panko/code_gen/active_record/define_attribute_methods.rb +13 -0
  6. data/lib/panko/code_gen/code_builder.rb +32 -0
  7. data/lib/panko/code_gen/compile_cache.rb +28 -0
  8. data/lib/panko/code_gen/compiler.rb +56 -0
  9. data/lib/panko/code_gen/config.rb +79 -0
  10. data/lib/panko/code_gen/datetime_format.rb +38 -0
  11. data/lib/panko/code_gen/descriptor.rb +136 -0
  12. data/lib/panko/code_gen/descriptor_builder.rb +166 -0
  13. data/lib/panko/code_gen/dump.rb +52 -0
  14. data/lib/panko/code_gen/errors.rb +21 -0
  15. data/lib/panko/code_gen/filter.rb +29 -0
  16. data/lib/panko/code_gen/filter_adapter.rb +44 -0
  17. data/lib/panko/code_gen/filters/indexed.rb +62 -0
  18. data/lib/panko/code_gen/filters/none.rb +19 -0
  19. data/lib/panko/code_gen/generator.rb +23 -0
  20. data/lib/panko/code_gen/generators/banner.rb +27 -0
  21. data/lib/panko/code_gen/generators/class_emitter.rb +120 -0
  22. data/lib/panko/code_gen/generators/cycle_membership.rb +53 -0
  23. data/lib/panko/code_gen/generators/descriptor_walk.rb +22 -0
  24. data/lib/panko/code_gen/generators/fanout.rb +60 -0
  25. data/lib/panko/code_gen/generators/field_index.rb +31 -0
  26. data/lib/panko/code_gen/generators/field_walk.rb +38 -0
  27. data/lib/panko/code_gen/generators/generated_names.rb +73 -0
  28. data/lib/panko/code_gen/generators/hash_sink.rb +217 -0
  29. data/lib/panko/code_gen/generators/json_sink.rb +290 -0
  30. data/lib/panko/code_gen/generators/record_access/generic.rb +92 -0
  31. data/lib/panko/code_gen/generators/record_access/specialized.rb +90 -0
  32. data/lib/panko/code_gen/generators/release.rb +60 -0
  33. data/lib/panko/code_gen/generators/sink.rb +80 -0
  34. data/lib/panko/code_gen/instance_pool.rb +27 -0
  35. data/lib/panko/code_gen/runtime.rb +27 -0
  36. data/lib/panko/code_gen/serializer_cache.rb +242 -0
  37. data/lib/panko/code_gen/validators/callable_arity.rb +42 -0
  38. data/lib/panko/code_gen/validators/name_uniqueness.rb +44 -0
  39. data/lib/panko/code_gen/validators/source_resolution.rb +48 -0
  40. data/lib/panko/code_gen/validators/validator.rb +19 -0
  41. data/lib/panko/code_gen/writers_pool.rb +51 -0
  42. data/lib/panko/code_gen.rb +82 -0
  43. data/lib/panko/compile_all.rb +80 -0
  44. data/lib/panko/compile_all_result.rb +21 -0
  45. data/lib/panko/config.rb +71 -0
  46. data/lib/panko/descriptor.rb +93 -0
  47. data/lib/panko/response.rb +40 -8
  48. data/lib/panko/serializer.rb +175 -91
  49. data/lib/panko/serializer_resolver.rb +2 -8
  50. data/lib/panko/version.rb +1 -1
  51. data/lib/panko_serializer.rb +3 -5
  52. metadata +49 -35
  53. data/ext/panko_serializer/attributes_writer/active_record.c +0 -181
  54. data/ext/panko_serializer/attributes_writer/active_record.h +0 -17
  55. data/ext/panko_serializer/attributes_writer/attributes_writer.c +0 -55
  56. data/ext/panko_serializer/attributes_writer/attributes_writer.h +0 -35
  57. data/ext/panko_serializer/attributes_writer/common.c +0 -10
  58. data/ext/panko_serializer/attributes_writer/common.h +0 -9
  59. data/ext/panko_serializer/attributes_writer/hash.c +0 -13
  60. data/ext/panko_serializer/attributes_writer/hash.h +0 -7
  61. data/ext/panko_serializer/attributes_writer/plain.c +0 -13
  62. data/ext/panko_serializer/attributes_writer/plain.h +0 -7
  63. data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.c +0 -156
  64. data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.h +0 -10
  65. data/ext/panko_serializer/attributes_writer/type_cast/type_cast.c +0 -409
  66. data/ext/panko_serializer/attributes_writer/type_cast/type_cast.h +0 -81
  67. data/ext/panko_serializer/common.h +0 -9
  68. data/ext/panko_serializer/extconf.rb +0 -28
  69. data/ext/panko_serializer/panko_serializer.c +0 -181
  70. data/ext/panko_serializer/panko_serializer.h +0 -12
  71. data/ext/panko_serializer/serialization_descriptor/association.c +0 -90
  72. data/ext/panko_serializer/serialization_descriptor/association.h +0 -20
  73. data/ext/panko_serializer/serialization_descriptor/attribute.c +0 -96
  74. data/ext/panko_serializer/serialization_descriptor/attribute.h +0 -27
  75. data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.c +0 -177
  76. data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.h +0 -30
  77. data/lib/panko/association.rb +0 -17
  78. data/lib/panko/attribute.rb +0 -29
  79. data/lib/panko/object_writer.rb +0 -68
  80. data/lib/panko/serialization_descriptor.rb +0 -180
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../code_gen"
4
+ require_relative "../config"
5
+ require_relative "descriptor_builder"
6
+ require_relative "instance_pool"
7
+
8
+ module Panko
9
+ module CodeGen
10
+ # Per-serializer-class cache of compiled Generated Classes and their Descriptor. The engine
11
+ # keeps no cache between compile calls.
12
+ #
13
+ # Keyed by class identity, so a code reload (a new class object) starts empty. Reopening a
14
+ # live serializer or record class after its first serialize is not picked up: a variant's
15
+ # column-or-method choice for each source is fixed when it first sees the record class.
16
+ module SerializerCache
17
+ class Slots
18
+ attr_accessor :compiled, :pool, :variants
19
+ end
20
+
21
+ class State
22
+ attr_accessor :descriptor, :capacity_warned
23
+ attr_reader :json, :hash
24
+
25
+ def initialize
26
+ @json = Slots.new
27
+ @hash = Slots.new
28
+ end
29
+
30
+ def slot(output)
31
+ case output
32
+ when :json then @json
33
+ when :hash then @hash
34
+ else raise ArgumentError, "unknown output mode: #{output.inspect}"
35
+ end
36
+ end
37
+ end
38
+
39
+ # Guards the Descriptor and Slots writes and reset!. Reads skip it: under the GVL an ivar
40
+ # read sees nil or the finished value. Not reentrant, so a locked section must not lock again.
41
+ COMPILE_MUTEX = Mutex.new
42
+
43
+ EMPTY_VARIANTS = {}.freeze
44
+
45
+ def self.fetch(serializer_class, output:)
46
+ state = serializer_class._cg_state
47
+ compiled = state&.slot(output)&.compiled
48
+ return compiled if compiled
49
+
50
+ # Outside the lock: descriptor_for takes the mutex itself.
51
+ descriptor = descriptor_for(serializer_class)
52
+
53
+ COMPILE_MUTEX.synchronize do
54
+ slot = state_for!(serializer_class).slot(output)
55
+ slot.compiled ||= Panko::CodeGen.compile(descriptor, output: output, config: Config.new)
56
+ end
57
+ end
58
+
59
+ def self.instance_pool(serializer_class, output)
60
+ state = serializer_class._cg_state
61
+ pool = state&.slot(output)&.pool
62
+ return pool if pool
63
+
64
+ compiled = fetch(serializer_class, output: output)
65
+ descriptor = descriptor_for(serializer_class)
66
+
67
+ COMPILE_MUTEX.synchronize do
68
+ # Catches a +filters_for+ added some other way (e.g. +extend+) before the first
69
+ # serialize. ||= never downgrades a +true+ set by the hooks.
70
+ serializer_class._cg_has_filters_for ||= serializer_class.respond_to?(:filters_for)
71
+ slot = state_for!(serializer_class).slot(output)
72
+ slot.pool ||= InstancePool.new(
73
+ :"_panko_cg_pool_#{output}_#{serializer_class.object_id}",
74
+ compiled, descriptor
75
+ )
76
+ end
77
+ end
78
+
79
+ # Stores only compiled variants (bounded by capacity) and +CompileError+ base pins, so a
80
+ # failing compile does not repeat on every inline-cache miss. Ineligible classes, capacity
81
+ # overflow and other errors get the base pool with no entry, so per-call classes cannot
82
+ # grow the map. The compile runs outside the mutex; when two threads compile the same
83
+ # variant, the insert keeps one.
84
+ def self.variant_pool(serializer_class, output, model)
85
+ state = serializer_class._cg_state
86
+ variants = state&.slot(output)&.variants
87
+ pool = variants && variants[model]
88
+
89
+ unless pool
90
+ base = instance_pool(serializer_class, output)
91
+ pool, admissible = auto_variant_pool(serializer_class, output, model, base)
92
+ if admissible
93
+ COMPILE_MUTEX.synchronize do
94
+ # state_for!, not a bare _cg_state read: a reset! can land during the unlocked
95
+ # variant compile above.
96
+ slot = state_for!(serializer_class).slot(output)
97
+ current = slot.variants || EMPTY_VARIANTS
98
+ if (existing = current[model])
99
+ pool = existing
100
+ elsif (admitted = admit_variant(serializer_class, model, pool, base, current))
101
+ slot.variants = current.merge(model => admitted).freeze
102
+ pool = admitted
103
+ else
104
+ pool = base
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ remember_last(serializer_class, output, model, pool)
111
+ pool
112
+ end
113
+
114
+ def self.descriptor_for(serializer_class)
115
+ state = serializer_class._cg_state
116
+ cached = state&.descriptor
117
+ return cached if cached
118
+
119
+ COMPILE_MUTEX.synchronize do
120
+ state = state_for!(serializer_class)
121
+ state.descriptor ||=
122
+ DescriptorBuilder.uniquify_names(DescriptorBuilder.build(serializer_class))
123
+ end
124
+ end
125
+
126
+ # Clears every cache for +serializer_class+ so the next serialize rebuilds from its DSL
127
+ # declarations. Used by tests.
128
+ def self.reset!(serializer_class)
129
+ COMPILE_MUTEX.synchronize do
130
+ serializer_class._cg_state = nil
131
+ serializer_class._cg_last_json = nil
132
+ serializer_class._cg_last_hash = nil
133
+ serializer_class._cg_public_descriptor = nil
134
+ # Not nil: Serializer#descriptor reads the flag without going through instance_pool,
135
+ # so nil would skip +filters_for+ there.
136
+ serializer_class._cg_has_filters_for = serializer_class.respond_to?(:filters_for)
137
+ end
138
+ end
139
+
140
+ # False for unseen classes and for classes pinned to the base pool.
141
+ def self.specialized?(serializer_class, output, model)
142
+ state = serializer_class._cg_state
143
+ return false unless state
144
+ slot = state.slot(output)
145
+ pool = slot.variants && slot.variants[model]
146
+ return false unless pool
147
+ !pool.equal?(slot.pool)
148
+ end
149
+
150
+ # Includes classes pinned to the base pool.
151
+ def self.variant_models(serializer_class, output)
152
+ state = serializer_class._cg_state
153
+ variants = state&.slot(output)&.variants
154
+ variants ? variants.keys : []
155
+ end
156
+
157
+ # Callers must hold +COMPILE_MUTEX+: creating the State outside the lock could lose a
158
+ # concurrent writer's cells.
159
+ def self.state_for!(serializer_class)
160
+ serializer_class._cg_state ||= State.new
161
+ end
162
+ private_class_method :state_for!
163
+
164
+ # One frozen pair, so racing writers can never leave the model of one call with the pool
165
+ # of another. A direct class ivar keeps the hit path at one ivar read and one compare.
166
+ def self.remember_last(serializer_class, output, model, pool)
167
+ pair = [model, pool].freeze
168
+ case output
169
+ when :json then serializer_class._cg_last_json = pair
170
+ when :hash then serializer_class._cg_last_hash = pair
171
+ else raise ArgumentError, "unknown output mode: #{output.inspect}"
172
+ end
173
+ end
174
+ private_class_method :remember_last
175
+
176
+ # Returns +[pool, admissible]+. Errors other than +CompileError+ can be temporary (lost
177
+ # connection, schema not loaded), so they are not stored, and a serializer that works
178
+ # on the Generic path never raises here.
179
+ def self.auto_variant_pool(serializer_class, output, model, base)
180
+ return [base, false] unless auto_specialize?(model)
181
+ if specialized_count(serializer_class, output, base) >= Panko::Config.auto_specialization.capacity
182
+ warn_capacity_once(serializer_class, model)
183
+ return [base, false]
184
+ end
185
+
186
+ descriptor = DescriptorBuilder.uniquify_names(DescriptorBuilder.specialize(descriptor_for(serializer_class), model))
187
+ compiled = Panko::CodeGen.compile(descriptor, output: output, config: Config.new(guarded_model: true))
188
+ pool = InstancePool.new(
189
+ :"_panko_cg_pool_#{output}_#{serializer_class.object_id}_#{model.object_id}",
190
+ compiled, descriptor
191
+ )
192
+ [pool, true]
193
+ rescue CompileError
194
+ [base, true]
195
+ rescue
196
+ [base, false]
197
+ end
198
+ private_class_method :auto_variant_pool
199
+
200
+ def self.auto_specialize?(model)
201
+ Panko::Config.auto_specialization.enabled &&
202
+ model.respond_to?(:columns_hash) &&
203
+ model.respond_to?(:attribute_methods_generated?) &&
204
+ DescriptorBuilder.resolvable_name?(model)
205
+ end
206
+ private_class_method :auto_specialize?
207
+
208
+ # Pinned entries share the base pool object, so only non-base entries count.
209
+ def self.specialized_count(serializer_class, output, base)
210
+ state = serializer_class._cg_state
211
+ variants = state&.slot(output)&.variants || EMPTY_VARIANTS
212
+ variants.count { |_, pool| !pool.equal?(base) }
213
+ end
214
+ private_class_method :specialized_count
215
+
216
+ # Runs under +COMPILE_MUTEX+ and checks capacity again, since the check in
217
+ # {auto_variant_pool} ran without the lock. +nil+ means over capacity.
218
+ def self.admit_variant(serializer_class, model, candidate, base, current)
219
+ return candidate if candidate.equal?(base)
220
+ return candidate if current.count { |_, pool| !pool.equal?(base) } < Panko::Config.auto_specialization.capacity
221
+
222
+ warn_capacity_once(serializer_class, model)
223
+ nil
224
+ end
225
+ private_class_method :admit_variant
226
+
227
+ # Reads the State without creating it: both callers run after instance_pool built it, and
228
+ # state_for! needs +COMPILE_MUTEX+, which one caller already holds. The flag write can race;
229
+ # at worst the warning prints more than once.
230
+ def self.warn_capacity_once(serializer_class, model)
231
+ state = serializer_class._cg_state
232
+ return if state.nil? || state.capacity_warned
233
+ state.capacity_warned = true
234
+ warn "#{serializer_class} auto-specialization capacity " \
235
+ "(#{Panko::Config.auto_specialization.capacity}) reached at #{model}; " \
236
+ "further record classes use the generic path. Raise " \
237
+ "Panko::Config.auto_specialization.capacity if this is intentional."
238
+ end
239
+ private_class_method :warn_capacity_once
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Validators
5
+ # Raises ArityError when a MethodAttribute body or an Association +if:+
6
+ # has an arity outside 0..3.
7
+ module CallableArity
8
+ # The generator's call shapes cover only 0..3; it treats any other arity as 3.
9
+ ALLOWED_ARITIES = 0..3
10
+
11
+ # Walks by object identity, so a shared or self-recursive Descriptor is checked once.
12
+ def self.validate(descriptor, output:, config:)
13
+ walk(descriptor, {})
14
+ nil
15
+ end
16
+
17
+ class << self
18
+ private
19
+
20
+ # A Symbol body names a method and has no arity to check.
21
+ def walk(descriptor, seen)
22
+ return if seen[descriptor.__id__]
23
+ seen[descriptor.__id__] = true
24
+ descriptor.method_attributes.each do |ma|
25
+ next if ma.body.is_a?(Symbol)
26
+ check_arity!(descriptor.name, ma.name, "MethodAttribute#body", ma.body.arity)
27
+ end
28
+ descriptor.associations.each do |assoc|
29
+ check_arity!(descriptor.name, assoc.name, "Association#if", assoc.if.arity) if assoc.if
30
+ assoc.descriptors.each { |child| walk(child, seen) }
31
+ end
32
+ end
33
+
34
+ def check_arity!(descriptor_name, field_name, callable_label, arity)
35
+ return if ALLOWED_ARITIES.include?(arity)
36
+ raise ArityError,
37
+ "#{descriptor_name}##{field_name}: #{callable_label} has arity #{arity}; must be 0, 1, 2, or 3."
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Validators
5
+ # Raises NameCollisionError when two Fields at the same level share a name:
6
+ # each Field writes its output under its name. The same name at different levels is fine.
7
+ module NameUniqueness
8
+ def self.validate(descriptor, output:, config:)
9
+ walk(descriptor, {})
10
+ nil
11
+ end
12
+
13
+ class << self
14
+ private
15
+
16
+ def walk(descriptor, seen_descriptors)
17
+ return if seen_descriptors[descriptor.__id__]
18
+ seen_descriptors[descriptor.__id__] = true
19
+ check_level!(descriptor)
20
+ descriptor.associations.each { |assoc| assoc.descriptors.each { |child| walk(child, seen_descriptors) } }
21
+ end
22
+
23
+ def check_level!(descriptor)
24
+ seen_names = {}
25
+ fields_with_kinds(descriptor).each do |field, kind|
26
+ previous_kind = seen_names[field.name]
27
+ if previous_kind
28
+ raise NameCollisionError,
29
+ "#{descriptor.name}##{field.name}: #{previous_kind} and #{kind} share name; " \
30
+ "every Field at the same level must have a unique name."
31
+ end
32
+ seen_names[field.name] = kind
33
+ end
34
+ end
35
+
36
+ def fields_with_kinds(descriptor)
37
+ descriptor.attributes.map { |a| [a, "Attribute"] } +
38
+ descriptor.method_attributes.map { |m| [m, "MethodAttribute"] } +
39
+ descriptor.associations.map { |a| [a, "Association"] }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Validators
5
+ # Raises UnknownSourceError when an Attribute source on an Active Record +model:+
6
+ # is neither a column nor an instance method. Other models are not checked.
7
+ module SourceResolution
8
+ def self.validate(descriptor, output:, config:)
9
+ walk(descriptor, {})
10
+ nil
11
+ end
12
+
13
+ class << self
14
+ private
15
+
16
+ def walk(descriptor, seen)
17
+ return if seen[descriptor.__id__]
18
+ seen[descriptor.__id__] = true
19
+ classify_attributes!(descriptor) if descriptor.model
20
+ descriptor.associations.each { |assoc| assoc.descriptors.each { |child| walk(child, seen) } }
21
+ end
22
+
23
+ def classify_attributes!(descriptor)
24
+ model = descriptor.model
25
+ return unless ar_class?(model)
26
+ ActiveRecord::DefineAttributeMethods.ensure!(model)
27
+ descriptor.attributes.each do |attribute|
28
+ classify_or_raise!(model, attribute, descriptor.name)
29
+ end
30
+ end
31
+
32
+ # The classifier knows only the class and the source; add the Descriptor and Field names.
33
+ def classify_or_raise!(model, attribute, descriptor_name)
34
+ ActiveRecord::AccessClassifier.classify(model, attribute.source)
35
+ nil
36
+ rescue UnknownSourceError
37
+ raise UnknownSourceError,
38
+ "#{descriptor_name}##{attribute.name}: Attribute#source :#{attribute.source} " \
39
+ "is not a column or instance method on #{model.name}."
40
+ end
41
+
42
+ def ar_class?(klass)
43
+ klass.respond_to?(:columns_hash) && klass.respond_to?(:attribute_methods_generated?)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Validators
5
+ class Validator
6
+ # Order matters: an arity error is reported before a source or name error.
7
+ DEFAULT_RULES = [CallableArity, SourceResolution, NameUniqueness].freeze
8
+
9
+ def initialize(rules: DEFAULT_RULES)
10
+ @rules = rules
11
+ end
12
+
13
+ def validate(descriptor, output:, config:)
14
+ @rules.each { |rule| rule.validate(descriptor, output: output, config: config) }
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj"
4
+ require_relative "../config"
5
+
6
+ module Panko::CodeGen
7
+ # A stack, not one writer per fiber: a nested serialize call (from a method attribute)
8
+ # pops a different writer, or creates one when the stack is empty.
9
+ # +Oj::StringWriter#reset+ keeps the grown buffer, so +checkin+ drops a writer
10
+ # whose output was larger than +Panko::Config.writer_pool_max_bytes+.
11
+ class WritersPool
12
+ STORAGE_KEY = :_panko_writers
13
+
14
+ def checkout
15
+ storage.pop || Oj::StringWriter.new(mode: :rails)
16
+ end
17
+
18
+ # A +nil+ +result+ means the call raised; the writer is dropped then too.
19
+ def checkin(writer, result)
20
+ return if result.nil? || result.bytesize > Panko::Config.writer_pool_max_bytes
21
+
22
+ writer.reset
23
+ storage.push(writer)
24
+ end
25
+
26
+ private
27
+
28
+ def storage
29
+ raise NotImplementedError, "#{self.class} must override #storage"
30
+ end
31
+
32
+ # +Thread.current[]+ is fiber-local in MRI. Needs no Rails.
33
+ class ThreadLocal < WritersPool
34
+ private
35
+
36
+ def storage
37
+ Thread.current[STORAGE_KEY] ||= []
38
+ end
39
+ end
40
+
41
+ # Follows ActiveRecord's connection pool: per thread, or per fiber when
42
+ # +ActiveSupport::IsolatedExecutionState.isolation_level+ is +:fiber+.
43
+ class IsolatedExecutionState < WritersPool
44
+ private
45
+
46
+ def storage
47
+ ActiveSupport::IsolatedExecutionState[STORAGE_KEY] ||= []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oj"
4
+
5
+ module Panko
6
+ module CodeGen
7
+ # Shared by the generated file banner and the synthetic backtrace path.
8
+ GENERATOR_NAME = "Panko::CodeGen"
9
+ end
10
+ end
11
+
12
+ require_relative "code_gen/errors"
13
+ require_relative "code_gen/config"
14
+ require_relative "code_gen/descriptor"
15
+ require_relative "code_gen/code_builder"
16
+ require_relative "code_gen/filter"
17
+ require_relative "code_gen/datetime_format"
18
+ require_relative "code_gen/active_record/access_classifier"
19
+ require_relative "code_gen/active_record/define_attribute_methods"
20
+ require_relative "code_gen/validators/callable_arity"
21
+ require_relative "code_gen/validators/source_resolution"
22
+ require_relative "code_gen/validators/name_uniqueness"
23
+ require_relative "code_gen/validators/validator"
24
+ require_relative "code_gen/generators/generated_names"
25
+ require_relative "code_gen/generators/sink"
26
+ require_relative "code_gen/generators/json_sink"
27
+ require_relative "code_gen/generators/hash_sink"
28
+ require_relative "code_gen/generators/field_walk"
29
+ require_relative "code_gen/generators/record_access/generic"
30
+ require_relative "code_gen/generators/record_access/specialized"
31
+ require_relative "code_gen/generators/cycle_membership"
32
+ require_relative "code_gen/generators/descriptor_walk"
33
+ require_relative "code_gen/generators/field_index"
34
+ require_relative "code_gen/generators/release"
35
+ require_relative "code_gen/generators/banner"
36
+ require_relative "code_gen/generators/class_emitter"
37
+ require_relative "code_gen/generators/fanout"
38
+ require_relative "code_gen/generator"
39
+ require_relative "code_gen/compile_cache"
40
+ require_relative "code_gen/compiler"
41
+ require_relative "code_gen/dump"
42
+ require_relative "code_gen/writers_pool"
43
+
44
+ # Internal code generator: turns an immutable Descriptor into a class that
45
+ # emits JSON or a Ruby Hash.
46
+ module Panko::CodeGen
47
+ # Responds to no Oj callback, so +Oj.sc_parse+ only checks the JSON is valid
48
+ # without building any Ruby objects.
49
+ JSON_NOOP_PARSER = Object.new.freeze
50
+
51
+ # Passed positionally: a +mode: :strict+ keyword would allocate a Hash per record.
52
+ JSON_STRICT_PARSE_OPTS = {mode: :strict}.freeze
53
+
54
+ # Returns a new Generated Class on every call. Raises CompileError when +descriptor+
55
+ # cannot be compiled, ArgumentError when +output:+ is not +:json+ or +:hash+.
56
+ def self.compile(descriptor, output:, config: Config.new)
57
+ Compiler.new(descriptor, output: output, config: config).compile
58
+ end
59
+
60
+ # Writes one file per Descriptor (root at +path:+, children beside it) and returns
61
+ # +path:+. Accepts a public +Panko::Descriptor+ too.
62
+ def self.dump(descriptor, output:, path:, config: Config.new)
63
+ # +defined?+ guard: the engine also loads standalone, without +Panko::Descriptor+.
64
+ if defined?(::Panko::Descriptor) && descriptor.is_a?(::Panko::Descriptor)
65
+ descriptor = SerializerCache.descriptor_for(descriptor.serializer)
66
+ end
67
+ Dump.new(descriptor, output: output, config: config, path: path).dump
68
+ end
69
+
70
+ # Hash mode only: converts a value the way Oj +:rails+ mode does in JSON mode (+#as_json+,
71
+ # non-finite Float to nil). Without ActiveSupport there is no +#as_json+, so the value passes raw.
72
+ def self.cast_datetime(value)
73
+ # Runs on many Hash-mode field writes, so types whose +#as_json+ returns self skip the
74
+ # call. Symbol is not one of them: its +#as_json+ is a String.
75
+ case value
76
+ when String, Integer, NilClass, TrueClass, FalseClass then value
77
+ when Float then value.finite? ? value : nil
78
+ else
79
+ value.respond_to?(:as_json) ? value.as_json : value
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "serializer"
4
+ require_relative "compile_all_result"
5
+ require_relative "code_gen/serializer_cache"
6
+
7
+ module Panko
8
+ COMPILE_ALL_MODES = %i[json hash].freeze
9
+
10
+ # Compiles every loaded Panko::Serializer subclass that declares a field:
11
+ # the base for each mode, plus a specialized variant per {Serializer.models}
12
+ # model. Call it at boot, after eager load and before forking workers.
13
+ # Run warm-up calls after it: a compile defines constants, which drops YJIT
14
+ # code that reads the same constant names. Safe to repeat.
15
+ #
16
+ # @param modes [Array<Symbol>] a non-empty subset of +[:json, :hash]+
17
+ # @return [Panko::CompileAllResult]
18
+ # @raise [ArgumentError] when +modes+ is empty or holds an unknown mode
19
+ def self.compile_all(modes: COMPILE_ALL_MODES)
20
+ if modes.empty? || !(modes - COMPILE_ALL_MODES).empty?
21
+ raise ArgumentError, "compile_all modes: expected a non-empty subset of #{COMPILE_ALL_MODES.inspect}, got #{modes.inspect}"
22
+ end
23
+
24
+ compiled = []
25
+ without_models = []
26
+ not_specialized = {}
27
+ errors = {}
28
+ cache = CodeGen::SerializerCache
29
+
30
+ compile_all_serializers.each do |serializer|
31
+ models = serializer._cg_models
32
+ missed = []
33
+ begin
34
+ modes.each do |mode|
35
+ cache.instance_pool(serializer, mode)
36
+ models.each do |model|
37
+ cache.variant_pool(serializer, mode, model)
38
+ missed << model unless cache.specialized?(serializer, mode, model)
39
+ end
40
+ end
41
+ rescue => error
42
+ errors[serializer] = error
43
+ next
44
+ end
45
+
46
+ if models.empty?
47
+ without_models << serializer
48
+ elsif missed.empty?
49
+ compiled << serializer
50
+ else
51
+ not_specialized[serializer] = missed.uniq
52
+ end
53
+ end
54
+
55
+ CompileAllResult.new(
56
+ compiled: compiled.freeze,
57
+ without_models: without_models.freeze,
58
+ not_specialized: not_specialized.freeze,
59
+ errors: errors.freeze
60
+ )
61
+ end
62
+
63
+ # Collected before any compile, so the Generated Classes it adds are not
64
+ # visited. They subclass the user serializer; only they define +serialize_one+.
65
+ def self.compile_all_serializers
66
+ found = []
67
+ pending = Panko::Serializer.subclasses
68
+ until pending.empty?
69
+ serializer = pending.shift
70
+ next if serializer.method_defined?(:serialize_one)
71
+ pending.concat(serializer.subclasses)
72
+ next if serializer._cg_attributes.empty? &&
73
+ serializer._cg_method_attributes.empty? &&
74
+ serializer._cg_associations.empty?
75
+ found << serializer
76
+ end
77
+ found
78
+ end
79
+ private_class_method :compile_all_serializers
80
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko
4
+ # What one {Panko.compile_all} call compiled. Every serializer it visited
5
+ # is in exactly one of the four fields.
6
+ #
7
+ # @!attribute [r] compiled
8
+ # @return [Array<Class>] serializers with the base and a specialized
9
+ # variant for every declared model, in every requested mode
10
+ # @!attribute [r] without_models
11
+ # @return [Array<Class>] serializers that declare no {Serializer.models}:
12
+ # only the base is compiled; ActiveRecord record classes can still
13
+ # specialize on first serialize
14
+ # @!attribute [r] not_specialized
15
+ # @return [Hash{Class => Array<Class>}] serializer to the declared models
16
+ # that got no specialized variant
17
+ # @!attribute [r] errors
18
+ # @return [Hash{Class => StandardError}] serializers whose base compile
19
+ # raised; their first serialize compiles again
20
+ CompileAllResult = Data.define(:compiled, :without_models, :not_specialized, :errors)
21
+ end