panko_serializer 0.8.4 → 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 +8 -6
- 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 -99
- data/.clang-format +0 -3
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/database_matrix.yml +0 -85
- data/.github/workflows/docs.yml +0 -39
- data/.github/workflows/lint.yml +0 -30
- data/.github/workflows/tests.yml +0 -34
- data/.gitignore +0 -20
- data/.rspec +0 -2
- data/.rubocop.yml +0 -37
- data/Appraisals +0 -28
- data/Gemfile +0 -37
- data/Rakefile +0 -93
- data/benchmarks/BENCHMARKS.md +0 -48
- data/benchmarks/allocs.rb +0 -23
- data/benchmarks/app.rb +0 -11
- data/benchmarks/benchmarking_support.rb +0 -44
- data/benchmarks/bm_ams_0_10.rb +0 -43
- data/benchmarks/bm_object_writer.rb +0 -65
- data/benchmarks/bm_panko_json.rb +0 -60
- data/benchmarks/bm_panko_object.rb +0 -46
- data/benchmarks/bm_plain_object.rb +0 -99
- data/benchmarks/bm_serialization_descriptor.rb +0 -76
- data/benchmarks/bm_serializer_resolver.rb +0 -22
- data/benchmarks/bm_to_object.rb +0 -81
- data/benchmarks/profile.rb +0 -86
- data/benchmarks/sanity.rb +0 -83
- data/benchmarks/setup.rb +0 -56
- data/benchmarks/type_casts/bm_active_record.rb +0 -58
- data/benchmarks/type_casts/bm_panko.rb +0 -65
- data/benchmarks/type_casts/support.rb +0 -27
- data/docs/docs/associations.md +0 -107
- data/docs/docs/attributes.md +0 -150
- data/docs/docs/design-choices.md +0 -127
- data/docs/docs/getting-started.md +0 -70
- data/docs/docs/introduction.md +0 -13
- data/docs/docs/performance.md +0 -32
- data/docs/docs/response-bag.md +0 -83
- data/docs/docusaurus.config.js +0 -86
- data/docs/package-lock.json +0 -17872
- data/docs/package.json +0 -18
- data/docs/sidebars.json +0 -15
- data/docs/src/css/customTheme.css +0 -9
- data/docs/static/.DS_Store +0 -0
- data/docs/static/CNAME +0 -1
- data/docs/static/css/custom.css +0 -51
- data/docs/static/img/favicon.ico +0 -0
- data/docs/static/img/oss_logo.png +0 -0
- data/docs/static/img/undraw_code_review.svg +0 -1
- data/docs/static/img/undraw_monitor.svg +0 -1
- data/docs/static/img/undraw_note_list.svg +0 -1
- data/docs/static/img/undraw_online.svg +0 -1
- data/docs/static/img/undraw_open_source.svg +0 -1
- data/docs/static/img/undraw_operating_system.svg +0 -1
- data/docs/static/img/undraw_react.svg +0 -1
- data/docs/static/img/undraw_tweetstorm.svg +0 -1
- data/docs/static/img/undraw_youtube_tutorial.svg +0 -1
- data/docs/static/index.html +0 -14
- 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 -398
- 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/gemfiles/7.2.0.gemfile +0 -41
- data/gemfiles/7.2.0.gemfile.lock +0 -221
- data/gemfiles/8.0.0.gemfile +0 -41
- data/gemfiles/8.0.0.gemfile.lock +0 -239
- data/gemfiles/8.1.0.gemfile +0 -41
- data/gemfiles/8.1.0.gemfile.lock +0 -248
- 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
- data/panko_serializer.gemspec +0 -36
|
@@ -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
|