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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -6
  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 -99
  53. data/.clang-format +0 -3
  54. data/.github/dependabot.yml +0 -6
  55. data/.github/workflows/database_matrix.yml +0 -85
  56. data/.github/workflows/docs.yml +0 -39
  57. data/.github/workflows/lint.yml +0 -30
  58. data/.github/workflows/tests.yml +0 -34
  59. data/.gitignore +0 -20
  60. data/.rspec +0 -2
  61. data/.rubocop.yml +0 -37
  62. data/Appraisals +0 -28
  63. data/Gemfile +0 -37
  64. data/Rakefile +0 -93
  65. data/benchmarks/BENCHMARKS.md +0 -48
  66. data/benchmarks/allocs.rb +0 -23
  67. data/benchmarks/app.rb +0 -11
  68. data/benchmarks/benchmarking_support.rb +0 -44
  69. data/benchmarks/bm_ams_0_10.rb +0 -43
  70. data/benchmarks/bm_object_writer.rb +0 -65
  71. data/benchmarks/bm_panko_json.rb +0 -60
  72. data/benchmarks/bm_panko_object.rb +0 -46
  73. data/benchmarks/bm_plain_object.rb +0 -99
  74. data/benchmarks/bm_serialization_descriptor.rb +0 -76
  75. data/benchmarks/bm_serializer_resolver.rb +0 -22
  76. data/benchmarks/bm_to_object.rb +0 -81
  77. data/benchmarks/profile.rb +0 -86
  78. data/benchmarks/sanity.rb +0 -83
  79. data/benchmarks/setup.rb +0 -56
  80. data/benchmarks/type_casts/bm_active_record.rb +0 -58
  81. data/benchmarks/type_casts/bm_panko.rb +0 -65
  82. data/benchmarks/type_casts/support.rb +0 -27
  83. data/docs/docs/associations.md +0 -107
  84. data/docs/docs/attributes.md +0 -150
  85. data/docs/docs/design-choices.md +0 -127
  86. data/docs/docs/getting-started.md +0 -70
  87. data/docs/docs/introduction.md +0 -13
  88. data/docs/docs/performance.md +0 -32
  89. data/docs/docs/response-bag.md +0 -83
  90. data/docs/docusaurus.config.js +0 -86
  91. data/docs/package-lock.json +0 -17872
  92. data/docs/package.json +0 -18
  93. data/docs/sidebars.json +0 -15
  94. data/docs/src/css/customTheme.css +0 -9
  95. data/docs/static/.DS_Store +0 -0
  96. data/docs/static/CNAME +0 -1
  97. data/docs/static/css/custom.css +0 -51
  98. data/docs/static/img/favicon.ico +0 -0
  99. data/docs/static/img/oss_logo.png +0 -0
  100. data/docs/static/img/undraw_code_review.svg +0 -1
  101. data/docs/static/img/undraw_monitor.svg +0 -1
  102. data/docs/static/img/undraw_note_list.svg +0 -1
  103. data/docs/static/img/undraw_online.svg +0 -1
  104. data/docs/static/img/undraw_open_source.svg +0 -1
  105. data/docs/static/img/undraw_operating_system.svg +0 -1
  106. data/docs/static/img/undraw_react.svg +0 -1
  107. data/docs/static/img/undraw_tweetstorm.svg +0 -1
  108. data/docs/static/img/undraw_youtube_tutorial.svg +0 -1
  109. data/docs/static/index.html +0 -14
  110. data/ext/panko_serializer/attributes_writer/active_record.c +0 -181
  111. data/ext/panko_serializer/attributes_writer/active_record.h +0 -17
  112. data/ext/panko_serializer/attributes_writer/attributes_writer.c +0 -55
  113. data/ext/panko_serializer/attributes_writer/attributes_writer.h +0 -35
  114. data/ext/panko_serializer/attributes_writer/common.c +0 -10
  115. data/ext/panko_serializer/attributes_writer/common.h +0 -9
  116. data/ext/panko_serializer/attributes_writer/hash.c +0 -13
  117. data/ext/panko_serializer/attributes_writer/hash.h +0 -7
  118. data/ext/panko_serializer/attributes_writer/plain.c +0 -13
  119. data/ext/panko_serializer/attributes_writer/plain.h +0 -7
  120. data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.c +0 -156
  121. data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.h +0 -10
  122. data/ext/panko_serializer/attributes_writer/type_cast/type_cast.c +0 -398
  123. data/ext/panko_serializer/attributes_writer/type_cast/type_cast.h +0 -81
  124. data/ext/panko_serializer/common.h +0 -9
  125. data/ext/panko_serializer/extconf.rb +0 -28
  126. data/ext/panko_serializer/panko_serializer.c +0 -181
  127. data/ext/panko_serializer/panko_serializer.h +0 -12
  128. data/ext/panko_serializer/serialization_descriptor/association.c +0 -90
  129. data/ext/panko_serializer/serialization_descriptor/association.h +0 -20
  130. data/ext/panko_serializer/serialization_descriptor/attribute.c +0 -96
  131. data/ext/panko_serializer/serialization_descriptor/attribute.h +0 -27
  132. data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.c +0 -177
  133. data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.h +0 -30
  134. data/gemfiles/7.2.0.gemfile +0 -41
  135. data/gemfiles/7.2.0.gemfile.lock +0 -221
  136. data/gemfiles/8.0.0.gemfile +0 -41
  137. data/gemfiles/8.0.0.gemfile.lock +0 -239
  138. data/gemfiles/8.1.0.gemfile +0 -41
  139. data/gemfiles/8.1.0.gemfile.lock +0 -248
  140. data/lib/panko/association.rb +0 -17
  141. data/lib/panko/attribute.rb +0 -29
  142. data/lib/panko/object_writer.rb +0 -68
  143. data/lib/panko/serialization_descriptor.rb +0 -180
  144. data/panko_serializer.gemspec +0 -36
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b4397a54b1f806131ee07ccaaa4062bba8eb5490c3810e5c6c3cc405b7167ec
4
- data.tar.gz: 8f3127a38fe93844c3a82aa8e2a30ea82312ef1b0ebdf66f86bcd4761c392749
3
+ metadata.gz: 933667f4549f885acffbbd822422f568a0b5ba737b05bc2f333299e9d2ba3085
4
+ data.tar.gz: 20f6302ef51561dce29e0608ad2a22334c0d8625f62cf815d7d31fb9a30e49f6
5
5
  SHA512:
6
- metadata.gz: 626bf9689a4be25a166a7ee3fe3b2274cfcc9b7bb17d57398d87301aca1c8039835d02ef37de647d62d3fa5c102cc22f752b2400e10240fa992d2007eaf9344c
7
- data.tar.gz: 9a0048188abc83c9a064a0bb7721cc94a7f3965338f62a84fe4382a7b98f52941c1a1914f25dfee3bc7ed80168a1bb47f7def51954b9b1250029e4784f6f0a5d
6
+ metadata.gz: a520312057f8f41687f6051faff37eb2061ca2b4d4939d54631b52250e120277c920b455480ca0977d4112a7f80200b960948c85895377574f0b324c1be976ac
7
+ data.tar.gz: '0779d69d34164e6d6fd361909bc1a4395c047f80190f61753dbcff817bef8ddd415278dfccd863bfff53ff0d5558dce6b6137d8c47e3cd8a989ec7cac9860761'
data/README.md CHANGED
@@ -4,20 +4,22 @@
4
4
 
5
5
  Panko is a library which is inspired by ActiveModelSerializers 0.9 for serializing ActiveRecord/Ruby objects to JSON strings, fast.
6
6
 
7
- To achieve its [performance](https://panko.dev/performance/):
7
+ Panko is pure Ruby: no native extension, nothing to compile at install. It needs Ruby 3.4+ and ActiveSupport 7.2+. To achieve its [performance](https://panko.dev/performance):
8
8
 
9
- * Oj - Panko relies on Oj since it's fast and allows for incremental serialization using `Oj::StringWriter`
10
- * Serialization Descriptor - Panko computes most of the metadata ahead of time, to save time later in serialization.
11
- * Type casting — Panko does type casting by itself, instead of relying on ActiveRecord.
9
+ * Code generation: each serializer is compiled once into specialized, straight-line Ruby, so serialization runs no per-record introspection and YJIT optimizes it well.
10
+ * Auto-specialization: the first time a serializer meets an ActiveRecord class, Panko compiles a variant hard-wired to that model, reading values straight from its attribute storage.
11
+ * Oj: JSON is written incrementally using `Oj::StringWriter`.
12
12
 
13
- To dig deeper about the performance choices, read [Design Choices](https://panko.dev/design-choices/).
13
+ To dig deeper about the performance choices, read [Design Choices](https://panko.dev/design-choices).
14
+
15
+ Coming from 0.8 (the C extension)? Read [Upgrading from 0.8](https://panko.dev/upgrading).
14
16
 
15
17
 
16
18
  Support
17
19
  -------
18
20
 
19
21
  - [Documentation](https://panko.dev/)
20
- - [Getting Started](https://panko.dev/getting-started/)
22
+ - [Getting Started](https://panko.dev/getting-started)
21
23
 
22
24
  License
23
25
  -------
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "code_gen/runtime"
4
+
3
5
  module Panko
4
6
  class ArraySerializer
5
7
  attr_accessor :subjects
@@ -15,38 +17,75 @@ Please pass valid each_serializer to ArraySerializer, for example:
15
17
  }
16
18
  end
17
19
 
18
- serializer_options = {
19
- only: options.fetch(:only, []),
20
- except: options.fetch(:except, []),
21
- context: options[:context],
22
- scope: options[:scope]
23
- }
24
-
25
- @serialization_context = SerializationContext.create(options)
26
- @descriptor = Panko::SerializationDescriptor.build(@each_serializer, serializer_options, @serialization_context)
20
+ @context = options[:context]
21
+ @scope = options[:scope]
22
+ @only = options[:only]
23
+ @except = options[:except]
27
24
  end
28
25
 
29
26
  def to_json
30
- serialize_to_json @subjects
27
+ serialize_to_json(@subjects)
28
+ end
29
+
30
+ # Same contract as Panko::Serializer#descriptor.
31
+ def descriptor
32
+ each_serializer = @each_serializer
33
+ filters = if @only || @except || each_serializer._cg_has_filters_for
34
+ Panko::CodeGen::Runtime.runtime_filters(each_serializer, @context, @scope, @only, @except)
35
+ end
36
+ base = Panko::Descriptor.for(each_serializer)
37
+ filters ? Panko::Descriptor::Filtered.new(base, filters) : base
31
38
  end
32
39
 
33
40
  def serialize(subjects)
34
- serialize_with_writer(subjects, Panko::ObjectWriter.new).output
41
+ serialize_batch(subjects, :hash) do |instance, records, filters|
42
+ instance.serialize_many(records, context: @context, scope: @scope, filters: filters)
43
+ end
35
44
  end
36
45
 
37
46
  def to_a
38
- serialize_with_writer(@subjects, Panko::ObjectWriter.new).output
47
+ serialize(@subjects)
39
48
  end
40
49
 
41
50
  def serialize_to_json(subjects)
42
- serialize_with_writer(subjects, Oj::StringWriter.new(mode: :rails)).to_s
51
+ serialize_batch(subjects, :json) do |instance, records, filters|
52
+ instance.serialize_many(records, context: @context, scope: @scope, filters: filters)
53
+ end
54
+ end
55
+
56
+ # Writes the array into +writer+ (an +Oj::StringWriter+) as the value of +key+, or as the
57
+ # next element when +key+ is nil. The bytes match what +to_json+ returns.
58
+ def write_json(writer, key = nil)
59
+ serialize_batch(@subjects, :json) do |instance, records, filters|
60
+ instance.serialize_many_to(writer, records, key, context: @context, scope: @scope, filters: filters)
61
+ end
43
62
  end
44
63
 
45
64
  private
46
65
 
47
- def serialize_with_writer(subjects, writer)
48
- Panko.serialize_objects(subjects.to_a, writer, @descriptor)
49
- writer
66
+ # The pool is picked by the first record's class. A mixed-class array is
67
+ # safe: a specialized variant hands records of another class to its generic twin.
68
+ def serialize_batch(subjects, mode)
69
+ each_serializer = @each_serializer
70
+ records = subjects.to_a
71
+ model = records.first.class
72
+ cached = (mode == :hash) ? each_serializer._cg_last_hash : each_serializer._cg_last_json
73
+ pool = if cached && model.equal?(cached[0])
74
+ cached[1]
75
+ else
76
+ Panko::CodeGen::SerializerCache.variant_pool(each_serializer, mode, model)
77
+ end
78
+ filters = if @only || @except || each_serializer._cg_has_filters_for
79
+ Panko::CodeGen::Runtime.runtime_filters(each_serializer, @context, @scope, @only, @except)
80
+ end
81
+ stack = pool.stack
82
+ instance = stack.pop || pool.build
83
+ begin
84
+ yield instance, records, filters
85
+ ensure
86
+ instance._release
87
+ stack.push(instance)
88
+ end
50
89
  end
51
90
  end
52
91
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module ActiveRecord
5
+ # Columns read through AR's own generated reader classify as +:column+.
6
+ # A user override of a column reader classifies as +:method+, so the
7
+ # Specialized path never bypasses it. Call +DefineAttributeMethods.ensure!+
8
+ # first, or non-column readers (+attribute+, +alias_attribute+) raise +UnknownSourceError+.
9
+ module AccessClassifier
10
+ def self.classify(klass, source)
11
+ if klass.columns_hash.key?(source.to_s)
12
+ return user_override?(klass, source) ? :method : :column
13
+ end
14
+ return :method if klass.method_defined?(source)
15
+ raise UnknownSourceError,
16
+ "#{klass.name}: source :#{source} is not a column or instance method."
17
+ end
18
+
19
+ def self.user_override?(klass, source)
20
+ return false unless klass.method_defined?(source)
21
+ !generated_reader?(klass.instance_method(source).owner)
22
+ end
23
+ private_class_method :user_override?
24
+
25
+ # The name check matches the fake model classes the unit specs use; +is_a?+ matches real AR.
26
+ # +defined?+ lets this run when Active Record is not loaded.
27
+ def self.generated_reader?(owner)
28
+ return true if owner.name.to_s.end_with?("::GeneratedAttributeMethods")
29
+ defined?(::ActiveRecord::AttributeMethods::GeneratedAttributeMethods) &&
30
+ owner.is_a?(::ActiveRecord::AttributeMethods::GeneratedAttributeMethods)
31
+ end
32
+ private_class_method :generated_reader?
33
+
34
+ # +is_a?+ rather than the +#type+ symbol: +Type::Serialized+ and encrypted
35
+ # attribute types share the symbol but do not inherit from +Type::Json+.
36
+ def self.json_typed?(model, attribute_name)
37
+ model.type_for_attribute(attribute_name.to_s).is_a?(::ActiveRecord::Type::Json)
38
+ end
39
+
40
+ # +:date+ / +:time+ are absent: their raw shapes differ from the
41
+ # "YYYY-MM-DD HH:MM:SS" shape {Panko::CodeGen::DateTimeFormat} splices.
42
+ DATETIME_TYPES = %i[datetime timestamptz].freeze
43
+
44
+ def self.datetime_typed?(model, attribute_name)
45
+ DATETIME_TYPES.include?(model.type_for_attribute(attribute_name.to_s).type)
46
+ end
47
+
48
+ # Cast values that +cast_datetime+ returns unchanged, so Hash mode may skip it.
49
+ # +:decimal+, +:float+, +:inet+, +:cidr+ are absent: +cast_datetime+ can change their value.
50
+ # +:json+ / +:jsonb+ are listed although +#as_json+ builds a new object: parsed JSON holds
51
+ # only primitives, so the result is ==-equal and skipping the cast saves only allocations.
52
+ PLAIN_TYPES = %i[
53
+ string text integer bigint boolean uuid binary
54
+ json jsonb macaddr
55
+ ].freeze
56
+
57
+ # Wrapper types (+Type::Serialized+, PG's +OID::Array+) report their +#subtype+'s +#type+
58
+ # but cast to something else.
59
+ def self.plain_typed?(model, attribute_name)
60
+ type = model.type_for_attribute(attribute_name.to_s)
61
+ PLAIN_TYPES.include?(type.type) && !type.respond_to?(:subtype)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ module ActiveRecord
5
+ module DefineAttributeMethods
6
+ def self.ensure!(klass)
7
+ return if klass.attribute_methods_generated?
8
+ klass.define_attribute_methods
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ class CodeBuilder
5
+ INDENT_UNIT = " "
6
+
7
+ def initialize
8
+ @lines = []
9
+ @indent = 0
10
+ end
11
+
12
+ def line(str = "")
13
+ @lines << (INDENT_UNIT * @indent) + str
14
+ end
15
+
16
+ # Unlike +line("")+, adds no indent, so the output has no trailing whitespace.
17
+ def blank
18
+ @lines << ""
19
+ end
20
+
21
+ def indent
22
+ @indent += 1
23
+ yield
24
+ ensure
25
+ @indent -= 1
26
+ end
27
+
28
+ def to_s
29
+ @lines.join("\n")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Descriptor -> Generated Class, one entry per unique Descriptor in a tree.
5
+ class CompileCache
6
+ # Keyed by +__id__+, so equal but distinct Descriptors get separate entries.
7
+ def initialize
8
+ @store = {}
9
+ end
10
+
11
+ def get(descriptor)
12
+ @store[descriptor.__id__]
13
+ end
14
+
15
+ def set(descriptor, generated_class)
16
+ @store[descriptor.__id__] = generated_class
17
+ end
18
+
19
+ # The block must call +#set+ before it descends into children, so a recursive
20
+ # call on the same Descriptor hits the entry and the cycle ends.
21
+ def lookup_or_compile(descriptor)
22
+ cached = @store[descriptor.__id__]
23
+ return cached if cached
24
+ yield
25
+ @store[descriptor.__id__]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ class Compiler
5
+ # A table, not +to_s.upcase+: the +:hash+ suffix is +Hash+, not +HASH+.
6
+ OUTPUT_SUFFIXES = {json: "JSON", hash: "Hash"}.freeze
7
+
8
+ def initialize(descriptor, output:, config:, validator: Validators::Validator.new,
9
+ generator: Generator.new)
10
+ @descriptor = descriptor
11
+ @output = output
12
+ @config = config
13
+ @validator = validator
14
+ @generator = generator
15
+ @cache = CompileCache.new
16
+ end
17
+
18
+ def compile
19
+ @validator.validate(@descriptor, output: @output, config: @config)
20
+ source = @generator.emit(@descriptor, output: @output, config: @config)
21
+ namespace = Class.new
22
+ bind_anonymous_parents(namespace)
23
+ namespace.module_eval(source, synthetic_path, 1)
24
+ cache_descendants(@descriptor, namespace)
25
+ @cache.get(@descriptor)
26
+ end
27
+
28
+ private
29
+
30
+ # A Hash, not one constant per class: +const_set+ gives an anonymous class a name,
31
+ # which would change +parent_class.name+ for the next compile.
32
+ def bind_anonymous_parents(namespace)
33
+ anonymous = {}
34
+ Generators::DescriptorWalk.in_emit_order(@descriptor).each do |descriptor|
35
+ parent = descriptor.parent_class
36
+ next if parent.name
37
+ anonymous[descriptor.name] = parent
38
+ end
39
+ namespace.const_set(:ANON_PARENTS, anonymous.freeze) unless anonymous.empty?
40
+ end
41
+
42
+ def synthetic_path
43
+ "(#{GENERATOR_NAME}: #{@descriptor.name}/#{@output})"
44
+ end
45
+
46
+ def cache_descendants(descriptor, namespace)
47
+ @cache.lookup_or_compile(descriptor) do
48
+ klass = namespace.const_get(:"#{descriptor.name}_#{OUTPUT_SUFFIXES.fetch(@output)}")
49
+ @cache.set(descriptor, klass)
50
+ descriptor.associations.each do |assoc|
51
+ assoc.descriptors.each { |child| cache_descendants(child, namespace) }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Compile-time settings baked into a Generated Class; omitted fields take DEFAULTS.
5
+ #
6
+ # - +null_for_missing_has_one+: a has_one source returning +nil+ writes +null+
7
+ # (+nil+ in Hash mode) instead of omitting the key.
8
+ # - +supports_root_key+: when +false+, passing +root_key:+ raises +ArgumentError+.
9
+ # - +json_column_emit+ (JSON mode, Specialized path only): +:wire_format+ writes the
10
+ # stored bytes after a well-formedness check, +:html_safe+ the typed value, escaped.
11
+ # - +pool_writer+: no effect in Hash output mode.
12
+ # - +guarded_model+: a record of another class goes to the generic body.
13
+ # Needs a named +descriptor.model+.
14
+ Config = Data.define(
15
+ :null_for_missing_has_one,
16
+ :supports_root_key,
17
+ :hash_record_key_type,
18
+ :hash_output_key_type,
19
+ :json_column_emit,
20
+ :pool_writer,
21
+ :guarded_model
22
+ )
23
+
24
+ Config::DEFAULTS = {
25
+ null_for_missing_has_one: true,
26
+ supports_root_key: false,
27
+ hash_record_key_type: :string,
28
+ hash_output_key_type: :string,
29
+ json_column_emit: :wire_format,
30
+ pool_writer: true,
31
+ guarded_model: false
32
+ }.freeze
33
+
34
+ Config::HASH_KEY_TYPES = %i[string symbol].freeze
35
+
36
+ Config::JSON_COLUMN_EMIT_MODES = %i[wire_format html_safe].freeze
37
+
38
+ # Prepended because +Data.define+ defines +new+ on Config itself, so a
39
+ # plain +def self.new+ would replace it and leave no +super+ to call.
40
+ module Config::ClassMethods
41
+ def new(**kwargs)
42
+ merged = Config::DEFAULTS.merge(kwargs)
43
+ validate!(merged)
44
+ super(**merged)
45
+ end
46
+
47
+ private
48
+
49
+ def validate!(values)
50
+ validate_enum!(:hash_record_key_type, values[:hash_record_key_type])
51
+ validate_enum!(:hash_output_key_type, values[:hash_output_key_type])
52
+ validate_json_column_emit!(values[:json_column_emit])
53
+ validate_boolean!(:pool_writer, values[:pool_writer])
54
+ validate_boolean!(:guarded_model, values[:guarded_model])
55
+ end
56
+
57
+ def validate_enum!(field, value)
58
+ return if Config::HASH_KEY_TYPES.include?(value)
59
+ raise DescriptorError,
60
+ "Config##{field}: invalid value #{value.inspect}; must be :string or :symbol."
61
+ end
62
+
63
+ # +ArgumentError+, not +DescriptorError+: this field picks how code is
64
+ # written, it is not part of the Descriptor's shape.
65
+ def validate_json_column_emit!(value)
66
+ return if Config::JSON_COLUMN_EMIT_MODES.include?(value)
67
+ raise ArgumentError,
68
+ "Config#json_column_emit: invalid value #{value.inspect}; must be :wire_format or :html_safe."
69
+ end
70
+
71
+ def validate_boolean!(field, value)
72
+ return if value.equal?(true) || value.equal?(false)
73
+ raise ArgumentError,
74
+ "Config##{field}: invalid value #{value.inspect}; must be true or false."
75
+ end
76
+ end
77
+
78
+ Config.singleton_class.prepend(Config::ClassMethods)
79
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Formats a raw DB datetime String ("YYYY-MM-DD HH:MM:SS[.fraction]") as ISO-8601 without
5
+ # building a Time. Used only when +ActiveRecord.default_timezone+ is +:utc+, so the value is UTC.
6
+ module DateTimeFormat
7
+ TEMPLATE = "0000-00-00T00:00:00.000Z"
8
+
9
+ module_function
10
+
11
+ # Returns +nil+ when +raw+ is not a DB datetime String (a dirty attribute holding
12
+ # a Time, a nil column, another adapter format); callers then use the type-cast read.
13
+ def format_raw(raw)
14
+ return nil unless raw.is_a?(String)
15
+ len = raw.bytesize
16
+ # Some adapters already return ISO-8601 UTC ("...T...Z").
17
+ return raw if len >= 20 && raw.getbyte(len - 1) == 90 && raw.getbyte(10) == 84
18
+ return nil if len < 19
19
+ return nil unless raw.getbyte(10) == 32
20
+
21
+ out = TEMPLATE.dup
22
+ out.bytesplice(0, 10, raw, 0, 10)
23
+ out.bytesplice(11, 8, raw, 11, 8)
24
+ if len > 20 && raw.getbyte(19) == 46
25
+ # Truncate (never round) to milliseconds, like +#xmlschema(3)+. Copy only the
26
+ # digits: PG trims trailing zeros and appends "+00" ("...15.5+00").
27
+ fraction_digits = 0
28
+ while fraction_digits < 3
29
+ byte = raw.getbyte(20 + fraction_digits)
30
+ break if byte.nil? || byte < 48 || byte > 57
31
+ fraction_digits += 1
32
+ end
33
+ out.bytesplice(20, fraction_digits, raw, 20, fraction_digits) if fraction_digits > 0
34
+ end
35
+ out
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panko::CodeGen
4
+ # Returned by a Method Attribute body to omit its Field. Compared by
5
+ # identity, so no caller value can be mistaken for it.
6
+ SKIP = Object.new.freeze
7
+
8
+ # Each Descriptor-family +Data+ type validates its own fields at +.new+,
9
+ # so +Descriptor+ checks only the type of each array element, not its fields.
10
+ module StructuralValidation
11
+ module_function
12
+
13
+ def validate_symbol!(field, value)
14
+ return if value.is_a?(Symbol)
15
+ raise DescriptorError, "#{field}: must be a Symbol; got #{value.inspect}:#{value.class}"
16
+ end
17
+
18
+ def validate_callable!(field, value)
19
+ return if value.is_a?(Symbol)
20
+ if value.is_a?(UnboundMethod)
21
+ raise DescriptorError, "#{field}: must be a bound Callable, not an UnboundMethod; got #{value.inspect}:#{value.class}"
22
+ end
23
+ return if value.respond_to?(:call)
24
+ raise DescriptorError, "#{field}: must respond to #call; got #{value.inspect}:#{value.class}"
25
+ end
26
+
27
+ def validate_optional_class!(field, value)
28
+ return if value.nil?
29
+ return if value.is_a?(Class)
30
+ raise DescriptorError, "#{field}: must be a Class or nil; got #{value.inspect}:#{value.class}"
31
+ end
32
+
33
+ def validate_class!(field, value)
34
+ return if value.is_a?(Class)
35
+ raise DescriptorError, "#{field}: must be a Class; got #{value.inspect}:#{value.class}"
36
+ end
37
+
38
+ def validate_non_empty_string!(field, value)
39
+ return if value.is_a?(String) && !value.empty?
40
+ raise DescriptorError, "#{field}: must be a non-empty String; got #{value.inspect}:#{value.class}"
41
+ end
42
+
43
+ # +kind_label+ is passed apart from +element_class+ so the namespace
44
+ # does not show in the error message.
45
+ def validate_array_of!(field, value, element_class, kind_label)
46
+ unless value.is_a?(Array)
47
+ raise DescriptorError, "#{field}: must be an Array of #{kind_label}; got #{value.inspect}:#{value.class}"
48
+ end
49
+ value.each do |el|
50
+ next if el.is_a?(element_class)
51
+ raise DescriptorError, "#{field}: must contain only #{kind_label} elements; got #{el.inspect}:#{el.class}"
52
+ end
53
+ end
54
+
55
+ def validate_descriptor!(field, value)
56
+ return if value.is_a?(Descriptor)
57
+ raise DescriptorError, "#{field}: must be a Descriptor; got #{value.inspect}:#{value.class}"
58
+ end
59
+
60
+ def validate_kind!(field, value)
61
+ return if Association::KINDS.include?(value)
62
+ raise DescriptorError, "#{field}: must be :has_one or :has_many; got #{value.inspect}:#{value.class}"
63
+ end
64
+ end
65
+
66
+ # A Field read from the Record's +source+ (defaults to +name+).
67
+ Attribute = Data.define(:name, :source) do
68
+ def initialize(name:, source: name)
69
+ StructuralValidation.validate_symbol!("Attribute#name", name)
70
+ StructuralValidation.validate_symbol!("Attribute#source", source)
71
+ super
72
+ end
73
+ end
74
+
75
+ # +body+ is a Callable (passed record, context, scope up to its arity) or a Symbol
76
+ # naming a +parent_class+ method; returning +SKIP+ omits the Field.
77
+ MethodAttribute = Data.define(:name, :body) do
78
+ def initialize(name:, body:)
79
+ StructuralValidation.validate_symbol!("MethodAttribute#name", name)
80
+ StructuralValidation.validate_callable!("MethodAttribute#body", body)
81
+ super
82
+ end
83
+ end
84
+
85
+ # +descriptor+ may be the parent itself for self-recursive shapes.
86
+ Association = Data.define(:name, :kind, :descriptor, :source, :if, :variants) do
87
+ # +variants+ serve a source returning several record classes: a record uses the
88
+ # variant whose +model+ is its exact class, else +descriptor+.
89
+ def initialize(name:, kind:, descriptor:, source: nil, if: nil, variants: [])
90
+ # +if+ is a keyword, so its value cannot be read as a local by name.
91
+ if_callable = binding.local_variable_get(:if)
92
+ source = name if source.nil?
93
+ StructuralValidation.validate_symbol!("Association#name", name)
94
+ StructuralValidation.validate_kind!("Association#kind", kind)
95
+ StructuralValidation.validate_descriptor!("Association#descriptor", descriptor)
96
+ StructuralValidation.validate_symbol!("Association#source", source)
97
+ # +if+ must be a Callable: a Symbol passes this check but fails at compile
98
+ # (Symbol has no +arity+).
99
+ StructuralValidation.validate_callable!("Association#if", if_callable) unless if_callable.nil?
100
+ StructuralValidation.validate_array_of!("Association#variants", variants, Descriptor, "Descriptor")
101
+ validate_variants!(variants)
102
+ super(name: name, kind: kind, descriptor: descriptor, source: source, if: if_callable, variants: variants.frozen? ? variants : variants.dup.freeze)
103
+ end
104
+
105
+ def descriptors
106
+ variants.empty? ? [descriptor] : [descriptor, *variants]
107
+ end
108
+
109
+ private
110
+
111
+ def validate_variants!(variants)
112
+ models = variants.map(&:model)
113
+ if models.any? { |model| model.nil? || model.name.nil? }
114
+ raise DescriptorError, "Association#variants: every variant needs a named model; got #{models.inspect}"
115
+ end
116
+ return if models.uniq.size == models.size
117
+ raise DescriptorError, "Association#variants: models must be distinct; got #{models.inspect}"
118
+ end
119
+ end
120
+
121
+ Association::KINDS = %i[has_one has_many].freeze
122
+
123
+ # +model+ nil uses the generic path, a Class allows specialization; the Generated
124
+ # Class subclasses +parent_class+.
125
+ Descriptor = Data.define(:name, :model, :attributes, :method_attributes, :associations, :parent_class) do
126
+ def initialize(name:, model:, attributes:, method_attributes:, associations:, parent_class:)
127
+ StructuralValidation.validate_non_empty_string!("Descriptor#name", name)
128
+ StructuralValidation.validate_optional_class!("Descriptor#model", model)
129
+ StructuralValidation.validate_array_of!("Descriptor#attributes", attributes, Attribute, "Attribute")
130
+ StructuralValidation.validate_array_of!("Descriptor#method_attributes", method_attributes, MethodAttribute, "MethodAttribute")
131
+ StructuralValidation.validate_array_of!("Descriptor#associations", associations, Association, "Association")
132
+ StructuralValidation.validate_class!("Descriptor#parent_class", parent_class)
133
+ super
134
+ end
135
+ end
136
+ end