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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ class JsonSink < Sink
6
+ def output
7
+ :json
8
+ end
9
+
10
+ def suffix
11
+ "JSON"
12
+ end
13
+
14
+ def entry_name
15
+ GeneratedNames.write_one
16
+ end
17
+
18
+ def generic_entry_name
19
+ GeneratedNames.generic_write_one
20
+ end
21
+
22
+ def split_hash_helper
23
+ GeneratedNames.write_one_hash
24
+ end
25
+
26
+ def split_object_helper
27
+ GeneratedNames.write_one_object
28
+ end
29
+
30
+ def entry_params
31
+ "record, writer, context, scope, filters"
32
+ end
33
+
34
+ def emit_class_constants(descriptor, config, builder)
35
+ return unless config.pool_writer
36
+ subclass = defined?(ActiveSupport::IsolatedExecutionState) ? "IsolatedExecutionState" : "ThreadLocal"
37
+ builder.line "POOL = Panko::CodeGen::WritersPool::#{subclass}.new"
38
+ end
39
+
40
+ def emit_serialize_one(config, builder)
41
+ signature = config.supports_root_key ?
42
+ "def serialize_one(record, context: nil, scope: nil, filters: nil, root_key: nil)" :
43
+ "def serialize_one(record, context: nil, scope: nil, filters: nil)"
44
+ builder.line signature
45
+ builder.indent do
46
+ builder.line "filters = Panko::CodeGen::Filter.wrap(filters, #{GeneratedNames.field_index_const})"
47
+ if config.supports_root_key
48
+ builder.line "validate_root_key!(root_key)"
49
+ end
50
+ with_writer(config, builder) { emit_serialize_one_body(config, builder) }
51
+ end
52
+ builder.line "end"
53
+ end
54
+
55
+ def emit_serialize_many(config, builder)
56
+ signature = config.supports_root_key ?
57
+ "def serialize_many(records, context: nil, scope: nil, filters: nil, root_key: nil)" :
58
+ "def serialize_many(records, context: nil, scope: nil, filters: nil)"
59
+ builder.line signature
60
+ builder.indent do
61
+ builder.line "filters = Panko::CodeGen::Filter.wrap(filters, #{GeneratedNames.field_index_const})"
62
+ if config.supports_root_key
63
+ builder.line "validate_root_key!(root_key)"
64
+ end
65
+ with_writer(config, builder) { emit_serialize_many_body(config, builder) }
66
+ end
67
+ builder.line "end"
68
+ end
69
+
70
+ # Write into a writer the caller owns, so a JSON document that holds this output
71
+ # (Panko::Response) gets it without a String copy. No pooled writer, no root key.
72
+ def emit_serialize_to(config, builder)
73
+ builder.line "def serialize_one_to(writer, record, key = nil, context: nil, scope: nil, filters: nil)"
74
+ builder.indent do
75
+ builder.line "filters = Panko::CodeGen::Filter.wrap(filters, #{GeneratedNames.field_index_const})"
76
+ builder.line "writer.push_key(key) if key"
77
+ builder.line "#{GeneratedNames.write_one}(record, writer, context, scope, filters)"
78
+ builder.line "nil"
79
+ end
80
+ builder.line "end"
81
+ builder.blank
82
+ builder.line "def serialize_many_to(writer, records, key = nil, context: nil, scope: nil, filters: nil)"
83
+ builder.indent do
84
+ builder.line "filters = Panko::CodeGen::Filter.wrap(filters, #{GeneratedNames.field_index_const})"
85
+ builder.line "writer.push_array(key)"
86
+ builder.line "records.each { |r| #{GeneratedNames.write_one}(r, writer, context, scope, filters) }"
87
+ builder.line "writer.pop"
88
+ builder.line "nil"
89
+ end
90
+ builder.line "end"
91
+ end
92
+
93
+ def open_record(builder)
94
+ builder.line "writer.push_object"
95
+ end
96
+
97
+ def close_record(builder)
98
+ builder.line "writer.pop"
99
+ end
100
+
101
+ # +cast:+ exists to match {HashSink#attribute} and is ignored: Oj's +:rails+ mode formats
102
+ # JSON values.
103
+ def attribute(attribute, read_expr, config, index, builder, cast: true)
104
+ builder.line "unless filters.drops?(#{index})"
105
+ builder.indent do
106
+ builder.line %(writer.push_value(#{read_expr}, "#{attribute.name}"))
107
+ end
108
+ builder.line "end"
109
+ end
110
+
111
+ def specialized_attribute(attribute, ar_model, config, index, builder)
112
+ if RecordAccess::Specialized.json_column_attribute?(attribute, ar_model)
113
+ json_column_attribute(attribute, config, index, builder)
114
+ elsif RecordAccess::Specialized.datetime_column_attribute?(attribute, ar_model)
115
+ datetime_column_attribute(attribute, index, builder)
116
+ else
117
+ attribute(attribute, RecordAccess::Specialized.attribute_read_expr(attribute, ar_model), config, index, builder)
118
+ end
119
+ end
120
+
121
+ # +equal?+, not +==+, so an object that overrides +==+ cannot match +SKIP+.
122
+ def method_attribute(method_attribute, config, index, builder)
123
+ builder.line "unless filters.drops?(#{index})"
124
+ builder.indent do
125
+ builder.line "value = #{method_attribute_call_expression(method_attribute)}"
126
+ builder.line "unless value.equal?(Panko::CodeGen::SKIP)"
127
+ builder.indent do
128
+ builder.line %(writer.push_value(value, "#{method_attribute.name}"))
129
+ end
130
+ builder.line "end"
131
+ end
132
+ builder.line "end"
133
+ end
134
+
135
+ def association(association, source_read_expr, config, index, builder)
136
+ builder.line "unless filters.drops?(#{index})"
137
+ builder.indent do
138
+ with_if_guard(association, builder) do
139
+ case association.kind
140
+ when :has_one
141
+ builder.line "value = #{source_read_expr}"
142
+ if config.null_for_missing_has_one
143
+ has_one_default(association, builder)
144
+ else
145
+ has_one_omit(association, builder)
146
+ end
147
+ when :has_many
148
+ has_many(association, source_read_expr, builder)
149
+ end
150
+ end
151
+ end
152
+ builder.line "end"
153
+ end
154
+
155
+ private
156
+
157
+ # +result+ starts nil so a body that raises hands checkin nil, and the writer is dropped.
158
+ def with_writer(config, builder, &block)
159
+ if config.pool_writer
160
+ builder.line "writer = POOL.checkout"
161
+ builder.line "result = nil"
162
+ builder.line "begin"
163
+ builder.indent(&block)
164
+ builder.line "ensure"
165
+ builder.indent do
166
+ builder.line "POOL.checkin(writer, result)"
167
+ end
168
+ builder.line "end"
169
+ else
170
+ builder.line "writer = Oj::StringWriter.new(mode: :rails)"
171
+ yield
172
+ end
173
+ end
174
+
175
+ def emit_serialize_one_body(config, builder)
176
+ if config.supports_root_key
177
+ builder.line "if root_key"
178
+ builder.indent do
179
+ builder.line "writer.push_object"
180
+ builder.line "writer.push_key(root_key)"
181
+ end
182
+ builder.line "end"
183
+ end
184
+ builder.line "#{GeneratedNames.write_one}(record, writer, context, scope, filters)"
185
+ builder.line "writer.pop if root_key" if config.supports_root_key
186
+ builder.line "result = writer.to_s"
187
+ builder.line "result.chomp!"
188
+ builder.line "result"
189
+ end
190
+
191
+ def emit_serialize_many_body(config, builder)
192
+ if config.supports_root_key
193
+ builder.line "writer.push_object if root_key"
194
+ builder.line "writer.push_array(root_key)"
195
+ else
196
+ builder.line "writer.push_array"
197
+ end
198
+ builder.line "records.each { |r| #{GeneratedNames.write_one}(r, writer, context, scope, filters) }"
199
+ builder.line "writer.pop"
200
+ builder.line "writer.pop if root_key" if config.supports_root_key
201
+ builder.line "result = writer.to_s"
202
+ builder.line "result.chomp!"
203
+ builder.line "result"
204
+ end
205
+
206
+ # A non-nil +has_one+ pushes its key separately instead of +push_object("name")+: the
207
+ # child's +_write_one+ opens its own +push_object+ frame.
208
+ def has_one_default(association, builder)
209
+ builder.line "if value.nil?"
210
+ builder.indent { builder.line %(writer.push_value(nil, "#{association.name}")) }
211
+ builder.line "else"
212
+ builder.indent do
213
+ builder.line %(writer.push_key("#{association.name}"))
214
+ child_write(association, "value", builder) do |ivar|
215
+ "#{ivar}.#{GeneratedNames.write_one}(value, writer, context, scope, #{child_filter_expr(association)})"
216
+ end
217
+ end
218
+ builder.line "end"
219
+ end
220
+
221
+ def has_one_omit(association, builder)
222
+ builder.line "unless value.nil?"
223
+ builder.indent do
224
+ builder.line %(writer.push_key("#{association.name}"))
225
+ child_write(association, "value", builder) do |ivar|
226
+ "#{ivar}.#{GeneratedNames.write_one}(value, writer, context, scope, #{child_filter_expr(association)})"
227
+ end
228
+ end
229
+ builder.line "end"
230
+ end
231
+
232
+ def has_many(association, source_read_expr, builder)
233
+ builder.line "child_filter = #{child_filter_expr(association)}"
234
+ builder.line %(writer.push_array("#{association.name}"))
235
+ builder.line "#{source_read_expr}.each do |element|"
236
+ builder.indent do
237
+ child_write(association, "element", builder) do |ivar|
238
+ "#{ivar}.#{GeneratedNames.write_one}(element, writer, context, scope, child_filter)"
239
+ end
240
+ end
241
+ builder.line "end"
242
+ builder.line "writer.pop"
243
+ end
244
+
245
+ # Raw column bytes are pushed only after a strict parse accepts them; anything else
246
+ # (non-String, empty, malformed) falls back to the typed read.
247
+ def json_column_attribute(attribute, config, index, builder)
248
+ if config.json_column_emit == :html_safe
249
+ attribute(attribute, %(record._read_attribute("#{attribute.source}")), config, index, builder)
250
+ return
251
+ end
252
+ source_lit = %("#{attribute.source}")
253
+ key_lit = %("#{attribute.name}")
254
+ builder.line "unless filters.drops?(#{index})"
255
+ builder.indent do
256
+ builder.line %(raw = record.read_attribute_before_type_cast(#{source_lit}))
257
+ builder.line "if raw.is_a?(String) && !raw.empty? && (begin"
258
+ builder.indent do
259
+ builder.line "Oj.sc_parse(Panko::CodeGen::JSON_NOOP_PARSER, raw, Panko::CodeGen::JSON_STRICT_PARSE_OPTS)"
260
+ builder.line "true"
261
+ end
262
+ builder.line "rescue Oj::ParseError, EncodingError"
263
+ builder.indent do
264
+ builder.line "false"
265
+ end
266
+ builder.line "end)"
267
+ builder.indent do
268
+ builder.line "writer.push_json(raw, #{key_lit})"
269
+ end
270
+ builder.line "else"
271
+ builder.indent do
272
+ builder.line "writer.push_value(record._read_attribute(#{source_lit}), #{key_lit})"
273
+ end
274
+ builder.line "end"
275
+ end
276
+ builder.line "end"
277
+ end
278
+
279
+ def datetime_column_attribute(attribute, index, builder)
280
+ source_lit = %("#{attribute.source}")
281
+ builder.line "unless filters.drops?(#{index})"
282
+ builder.indent do
283
+ builder.line "value = Panko::CodeGen::DateTimeFormat.format_raw(record.read_attribute_before_type_cast(#{source_lit}))"
284
+ builder.line %(writer.push_value(value || record._read_attribute(#{source_lit}), "#{attribute.name}"))
285
+ end
286
+ builder.line "end"
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ module RecordAccess
6
+ # Both field bodies are inlined under one +is_a?(Hash)+ branch: this saves a call per record,
7
+ # and each arm owns its call sites, so a Hash and an object never share one inline cache.
8
+ module Generic
9
+ # Above this field count the two bodies go into separate helper methods, which halves each
10
+ # method's size for JITs that compile whole methods (ZJIT).
11
+ FUSED_DISPATCH_MAX_FIELDS = 64
12
+
13
+ def self.emit(descriptor, config, field_index, builder, sink, method_name: sink.entry_name)
14
+ return emit_split(descriptor, config, field_index, builder, sink, method_name: method_name) if split_dispatch?(descriptor)
15
+
16
+ builder.line "def #{method_name}(#{sink.entry_params})"
17
+ builder.indent do
18
+ emit_parent_class_ivar_writes(descriptor, builder)
19
+ builder.line "if record.is_a?(Hash)"
20
+ builder.indent do
21
+ FieldWalk.emit_fields(
22
+ descriptor, config, field_index, builder, sink,
23
+ read_expr: ->(source) { hash_read_expr(source, config) }
24
+ )
25
+ end
26
+ builder.line "else"
27
+ builder.indent do
28
+ FieldWalk.emit_fields(
29
+ descriptor, config, field_index, builder, sink,
30
+ read_expr: ->(source) { "record.#{source}" }
31
+ )
32
+ end
33
+ builder.line "end"
34
+ end
35
+ builder.line "end"
36
+ end
37
+
38
+ def self.emit_split(descriptor, config, field_index, builder, sink, method_name: sink.entry_name)
39
+ builder.line "def #{method_name}(#{sink.entry_params})"
40
+ builder.indent do
41
+ emit_parent_class_ivar_writes(descriptor, builder)
42
+ builder.line "if record.is_a?(Hash)"
43
+ builder.indent { builder.line "#{sink.split_hash_helper}(#{sink.entry_params})" }
44
+ builder.line "else"
45
+ builder.indent { builder.line "#{sink.split_object_helper}(#{sink.entry_params})" }
46
+ builder.line "end"
47
+ end
48
+ builder.line "end"
49
+ builder.blank
50
+ builder.line "def #{sink.split_hash_helper}(#{sink.entry_params})"
51
+ builder.indent do
52
+ FieldWalk.emit_fields(
53
+ descriptor, config, field_index, builder, sink,
54
+ read_expr: ->(source) { hash_read_expr(source, config) }
55
+ )
56
+ end
57
+ builder.line "end"
58
+ builder.blank
59
+ builder.line "def #{sink.split_object_helper}(#{sink.entry_params})"
60
+ builder.indent do
61
+ FieldWalk.emit_fields(
62
+ descriptor, config, field_index, builder, sink,
63
+ read_expr: ->(source) { "record.#{source}" }
64
+ )
65
+ end
66
+ builder.line "end"
67
+ end
68
+
69
+ def self.split_dispatch?(descriptor)
70
+ descriptor.attributes.size + descriptor.method_attributes.size + descriptor.associations.size >
71
+ FUSED_DISPATCH_MAX_FIELDS
72
+ end
73
+
74
+ # Only a Symbol-body Method Attribute reads +@object+ / +@context+ / +@scope+ on +self+;
75
+ # Callable bodies get them as arguments, so without one the writes are wasted work per record.
76
+ def self.emit_parent_class_ivar_writes(descriptor, builder)
77
+ return if descriptor.method_attributes.none? { |method_attribute| method_attribute.body.is_a?(Symbol) }
78
+ builder.line "@object = record"
79
+ builder.line "@context = context"
80
+ builder.line "@scope = scope"
81
+ end
82
+
83
+ def self.hash_read_expr(source_name, config)
84
+ case config.hash_record_key_type
85
+ when :symbol then "record[:#{source_name}]"
86
+ else %(record["#{source_name}"])
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ module RecordAccess
6
+ # Records are assumed to be instances of the Descriptor's Model, so there is no Hash branch.
7
+ module Specialized
8
+ def self.emit(descriptor, config, field_index, builder, sink)
9
+ ar_model = ar_model(descriptor)
10
+ builder.line "def #{sink.entry_name}(#{sink.entry_params})"
11
+ builder.indent do
12
+ emit_model_guard(descriptor, config, "#{sink.generic_entry_name}(#{sink.entry_params})", builder)
13
+ Generic.emit_parent_class_ivar_writes(descriptor, builder)
14
+ FieldWalk.emit_fields(
15
+ descriptor, config, field_index, builder, sink,
16
+ read_expr: ->(source) { "record.#{source}" },
17
+ attribute_emit: ->(attribute, index) { sink.specialized_attribute(attribute, ar_model, config, index, builder) }
18
+ )
19
+ end
20
+ builder.line "end"
21
+ emit_generic_twin(config, builder) do
22
+ Generic.emit(descriptor, config, field_index, builder, sink, method_name: sink.generic_entry_name)
23
+ end
24
+ end
25
+
26
+ # Under auto-specialization nothing promises every record has the Model's class, so a record of
27
+ # another class (Hash, STI sibling, mixed array) goes to the generic twin instead of wrong output.
28
+ def self.emit_model_guard(descriptor, config, fallback_call, builder)
29
+ return unless config.guarded_model
30
+ model_name = descriptor.model.name
31
+ if model_name.nil?
32
+ raise CompileError,
33
+ "#{descriptor.name}: guarded_model requires a named Model class; got anonymous #{descriptor.model.inspect}"
34
+ end
35
+ builder.line "return #{fallback_call} unless record.instance_of?(::#{model_name})"
36
+ end
37
+
38
+ def self.emit_generic_twin(config, builder)
39
+ return unless config.guarded_model
40
+ builder.blank
41
+ yield
42
+ end
43
+
44
+ # A method the Model does not declare as an attribute reads the loaded attribute first, so a
45
+ # SELECT alias named like a method (+thing_ids+ next to +has_many :things+) returns the selected value.
46
+ def self.attribute_read_expr(attribute, ar_model)
47
+ source = attribute.source
48
+ return "record.#{source}" if ar_model.nil?
49
+ return %(record._read_attribute("#{source}")) if ActiveRecord::AccessClassifier.classify(ar_model, source) == :column
50
+ return "record.#{source}" if declared_attribute?(ar_model, source)
51
+ %((record._has_attribute?("#{source}") ? record._read_attribute("#{source}") : record.#{source}))
52
+ end
53
+
54
+ def self.declared_attribute?(ar_model, source)
55
+ ar_model.attribute_types.key?(source.to_s) || ar_model.attribute_aliases.key?(source.to_s)
56
+ end
57
+
58
+ # Called here too so the Generator works without the validators; repeating +ensure!+ is safe.
59
+ def self.ar_model(descriptor)
60
+ model = descriptor.model
61
+ return nil unless model.respond_to?(:columns_hash) && model.respond_to?(:attribute_methods_generated?)
62
+ ActiveRecord::DefineAttributeMethods.ensure!(model)
63
+ model
64
+ end
65
+
66
+ # Requires a +:column+ result because the column fast paths read past a user-overridden reader.
67
+ def self.json_column_attribute?(attribute, ar_model)
68
+ return false if ar_model.nil?
69
+ return false unless ActiveRecord::AccessClassifier.json_typed?(ar_model, attribute.source)
70
+ ActiveRecord::AccessClassifier.classify(ar_model, attribute.source) == :column
71
+ end
72
+
73
+ # Only under +:utc+ is the zoneless raw DB string really UTC, so the fast path's trailing "Z" is true.
74
+ # The +::+ matters: bare +ActiveRecord+ here resolves to +Panko::CodeGen::ActiveRecord+.
75
+ def self.datetime_column_attribute?(attribute, ar_model)
76
+ return false if ar_model.nil?
77
+ return false unless ::ActiveRecord.default_timezone == :utc
78
+ return false unless ActiveRecord::AccessClassifier.datetime_typed?(ar_model, attribute.source)
79
+ ActiveRecord::AccessClassifier.classify(ar_model, attribute.source) == :column
80
+ end
81
+
82
+ def self.plain_column_attribute?(attribute, ar_model)
83
+ return false if ar_model.nil?
84
+ return false unless ActiveRecord::AccessClassifier.plain_typed?(ar_model, attribute.source)
85
+ ActiveRecord::AccessClassifier.classify(ar_model, attribute.source) == :column
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ # A pooled instance that kept +@object+ / +@context+ / +@scope+ after
6
+ # checkin would keep the last record graph alive in fiber-local storage.
7
+ module Release
8
+ module_function
9
+
10
+ # Must match the gate in +RecordAccess::Generic.emit_parent_class_ivar_writes+.
11
+ def ivar_writes?(descriptor)
12
+ descriptor.method_attributes.any? { |method_attribute| method_attribute.body.is_a?(Symbol) }
13
+ end
14
+
15
+ # Must follow the same edges as {emit} so the answer matches the
16
+ # emitted code.
17
+ def subtree_releases?(descriptor, cyclic_ids, seen = {})
18
+ return false if seen[descriptor.__id__]
19
+ seen[descriptor.__id__] = true
20
+ return true if ivar_writes?(descriptor)
21
+ descriptor.associations.flat_map(&:descriptors).any? do |child|
22
+ next false if child.equal?(descriptor)
23
+ next false if cyclic_ids[descriptor.__id__] && cyclic_ids[child.__id__]
24
+ subtree_releases?(child, cyclic_ids, seen)
25
+ end
26
+ end
27
+
28
+ def child_ivars(assoc)
29
+ variants = assoc.variants.each_with_index.map do |variant, index|
30
+ [GeneratedNames.variant_serializer_ivar(assoc, index), variant]
31
+ end
32
+ [[GeneratedNames.serializer_ivar(assoc), assoc.descriptor], *variants]
33
+ end
34
+
35
+ # Every class defines +_release+, even an empty one: the serializers call it
36
+ # on every checkin without checking.
37
+ def emit(descriptor, builder, cyclic_ids)
38
+ builder.line "def _release"
39
+ builder.indent do
40
+ if ivar_writes?(descriptor)
41
+ builder.line "@object = nil"
42
+ builder.line "@context = nil"
43
+ builder.line "@scope = nil"
44
+ end
45
+ descriptor.associations.each do |assoc|
46
+ child_ivars(assoc).each do |ivar, child|
47
+ next if child.equal?(descriptor)
48
+ # Ends the chain without per-call visited state; such serializers keep their last record until the next call.
49
+ next if cyclic_ids[descriptor.__id__] && cyclic_ids[child.__id__]
50
+ next unless subtree_releases?(child, cyclic_ids)
51
+ builder.line "#{ivar}._release"
52
+ end
53
+ end
54
+ builder.line "nil"
55
+ end
56
+ builder.line "end"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ class Sink
6
+ # The child's +FIELD_INDEX+ is read through its class name when the method runs, so
7
+ # self-recursive Descriptors resolve it.
8
+ def child_filter_expr(association)
9
+ "filters.child(:#{GeneratedNames.filter_key(association)}, " \
10
+ "#{GeneratedNames.class_name(association.descriptor, suffix)}::#{GeneratedNames.field_index_const})"
11
+ end
12
+
13
+ # +case+/+when+, not an +if+/+elsif+ chain of +instance_of?+: under YJIT the chain gets
14
+ # slower once the call site has warmed on one class. HashSink uses the +case+ as an expression.
15
+ def child_write(association, record_expr, builder, &call_for)
16
+ base_call = call_for.call(GeneratedNames.serializer_ivar(association))
17
+ if association.variants.empty?
18
+ builder.line base_call
19
+ return
20
+ end
21
+ builder.line "case #{record_expr}"
22
+ subclass_first(association.variants).each do |variant, index|
23
+ model = "::#{variant.model.name}"
24
+ builder.line "when #{model}"
25
+ builder.indent do
26
+ builder.line "if #{record_expr}.instance_of?(#{model})"
27
+ builder.indent { builder.line call_for.call(GeneratedNames.variant_serializer_ivar(association, index)) }
28
+ builder.line "else"
29
+ builder.indent { builder.line base_call }
30
+ builder.line "end"
31
+ end
32
+ end
33
+ builder.line "else"
34
+ builder.indent { builder.line base_call }
35
+ builder.line "end"
36
+ end
37
+
38
+ # Each subclass before its ancestors, so a parent's +when+ arm does not take a declared
39
+ # subclass.
40
+ def subclass_first(variants)
41
+ variants.each_with_index.sort_by { |variant, index| [-variant.model.ancestors.size, index] }
42
+ end
43
+
44
+ # The guard wraps the Source read too, so a falsy +if:+ skips the read.
45
+ def with_if_guard(association, builder)
46
+ if association.if
47
+ builder.line "if #{if_guard_call_expression(GeneratedNames.if_guard_ivar(association), association.if.arity)}"
48
+ builder.indent { yield }
49
+ builder.line "end"
50
+ else
51
+ yield
52
+ end
53
+ end
54
+
55
+ # The +callable_arity+ rule limits arity to +0..3+, so +else+ is arity 3.
56
+ def if_guard_call_expression(ivar, arity)
57
+ case arity
58
+ when 0 then "#{ivar}.call"
59
+ when 1 then "#{ivar}.call(record)"
60
+ when 2 then "#{ivar}.call(record, context)"
61
+ else "#{ivar}.call(record, context, scope)"
62
+ end
63
+ end
64
+
65
+ # Symbol bodies need the explicit +self.+: a bare name such as +record+ or +value+ would
66
+ # resolve to a local of the generated method.
67
+ def method_attribute_call_expression(method_attribute)
68
+ body = method_attribute.body
69
+ return "self.#{body}" if body.is_a?(Symbol)
70
+ ivar = GeneratedNames.callable_ivar(method_attribute)
71
+ case body.arity
72
+ when 0 then "#{ivar}.call"
73
+ when 1 then "#{ivar}.call(record)"
74
+ when 2 then "#{ivar}.call(record, context)"
75
+ else "#{ivar}.call(record, context, scope)"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Fiber-local stack of Generated Class instances. An instance cannot be shared: writing a record
5
+ # can set +@object+/+@context+/+@scope+, so a nested serialize of the same class needs its own.
6
+ #
7
+ # After a code reload or SerializerCache.reset!, each fiber keeps its old stack (and the old
8
+ # class) until it ends. Accepted: reloads are a development feature and reset! is for tests.
9
+ class InstancePool
10
+ def initialize(key, compiled, descriptor)
11
+ # The pool's own object_id keeps a pool rebuilt after SerializerCache.reset! off the old
12
+ # pool's stacks, which hold instances of the old compiled class.
13
+ @key = :"#{key}_#{object_id}"
14
+ @compiled = compiled
15
+ @descriptor = descriptor
16
+ end
17
+
18
+ # Returns the stack itself so a checkout and checkin cost one +Thread.current+ lookup.
19
+ def stack
20
+ Thread.current[@key] ||= []
21
+ end
22
+
23
+ def build
24
+ @compiled.new(descriptor: @descriptor)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../code_gen"
4
+ require_relative "serializer_cache"
5
+ require_relative "filter_adapter"
6
+
7
+ module Panko
8
+ module CodeGen
9
+ module Runtime
10
+ module_function
11
+
12
+ def runtime_filters(serializer_class, context, scope, only, except)
13
+ if serializer_class.respond_to?(:filters_for)
14
+ overrides = serializer_class.filters_for(context, scope) || {}
15
+ only = overrides.fetch(:only, only)
16
+ except = overrides.fetch(:except, except)
17
+ end
18
+ return nil if blank?(only) && blank?(except)
19
+ FilterAdapter.to_engine_filters(blank?(only) ? nil : only, blank?(except) ? nil : except)
20
+ end
21
+
22
+ def blank?(filter)
23
+ filter.nil? || (filter.respond_to?(:empty?) && filter.empty?)
24
+ end
25
+ end
26
+ end
27
+ end