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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../code_gen"
4
+ require_relative "filter_adapter"
5
+
6
+ module Panko
7
+ module CodeGen
8
+ # Builds a serializer class's Descriptor from its DSL declarations. Nested Descriptors are
9
+ # built when the association is declared, so +build+ never recurses.
10
+ module DescriptorBuilder
11
+ module_function
12
+
13
+ def build(serializer_class)
14
+ Descriptor.new(
15
+ name: descriptor_name(serializer_class),
16
+ model: nil,
17
+ attributes: serializer_class._cg_attributes.dup,
18
+ method_attributes: serializer_class._cg_method_attributes.dup,
19
+ associations: serializer_class._cg_associations.dup,
20
+ parent_class: serializer_class
21
+ )
22
+ end
23
+
24
+ # "::" is replaced because the name becomes a constant inside an anonymous namespace, where
25
+ # a qualified name would reopen the real outer module.
26
+ def descriptor_name(serializer_class)
27
+ name = serializer_class.name || "PankoSerializer#{serializer_class.object_id}"
28
+ name.gsub("::", "__")
29
+ end
30
+
31
+ # The engine defines one Generated Class per Descriptor object, named after +name+.
32
+ # Equal Descriptors become one object, so two associations using one child serializer share
33
+ # a class and the child's method fields stay monomorphic on +self+. Different Descriptors
34
+ # with one name (a self-reference snapshot, a static filter, another Model) get a suffix.
35
+ # +shared+ is keyed before the final name is set, so equal subtrees give equal keys.
36
+ def uniquify_names(descriptor, seen = Hash.new(0), shared = {})
37
+ associations = descriptor.associations.map do |association|
38
+ association.with(
39
+ descriptor: uniquify_names(association.descriptor, seen, shared),
40
+ variants: association.variants.map { |variant| uniquify_names(variant, seen, shared) }
41
+ )
42
+ end
43
+ rebuilt = descriptor.with(associations: associations)
44
+ shared[rebuilt] ||= begin
45
+ seen[descriptor.name] += 1
46
+ count = seen[descriptor.name]
47
+ rebuilt.with(name: (count == 1) ? descriptor.name : "#{descriptor.name}_#{count}")
48
+ end
49
+ end
50
+
51
+ # Bakes a static association filter (+has_many :x, only: [...]+) into the cached Descriptor.
52
+ def narrow(descriptor, only, except)
53
+ return descriptor if blank?(only) && blank?(except)
54
+ narrow_by(descriptor, FilterAdapter.to_engine_filters(only, except))
55
+ end
56
+
57
+ # A child with no specializable class is left unchanged. Several declared classes
58
+ # become +Association#variants+, which share a name, so callers run {uniquify_names}.
59
+ # +seen+ keeps each (Descriptor, Model) result, so a child shared by several associations
60
+ # stays one object. A pair still in progress is a cycle and comes back unspecialized.
61
+ def specialize(descriptor, model, seen = {})
62
+ key = [descriptor.__id__, model]
63
+ return seen[key] || descriptor if seen.key?(key)
64
+ seen[key] = nil
65
+ associations = descriptor.associations.map do |association|
66
+ child_models = child_models(model, association)
67
+ case child_models.size
68
+ when 0 then association
69
+ when 1 then association.with(descriptor: specialize(association.descriptor, child_models.first, seen))
70
+ else
71
+ association.with(variants: child_models.map { |child_model| specialize(association.descriptor, child_model, seen) })
72
+ end
73
+ end
74
+ seen[key] = descriptor.with(model: model, associations: associations)
75
+ end
76
+
77
+ def child_models(model, association)
78
+ return [] unless association.descriptor.model.nil?
79
+ reflection = model.reflect_on_association(association.source) if model.respond_to?(:reflect_on_association)
80
+ return Array(reflected_child_model(reflection)) if reflection
81
+ declared_models(association.descriptor.parent_class).select do |klass|
82
+ specializable?(klass) && serves?(klass, association.descriptor)
83
+ end
84
+ end
85
+ private_class_method :child_models
86
+
87
+ # A declared model missing one of the sources would stop the whole parent from compiling,
88
+ # so it is left out and its records get the unspecialized child.
89
+ def serves?(klass, descriptor)
90
+ ActiveRecord::DefineAttributeMethods.ensure!(klass)
91
+ sources = descriptor.attributes.map(&:source) + descriptor.associations.map(&:source)
92
+ sources.all? do |source|
93
+ ActiveRecord::AccessClassifier.classify(klass, source)
94
+ true
95
+ rescue UnknownSourceError
96
+ false
97
+ end
98
+ end
99
+ private_class_method :serves?
100
+
101
+ def declared_models(serializer_class)
102
+ serializer_class.respond_to?(:_cg_models) ? Array(serializer_class._cg_models) : []
103
+ end
104
+ private_class_method :declared_models
105
+
106
+ def reflected_child_model(reflection)
107
+ return nil if reflection.polymorphic?
108
+ klass = begin
109
+ reflection.klass
110
+ rescue
111
+ # An undefined +class_name:+ or a badly declared association raises here; such a child
112
+ # is just not specializable.
113
+ nil
114
+ end
115
+ specializable?(klass) ? klass : nil
116
+ end
117
+ private_class_method :reflected_child_model
118
+
119
+ def specializable?(klass)
120
+ return false if klass.nil? || !resolvable_name?(klass)
121
+ klass.respond_to?(:columns_hash) && klass.respond_to?(:attribute_methods_generated?)
122
+ end
123
+ private_class_method :specializable?
124
+
125
+ # The specialized code checks +record.instance_of?(::<Name>)+, so the name must resolve
126
+ # to this same class object, or the check raises +NameError+ or never matches.
127
+ def self.resolvable_name?(klass)
128
+ name = klass.name
129
+ return false if name.nil?
130
+ Object.const_defined?(name) && Object.const_get(name).equal?(klass)
131
+ rescue NameError
132
+ false
133
+ end
134
+
135
+ def narrow_by(descriptor, engine)
136
+ only_set = engine[:only]&.to_set
137
+ except_set = engine[:except]&.to_set
138
+ descriptor.with(
139
+ attributes: descriptor.attributes.reject { |a| drop?(a.name, only_set, except_set) },
140
+ method_attributes: descriptor.method_attributes.reject { |m| drop?(m.name, only_set, except_set) },
141
+ associations: descriptor.associations.reject { |as| drop?(as.source, only_set, except_set) }.map do |as|
142
+ sub = engine[as.source]
143
+ (sub.is_a?(Hash) && !sub.empty?) ? as.with(descriptor: narrow_by(as.descriptor, sub)) : as
144
+ end
145
+ )
146
+ end
147
+ private_class_method :narrow_by
148
+
149
+ def drop?(name, only_set, except_set)
150
+ if only_set
151
+ !only_set.include?(name)
152
+ elsif except_set
153
+ except_set.include?(name)
154
+ else
155
+ false
156
+ end
157
+ end
158
+ private_class_method :drop?
159
+
160
+ def blank?(filter)
161
+ filter.nil? || (filter.respond_to?(:empty?) && filter.empty?)
162
+ end
163
+ private_class_method :blank?
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ class Dump
5
+ def initialize(descriptor, output:, config:, path:,
6
+ validator: Validators::Validator.new, generator: Generator.new)
7
+ @descriptor = descriptor
8
+ @output = output
9
+ @config = config
10
+ @path = path
11
+ @validator = validator
12
+ @generator = generator
13
+ end
14
+
15
+ def dump
16
+ validate_path!
17
+ @validator.validate(@descriptor, output: @output, config: @config)
18
+ if @descriptor.associations.empty?
19
+ write_flat
20
+ else
21
+ write_fan_out
22
+ end
23
+ @path
24
+ end
25
+
26
+ private
27
+
28
+ def validate_path!
29
+ unless @path.is_a?(String)
30
+ raise ArgumentError,
31
+ "path: must be a String, got #{@path.class} (#{@path.inspect})"
32
+ end
33
+ raise ArgumentError, "path: must not be empty" if @path.empty?
34
+ end
35
+
36
+ def write_flat
37
+ source = @generator.emit(@descriptor, output: @output, config: @config)
38
+ File.write(@path, source)
39
+ end
40
+
41
+ # Inner file paths are derived, never taken from the caller: +require_relative+
42
+ # lines name derived basenames. When the root is in a cycle, its peers
43
+ # +require_relative+ it by its derived name, so +path:+ must use that basename.
44
+ def write_fan_out
45
+ directory = File.dirname(@path)
46
+ Generators::Fanout.emit_files(@descriptor, output: @output, config: @config).each do |file|
47
+ target = file[:descriptor].equal?(@descriptor) ? @path : File.join(directory, file[:basename])
48
+ File.write(target, file[:source])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Errors raised by user code (method bodies, record reads) are not wrapped in it.
5
+ class Error < StandardError; end
6
+
7
+ # Raised while building a Descriptor or one of its parts with a wrong type or shape,
8
+ # and by Config for an invalid +hash_*_key_type+.
9
+ class DescriptorError < Error; end
10
+
11
+ class CompileError < Error; end
12
+
13
+ class NameCollisionError < CompileError; end
14
+
15
+ # Specialized path only: the source is neither a column nor a method on the model.
16
+ # On the generic path a missing method raises Ruby's own +NoMethodError+ at run time.
17
+ class UnknownSourceError < CompileError; end
18
+
19
+ # A method body or +if:+ callable whose arity is outside 0..3, variadic included.
20
+ class ArityError < CompileError; end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filters/none"
4
+ require_relative "filters/indexed"
5
+
6
+ module Panko::CodeGen
7
+ module Filter
8
+ # Validates every level here, once per call, so the generated per-record
9
+ # bodies need no validation branches.
10
+ def self.wrap(filters, field_index = nil)
11
+ return None if filters.nil? || filters.empty?
12
+ validate_no_only_except_co_supply!(filters)
13
+ raise ArgumentError, "Filter.wrap: a non-empty filters Hash requires a field_index (the Generated Class's FIELD_INDEX)" if field_index.nil?
14
+ Indexed.build(filters, field_index)
15
+ end
16
+
17
+ def self.validate_no_only_except_co_supply!(hash)
18
+ if hash.key?(:only) && hash.key?(:except)
19
+ raise ArgumentError,
20
+ "filters: cannot supply both :only and :except at the same level " \
21
+ "(got only: #{hash[:only].inspect}, except: #{hash[:except].inspect})"
22
+ end
23
+ hash.each_value do |value|
24
+ validate_no_only_except_co_supply!(value) if value.is_a?(Hash)
25
+ end
26
+ end
27
+ private_class_method :validate_no_only_except_co_supply!
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../code_gen"
4
+
5
+ module Panko
6
+ module CodeGen
7
+ # Translates Panko's +only:+/+except:+ filters into the engine's runtime Filter shape.
8
+ module FilterAdapter
9
+ module_function
10
+
11
+ def to_engine_filters(only, except)
12
+ only_attrs, only_assocs = split(only)
13
+ except_attrs, except_assocs = split(except)
14
+
15
+ result = {}
16
+ merge_level!(result, only_attrs, except_attrs)
17
+
18
+ (only_assocs.keys | except_assocs.keys).each do |source|
19
+ result[source] = to_engine_filters(only_assocs[source], except_assocs[source])
20
+ end
21
+
22
+ result
23
+ end
24
+
25
+ def split(filter)
26
+ case filter
27
+ when nil then [[], {}]
28
+ when ::Array then [filter, {}]
29
+ when ::Hash then [Array(filter[:instance]), filter.except(:instance)]
30
+ else raise ArgumentError, "filters must be an Array or Hash, got #{filter.class}"
31
+ end
32
+ end
33
+
34
+ # Filter.wrap raises when one level has both :only and :except, so both fold into :only.
35
+ def merge_level!(result, only_attrs, except_attrs)
36
+ if !only_attrs.empty?
37
+ result[:only] = only_attrs - except_attrs
38
+ elsif !except_attrs.empty?
39
+ result[:except] = except_attrs
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Filter
5
+ module Indexed
6
+ module_function
7
+
8
+ def build(hash, field_index)
9
+ n = field_index.size
10
+ only = hash[:only]
11
+ except = hash[:except]
12
+ only_set = only&.to_set
13
+ except_set = except&.to_set
14
+
15
+ arr = ::Array.new(n)
16
+ field_index.each do |name, i|
17
+ arr[i] = drop?(name, only_set, except_set)
18
+ end
19
+ Array.new(hash, arr)
20
+ end
21
+
22
+ def drop?(name, only_set, except_set)
23
+ if only_set
24
+ !only_set.include?(name)
25
+ elsif except_set
26
+ except_set.include?(name)
27
+ else
28
+ false
29
+ end
30
+ end
31
+
32
+ class Array
33
+ def initialize(hash, drops_array)
34
+ @hash = hash
35
+ @drops_array = drops_array
36
+ @children_cache = {}
37
+ end
38
+
39
+ def drops?(index)
40
+ @drops_array[index]
41
+ end
42
+
43
+ # Each cached entry keeps the +field_index+ it was built for (checked with
44
+ # +equal?+): two Associations may share a Source but nest different classes.
45
+ def child(source, field_index)
46
+ cached = @children_cache[source]
47
+ return cached[1] if cached && cached[0].equal?(field_index)
48
+ resolved = Indexed.resolve_child(@hash, source, field_index)
49
+ @children_cache[source] = [field_index, resolved]
50
+ resolved
51
+ end
52
+ end
53
+
54
+ # +Filter.wrap+ already validated every nested level, so no re-check here.
55
+ def resolve_child(hash, source, field_index)
56
+ sub = hash[source]
57
+ return None unless sub.is_a?(Hash) && !sub.empty?
58
+ build(sub, field_index)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Filter
5
+ # Takes the same arguments as +Indexed::Array+ so generated code calls
6
+ # one shape whether or not filters were given.
7
+ module None
8
+ def self.drops?(_index)
9
+ false
10
+ end
11
+
12
+ def self.child(_source, _field_index)
13
+ self
14
+ end
15
+
16
+ freeze
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ class Generator
5
+ OUTPUT_MODES = %i[json hash].freeze
6
+
7
+ # Sinks are stateless, so one frozen instance per mode serves every emit.
8
+ SINKS = {
9
+ json: Generators::JsonSink.new.freeze,
10
+ hash: Generators::HashSink.new.freeze
11
+ }.freeze
12
+
13
+ def self.sink_for(output)
14
+ SINKS.fetch(output) do
15
+ raise ArgumentError, "unknown output mode #{output.inspect}; must be one of #{OUTPUT_MODES.inspect}"
16
+ end
17
+ end
18
+
19
+ def emit(descriptor, output:, config:)
20
+ Generators::ClassEmitter.new(self.class.sink_for(output)).emit(descriptor, config)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ module Banner
6
+ def self.emit(builder, descriptor, output:, config:)
7
+ suffix = Compiler::OUTPUT_SUFFIXES.fetch(output)
8
+ snake = descriptor.name.gsub(/(?<=.)([A-Z])/, '_\1').downcase
9
+ builder.line "#"
10
+ builder.line "# Generated by #{GENERATOR_NAME}"
11
+ builder.line "# Descriptor: #{descriptor.name}"
12
+ builder.line "# Mode: :#{output}"
13
+ builder.line "# Config: #{render_config(config)}"
14
+ builder.line "#"
15
+ builder.line "# To use:"
16
+ builder.line %(# require_relative "#{snake}_#{output}.generated")
17
+ builder.line "# #{descriptor.name}_#{suffix}.new(descriptor: descriptor).serialize_one(record)"
18
+ builder.blank
19
+ end
20
+
21
+ def self.render_config(config)
22
+ config.to_h.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
23
+ end
24
+ private_class_method :render_config
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ class ClassEmitter
6
+ def initialize(sink)
7
+ @sink = sink
8
+ end
9
+
10
+ def emit(descriptor, config)
11
+ builder = CodeBuilder.new
12
+ builder.line "# frozen_string_literal: true"
13
+ builder.blank
14
+ Banner.emit(builder, descriptor, output: @sink.output, config: config)
15
+ cyclic_ids = CycleMembership.cyclic_descriptor_ids(descriptor)
16
+ DescriptorWalk.in_emit_order(descriptor).each_with_index do |desc, i|
17
+ builder.blank if i > 0
18
+ emit_class(desc, config, builder, cyclic_ids)
19
+ end
20
+ builder.to_s + "\n"
21
+ end
22
+
23
+ # Public so {Generators::Fanout} can write one class per file.
24
+ def emit_class(descriptor, config, builder, cyclic_ids)
25
+ field_index = FieldIndex.build(descriptor)
26
+ builder.line class_line(descriptor)
27
+ builder.indent do
28
+ builder.line "#{GeneratedNames.field_index_const} = #{FieldIndex.to_hash_literal(field_index)}.freeze"
29
+ @sink.emit_class_constants(descriptor, config, builder)
30
+ builder.blank
31
+ emit_initialize(descriptor, builder, cyclic_ids)
32
+ builder.blank
33
+ @sink.emit_serialize_one(config, builder)
34
+ builder.blank
35
+ @sink.emit_serialize_many(config, builder)
36
+ builder.blank
37
+ if @sink.output == :json
38
+ @sink.emit_serialize_to(config, builder)
39
+ builder.blank
40
+ end
41
+ Release.emit(descriptor, builder, cyclic_ids)
42
+ builder.blank
43
+ if descriptor.model.nil?
44
+ RecordAccess::Generic.emit(descriptor, config, field_index, builder, @sink)
45
+ else
46
+ RecordAccess::Specialized.emit(descriptor, config, field_index, builder, @sink)
47
+ end
48
+ if config.supports_root_key
49
+ builder.blank
50
+ emit_validate_root_key(builder)
51
+ end
52
+ end
53
+ builder.line "end"
54
+ end
55
+
56
+ private
57
+
58
+ # An anonymous parent class has no name to write into the source, so
59
+ # it is fetched from the +ANON_PARENTS+ constant instead.
60
+ def class_line(descriptor)
61
+ class_name = GeneratedNames.class_name(descriptor, @sink.suffix)
62
+ if descriptor.parent_class.name
63
+ "class #{class_name} < #{descriptor.parent_class.name}"
64
+ else
65
+ "class #{class_name} < ANON_PARENTS.fetch(#{descriptor.name.inspect})"
66
+ end
67
+ end
68
+
69
+ # A Descriptor in a recursion cycle registers +self+ in +_construct_cache+
70
+ # before building its children, so the cycle reuses this instance.
71
+ def emit_initialize(descriptor, builder, cyclic_ids)
72
+ cyclic_self = cyclic_ids[descriptor.__id__]
73
+ signature = cyclic_self ? "def initialize(descriptor:, _construct_cache: {})" : "def initialize(descriptor:)"
74
+ builder.line signature
75
+ builder.indent do
76
+ builder.line "_construct_cache[descriptor.__id__] = self" if cyclic_self
77
+ descriptor.method_attributes.each_with_index do |method_attribute, index|
78
+ next if method_attribute.body.is_a?(Symbol)
79
+ ivar = GeneratedNames.callable_ivar(method_attribute)
80
+ builder.line "#{ivar} = descriptor.method_attributes[#{index}].body"
81
+ end
82
+ descriptor.associations.each_with_index do |assoc, i|
83
+ if assoc.if
84
+ builder.line "#{GeneratedNames.if_guard_ivar(assoc)} = descriptor.associations[#{i}].if"
85
+ end
86
+ builder.line emit_serializer_assignment(descriptor, assoc, i, cyclic_ids)
87
+ assoc.variants.each_with_index do |variant, v|
88
+ builder.line "#{GeneratedNames.variant_serializer_ivar(assoc, v)} = " \
89
+ "#{GeneratedNames.class_name(variant, @sink.suffix)}.new(descriptor: descriptor.associations[#{i}].variants[#{v}])"
90
+ end
91
+ end
92
+ end
93
+ builder.line "end"
94
+ end
95
+
96
+ # The cache is passed only when both ends are cyclic: a child outside
97
+ # the cycle has a constructor without the +_construct_cache:+ kwarg.
98
+ def emit_serializer_assignment(descriptor, assoc, i, cyclic_ids)
99
+ ivar = GeneratedNames.serializer_ivar(assoc)
100
+ if assoc.descriptor.equal?(descriptor)
101
+ "#{ivar} = self"
102
+ elsif cyclic_ids[descriptor.__id__] && cyclic_ids[assoc.descriptor.__id__]
103
+ "#{ivar} = (_construct_cache[descriptor.associations[#{i}].descriptor.__id__] ||= " \
104
+ "#{GeneratedNames.class_name(assoc.descriptor, @sink.suffix)}.new(descriptor: descriptor.associations[#{i}].descriptor, _construct_cache: _construct_cache))"
105
+ else
106
+ "#{ivar} = #{GeneratedNames.class_name(assoc.descriptor, @sink.suffix)}.new(descriptor: descriptor.associations[#{i}].descriptor)"
107
+ end
108
+ end
109
+
110
+ def emit_validate_root_key(builder)
111
+ builder.line "private def validate_root_key!(root_key)"
112
+ builder.indent do
113
+ builder.line "return if root_key.nil? || (root_key.is_a?(String) && !root_key.empty?)"
114
+ builder.line %(raise ArgumentError, "root_key: must be a non-empty String, got \#{root_key.inspect}")
115
+ end
116
+ builder.line "end"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ # Only cycles of two or more need +_construct_cache:+; a self-loop uses +@<name>_serializer = self+.
6
+ # Tarjan's SCC: a DFS that skips finished nodes misses Y in A -> X -> A plus A -> Y -> X -> A.
7
+ module CycleMembership
8
+ module_function
9
+
10
+ def cyclic_descriptor_ids(root)
11
+ cyclic = {}
12
+ index = {}
13
+ lowlink = {}
14
+ on_stack = {}
15
+ scc_stack = []
16
+ next_index = [0]
17
+ strongconnect(root, cyclic, index, lowlink, on_stack, scc_stack, next_index)
18
+ cyclic
19
+ end
20
+
21
+ # +next_index+ is a one-element Array so recursive calls share the counter.
22
+ def strongconnect(v, cyclic, index, lowlink, on_stack, scc_stack, next_index)
23
+ vid = v.__id__
24
+ index[vid] = next_index[0]
25
+ lowlink[vid] = next_index[0]
26
+ next_index[0] += 1
27
+ scc_stack.push(v)
28
+ on_stack[vid] = true
29
+
30
+ v.associations.flat_map(&:descriptors).each do |w|
31
+ next if w.equal?(v)
32
+ wid = w.__id__
33
+ if !index.key?(wid)
34
+ strongconnect(w, cyclic, index, lowlink, on_stack, scc_stack, next_index)
35
+ lowlink[vid] = lowlink[wid] if lowlink[wid] < lowlink[vid]
36
+ elsif on_stack[wid]
37
+ lowlink[vid] = index[wid] if index[wid] < lowlink[vid]
38
+ end
39
+ end
40
+
41
+ return unless lowlink[vid] == index[vid]
42
+ scc = []
43
+ loop do
44
+ w = scc_stack.pop
45
+ on_stack.delete(w.__id__)
46
+ scc << w
47
+ break if w.equal?(v)
48
+ end
49
+ scc.each { |w| cyclic[w.__id__] = true } if scc.size > 1
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module Generators
5
+ module DescriptorWalk
6
+ module_function
7
+
8
+ def in_emit_order(root)
9
+ order = []
10
+ visit(root, order, {})
11
+ order
12
+ end
13
+
14
+ def visit(descriptor, order, seen)
15
+ return if seen[descriptor.__id__]
16
+ seen[descriptor.__id__] = true
17
+ descriptor.associations.each { |assoc| assoc.descriptors.each { |child| visit(child, order, seen) } }
18
+ order << descriptor
19
+ end
20
+ end
21
+ end
22
+ end