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.
- checksums.yaml +4 -4
- data/README.md +6 -4
- data/lib/panko/array_serializer.rb +55 -16
- data/lib/panko/code_gen/active_record/access_classifier.rb +65 -0
- data/lib/panko/code_gen/active_record/define_attribute_methods.rb +13 -0
- data/lib/panko/code_gen/code_builder.rb +32 -0
- data/lib/panko/code_gen/compile_cache.rb +28 -0
- data/lib/panko/code_gen/compiler.rb +56 -0
- data/lib/panko/code_gen/config.rb +79 -0
- data/lib/panko/code_gen/datetime_format.rb +38 -0
- data/lib/panko/code_gen/descriptor.rb +136 -0
- data/lib/panko/code_gen/descriptor_builder.rb +166 -0
- data/lib/panko/code_gen/dump.rb +52 -0
- data/lib/panko/code_gen/errors.rb +21 -0
- data/lib/panko/code_gen/filter.rb +29 -0
- data/lib/panko/code_gen/filter_adapter.rb +44 -0
- data/lib/panko/code_gen/filters/indexed.rb +62 -0
- data/lib/panko/code_gen/filters/none.rb +19 -0
- data/lib/panko/code_gen/generator.rb +23 -0
- data/lib/panko/code_gen/generators/banner.rb +27 -0
- data/lib/panko/code_gen/generators/class_emitter.rb +120 -0
- data/lib/panko/code_gen/generators/cycle_membership.rb +53 -0
- data/lib/panko/code_gen/generators/descriptor_walk.rb +22 -0
- data/lib/panko/code_gen/generators/fanout.rb +60 -0
- data/lib/panko/code_gen/generators/field_index.rb +31 -0
- data/lib/panko/code_gen/generators/field_walk.rb +38 -0
- data/lib/panko/code_gen/generators/generated_names.rb +73 -0
- data/lib/panko/code_gen/generators/hash_sink.rb +217 -0
- data/lib/panko/code_gen/generators/json_sink.rb +290 -0
- data/lib/panko/code_gen/generators/record_access/generic.rb +92 -0
- data/lib/panko/code_gen/generators/record_access/specialized.rb +90 -0
- data/lib/panko/code_gen/generators/release.rb +60 -0
- data/lib/panko/code_gen/generators/sink.rb +80 -0
- data/lib/panko/code_gen/instance_pool.rb +27 -0
- data/lib/panko/code_gen/runtime.rb +27 -0
- data/lib/panko/code_gen/serializer_cache.rb +242 -0
- data/lib/panko/code_gen/validators/callable_arity.rb +42 -0
- data/lib/panko/code_gen/validators/name_uniqueness.rb +44 -0
- data/lib/panko/code_gen/validators/source_resolution.rb +48 -0
- data/lib/panko/code_gen/validators/validator.rb +19 -0
- data/lib/panko/code_gen/writers_pool.rb +51 -0
- data/lib/panko/code_gen.rb +82 -0
- data/lib/panko/compile_all.rb +80 -0
- data/lib/panko/compile_all_result.rb +21 -0
- data/lib/panko/config.rb +71 -0
- data/lib/panko/descriptor.rb +93 -0
- data/lib/panko/response.rb +40 -8
- data/lib/panko/serializer.rb +175 -91
- data/lib/panko/serializer_resolver.rb +2 -8
- data/lib/panko/version.rb +1 -1
- data/lib/panko_serializer.rb +3 -5
- metadata +49 -35
- data/ext/panko_serializer/attributes_writer/active_record.c +0 -181
- data/ext/panko_serializer/attributes_writer/active_record.h +0 -17
- data/ext/panko_serializer/attributes_writer/attributes_writer.c +0 -55
- data/ext/panko_serializer/attributes_writer/attributes_writer.h +0 -35
- data/ext/panko_serializer/attributes_writer/common.c +0 -10
- data/ext/panko_serializer/attributes_writer/common.h +0 -9
- data/ext/panko_serializer/attributes_writer/hash.c +0 -13
- data/ext/panko_serializer/attributes_writer/hash.h +0 -7
- data/ext/panko_serializer/attributes_writer/plain.c +0 -13
- data/ext/panko_serializer/attributes_writer/plain.h +0 -7
- data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.c +0 -156
- data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.h +0 -10
- data/ext/panko_serializer/attributes_writer/type_cast/type_cast.c +0 -409
- data/ext/panko_serializer/attributes_writer/type_cast/type_cast.h +0 -81
- data/ext/panko_serializer/common.h +0 -9
- data/ext/panko_serializer/extconf.rb +0 -28
- data/ext/panko_serializer/panko_serializer.c +0 -181
- data/ext/panko_serializer/panko_serializer.h +0 -12
- data/ext/panko_serializer/serialization_descriptor/association.c +0 -90
- data/ext/panko_serializer/serialization_descriptor/association.h +0 -20
- data/ext/panko_serializer/serialization_descriptor/attribute.c +0 -96
- data/ext/panko_serializer/serialization_descriptor/attribute.h +0 -27
- data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.c +0 -177
- data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.h +0 -30
- data/lib/panko/association.rb +0 -17
- data/lib/panko/attribute.rb +0 -29
- data/lib/panko/object_writer.rb +0 -68
- 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
|