panko_serializer 0.8.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +6 -4
- data/lib/panko/array_serializer.rb +55 -16
- data/lib/panko/code_gen/active_record/access_classifier.rb +65 -0
- data/lib/panko/code_gen/active_record/define_attribute_methods.rb +13 -0
- data/lib/panko/code_gen/code_builder.rb +32 -0
- data/lib/panko/code_gen/compile_cache.rb +28 -0
- data/lib/panko/code_gen/compiler.rb +56 -0
- data/lib/panko/code_gen/config.rb +79 -0
- data/lib/panko/code_gen/datetime_format.rb +38 -0
- data/lib/panko/code_gen/descriptor.rb +136 -0
- data/lib/panko/code_gen/descriptor_builder.rb +166 -0
- data/lib/panko/code_gen/dump.rb +52 -0
- data/lib/panko/code_gen/errors.rb +21 -0
- data/lib/panko/code_gen/filter.rb +29 -0
- data/lib/panko/code_gen/filter_adapter.rb +44 -0
- data/lib/panko/code_gen/filters/indexed.rb +62 -0
- data/lib/panko/code_gen/filters/none.rb +19 -0
- data/lib/panko/code_gen/generator.rb +23 -0
- data/lib/panko/code_gen/generators/banner.rb +27 -0
- data/lib/panko/code_gen/generators/class_emitter.rb +120 -0
- data/lib/panko/code_gen/generators/cycle_membership.rb +53 -0
- data/lib/panko/code_gen/generators/descriptor_walk.rb +22 -0
- data/lib/panko/code_gen/generators/fanout.rb +60 -0
- data/lib/panko/code_gen/generators/field_index.rb +31 -0
- data/lib/panko/code_gen/generators/field_walk.rb +38 -0
- data/lib/panko/code_gen/generators/generated_names.rb +73 -0
- data/lib/panko/code_gen/generators/hash_sink.rb +217 -0
- data/lib/panko/code_gen/generators/json_sink.rb +290 -0
- data/lib/panko/code_gen/generators/record_access/generic.rb +92 -0
- data/lib/panko/code_gen/generators/record_access/specialized.rb +90 -0
- data/lib/panko/code_gen/generators/release.rb +60 -0
- data/lib/panko/code_gen/generators/sink.rb +80 -0
- data/lib/panko/code_gen/instance_pool.rb +27 -0
- data/lib/panko/code_gen/runtime.rb +27 -0
- data/lib/panko/code_gen/serializer_cache.rb +242 -0
- data/lib/panko/code_gen/validators/callable_arity.rb +42 -0
- data/lib/panko/code_gen/validators/name_uniqueness.rb +44 -0
- data/lib/panko/code_gen/validators/source_resolution.rb +48 -0
- data/lib/panko/code_gen/validators/validator.rb +19 -0
- data/lib/panko/code_gen/writers_pool.rb +51 -0
- data/lib/panko/code_gen.rb +82 -0
- data/lib/panko/compile_all.rb +80 -0
- data/lib/panko/compile_all_result.rb +21 -0
- data/lib/panko/config.rb +71 -0
- data/lib/panko/descriptor.rb +93 -0
- data/lib/panko/response.rb +40 -8
- data/lib/panko/serializer.rb +175 -91
- data/lib/panko/serializer_resolver.rb +2 -8
- data/lib/panko/version.rb +1 -1
- data/lib/panko_serializer.rb +3 -5
- metadata +49 -35
- data/ext/panko_serializer/attributes_writer/active_record.c +0 -181
- data/ext/panko_serializer/attributes_writer/active_record.h +0 -17
- data/ext/panko_serializer/attributes_writer/attributes_writer.c +0 -55
- data/ext/panko_serializer/attributes_writer/attributes_writer.h +0 -35
- data/ext/panko_serializer/attributes_writer/common.c +0 -10
- data/ext/panko_serializer/attributes_writer/common.h +0 -9
- data/ext/panko_serializer/attributes_writer/hash.c +0 -13
- data/ext/panko_serializer/attributes_writer/hash.h +0 -7
- data/ext/panko_serializer/attributes_writer/plain.c +0 -13
- data/ext/panko_serializer/attributes_writer/plain.h +0 -7
- data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.c +0 -156
- data/ext/panko_serializer/attributes_writer/type_cast/time_conversion.h +0 -10
- data/ext/panko_serializer/attributes_writer/type_cast/type_cast.c +0 -409
- data/ext/panko_serializer/attributes_writer/type_cast/type_cast.h +0 -81
- data/ext/panko_serializer/common.h +0 -9
- data/ext/panko_serializer/extconf.rb +0 -28
- data/ext/panko_serializer/panko_serializer.c +0 -181
- data/ext/panko_serializer/panko_serializer.h +0 -12
- data/ext/panko_serializer/serialization_descriptor/association.c +0 -90
- data/ext/panko_serializer/serialization_descriptor/association.h +0 -20
- data/ext/panko_serializer/serialization_descriptor/attribute.c +0 -96
- data/ext/panko_serializer/serialization_descriptor/attribute.h +0 -27
- data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.c +0 -177
- data/ext/panko_serializer/serialization_descriptor/serialization_descriptor.h +0 -30
- data/lib/panko/association.rb +0 -17
- data/lib/panko/attribute.rb +0 -29
- data/lib/panko/object_writer.rb +0 -68
- data/lib/panko/serialization_descriptor.rb +0 -180
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 933667f4549f885acffbbd822422f568a0b5ba737b05bc2f333299e9d2ba3085
|
|
4
|
+
data.tar.gz: 20f6302ef51561dce29e0608ad2a22334c0d8625f62cf815d7d31fb9a30e49f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a520312057f8f41687f6051faff37eb2061ca2b4d4939d54631b52250e120277c920b455480ca0977d4112a7f80200b960948c85895377574f0b324c1be976ac
|
|
7
|
+
data.tar.gz: '0779d69d34164e6d6fd361909bc1a4395c047f80190f61753dbcff817bef8ddd415278dfccd863bfff53ff0d5558dce6b6137d8c47e3cd8a989ec7cac9860761'
|
data/README.md
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
13
|
To dig deeper about the performance choices, read [Design Choices](https://panko.dev/design-choices).
|
|
14
14
|
|
|
15
|
+
Coming from 0.8 (the C extension)? Read [Upgrading from 0.8](https://panko.dev/upgrading).
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
Support
|
|
17
19
|
-------
|
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
serialize(@subjects)
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
def serialize_to_json(subjects)
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,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
|