senko 0.1.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.
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ require_relative 'format'
6
+
7
+ module Senko
8
+ class CodeGenerator
9
+ SUPPORTED_KEYS = %w[
10
+ type enum const minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
11
+ minLength maxLength pattern required properties additionalProperties allOf anyOf oneOf not
12
+ minItems maxItems uniqueItems prefixItems items contains minContains maxContains
13
+ minProperties maxProperties patternProperties propertyNames dependentRequired
14
+ ].freeze
15
+
16
+ def self.generate(schema)
17
+ new(schema).generate
18
+ rescue UnsupportedSchema
19
+ nil
20
+ end
21
+
22
+ class UnsupportedSchema < StandardError; end
23
+
24
+ def initialize(schema)
25
+ @schema = deep_stringify(schema)
26
+ @regexps = []
27
+ @constants = []
28
+ end
29
+
30
+ def generate
31
+ expression = compile_schema(@schema, 'data')
32
+ regexps = @regexps
33
+ constants = @constants
34
+ eval("lambda { |data| #{expression} }", binding, __FILE__, __LINE__)
35
+ end
36
+
37
+ private
38
+
39
+ def compile_schema(schema, variable)
40
+ case schema
41
+ when true
42
+ 'true'
43
+ when false
44
+ 'false'
45
+ when Hash
46
+ compile_hash_schema(schema, variable)
47
+ else
48
+ raise UnsupportedSchema
49
+ end
50
+ end
51
+
52
+ def compile_hash_schema(schema, variable)
53
+ unsupported = schema.keys.map(&:to_s) - SUPPORTED_KEYS
54
+ raise UnsupportedSchema unless unsupported.empty?
55
+
56
+ checks = []
57
+ checks << compile_type(schema['type'], variable) if schema.key?('type')
58
+ checks << compile_enum(schema['enum'], variable) if schema.key?('enum')
59
+ checks << compile_const(schema['const'], variable) if schema.key?('const')
60
+ checks.concat(compile_numeric(schema, variable))
61
+ checks.concat(compile_string(schema, variable))
62
+ checks.concat(compile_array(schema, variable))
63
+ checks.concat(compile_object(schema, variable))
64
+ checks.concat(compile_applicators(schema, variable))
65
+ checks.empty? ? 'true' : checks.join(' && ')
66
+ end
67
+
68
+ def compile_type(type, variable)
69
+ types = type.is_a?(Array) ? type : [type]
70
+ parts = types.map do |name|
71
+ case name.to_s
72
+ when 'null' then "#{variable}.nil?"
73
+ when 'boolean' then "#{variable} == true || #{variable} == false"
74
+ when 'integer' then "(#{variable}.is_a?(Integer) || (#{variable}.is_a?(Float) && #{variable}.finite? && #{variable} == #{variable}.to_i) || (#{variable}.is_a?(BigDecimal) && #{variable}.finite? && #{variable} == #{variable}.to_i))"
75
+ when 'number' then "#{variable}.is_a?(Integer) || #{variable}.is_a?(Float) || #{variable}.is_a?(BigDecimal)"
76
+ when 'string' then "#{variable}.is_a?(String)"
77
+ when 'array' then "#{variable}.is_a?(Array)"
78
+ when 'object' then "#{variable}.is_a?(Hash)"
79
+ else raise UnsupportedSchema
80
+ end
81
+ end
82
+ "(#{parts.join(' || ')})"
83
+ end
84
+
85
+ def compile_enum(values, variable)
86
+ index = push_constant(values)
87
+ "constants[#{index}].any? { |value| value == #{variable} }"
88
+ end
89
+
90
+ def compile_const(value, variable)
91
+ index = push_constant(value)
92
+ "(constants[#{index}] == #{variable})"
93
+ end
94
+
95
+ def compile_numeric(schema, variable)
96
+ checks = []
97
+ guard = "(#{variable}.is_a?(Integer) || #{variable}.is_a?(Float) || #{variable}.is_a?(BigDecimal))"
98
+ checks << "(!#{guard} || #{variable} >= #{schema['minimum'].inspect})" if schema.key?('minimum')
99
+ checks << "(!#{guard} || #{variable} > #{schema['exclusiveMinimum'].inspect})" if schema.key?('exclusiveMinimum')
100
+ checks << "(!#{guard} || #{variable} <= #{schema['maximum'].inspect})" if schema.key?('maximum')
101
+ checks << "(!#{guard} || #{variable} < #{schema['exclusiveMaximum'].inspect})" if schema.key?('exclusiveMaximum')
102
+ if schema.key?('multipleOf')
103
+ checks << "(!#{guard} || (BigDecimal(#{variable}.to_s) % BigDecimal(#{schema['multipleOf'].inspect}.to_s)).zero?)"
104
+ end
105
+ checks
106
+ end
107
+
108
+ def compile_string(schema, variable)
109
+ checks = []
110
+ guard = "#{variable}.is_a?(String)"
111
+ checks << "(!#{guard} || #{variable}.length >= #{schema['minLength'].to_i})" if schema.key?('minLength')
112
+ checks << "(!#{guard} || #{variable}.length <= #{schema['maxLength'].to_i})" if schema.key?('maxLength')
113
+ if schema.key?('pattern')
114
+ index = push_regexp(Regexp.new(Senko::Format.ecma_pattern_source(schema.fetch('pattern').to_s)))
115
+ checks << "(!#{guard} || #{variable}.match?(regexps[#{index}]))"
116
+ end
117
+ checks
118
+ end
119
+
120
+ def compile_object(schema, variable)
121
+ object_checks = []
122
+ return [] unless object_keyword?(schema)
123
+
124
+ object_checks << "#{variable}.length >= #{schema['minProperties'].to_i}" if schema.key?('minProperties')
125
+ object_checks << "#{variable}.length <= #{schema['maxProperties'].to_i}" if schema.key?('maxProperties')
126
+ object_checks.concat(schema.fetch('required', []).map { |key| "#{variable}.key?(#{key.to_s.inspect})" })
127
+ object_checks.concat(compile_properties(schema.fetch('properties', {}), variable))
128
+ object_checks.concat(compile_pattern_properties(schema.fetch('patternProperties', {}), variable))
129
+ object_checks << compile_property_names(schema.fetch('propertyNames'), variable) if schema.key?('propertyNames')
130
+ if schema.key?('dependentRequired')
131
+ object_checks << compile_dependent_required(schema.fetch('dependentRequired'),
132
+ variable)
133
+ end
134
+ object_checks << compile_additional_properties(schema, variable) if schema.key?('additionalProperties')
135
+ ["(!#{variable}.is_a?(Hash) || (#{object_checks.join(' && ')}))"]
136
+ end
137
+
138
+ def compile_array(schema, variable)
139
+ array_checks = []
140
+ return [] unless array_keyword?(schema)
141
+
142
+ array_checks << "#{variable}.length >= #{schema['minItems'].to_i}" if schema.key?('minItems')
143
+ array_checks << "#{variable}.length <= #{schema['maxItems'].to_i}" if schema.key?('maxItems')
144
+ array_checks << "#{variable}.uniq.length == #{variable}.length" if schema['uniqueItems'] == true
145
+ array_checks.concat(compile_prefix_items(schema.fetch('prefixItems', []), variable))
146
+ array_checks << compile_items(schema, variable) if schema.key?('items')
147
+ array_checks << compile_contains(schema, variable) if schema.key?('contains')
148
+ ["(!#{variable}.is_a?(Array) || (#{array_checks.join(' && ')}))"]
149
+ end
150
+
151
+ def array_keyword?(schema)
152
+ %w[minItems maxItems uniqueItems prefixItems items contains].any? { |key| schema.key?(key) }
153
+ end
154
+
155
+ def object_keyword?(schema)
156
+ %w[
157
+ minProperties maxProperties required properties patternProperties additionalProperties
158
+ propertyNames dependentRequired
159
+ ].any? { |key| schema.key?(key) }
160
+ end
161
+
162
+ def compile_properties(properties, variable)
163
+ properties.map do |key, subschema|
164
+ child = "#{variable}[#{key.to_s.inspect}]"
165
+ "(!#{variable}.key?(#{key.to_s.inspect}) || (#{compile_schema(subschema, child)}))"
166
+ end
167
+ end
168
+
169
+ def compile_pattern_properties(pattern_properties, variable)
170
+ pattern_properties.map do |pattern, subschema|
171
+ index = push_regexp(Regexp.new(Senko::Format.ecma_pattern_source(pattern.to_s)))
172
+ "#{variable}.all? { |key, value| !key.match?(regexps[#{index}]) || (#{compile_schema(subschema, 'value')}) }"
173
+ end
174
+ end
175
+
176
+ def compile_property_names(subschema, variable)
177
+ "#{variable}.keys.all? { |key| #{compile_schema(subschema, 'key')} }"
178
+ end
179
+
180
+ def compile_dependent_required(requirements, variable)
181
+ requirements.map do |property, dependencies|
182
+ deps = dependencies.map(&:to_s)
183
+ "(!#{variable}.key?(#{property.to_s.inspect}) || #{deps.inspect}.all? { |dependency| #{variable}.key?(dependency) })"
184
+ end.join(' && ')
185
+ end
186
+
187
+ def compile_additional_properties(schema, variable)
188
+ value = schema.fetch('additionalProperties')
189
+ return 'true' if value == true
190
+ raise UnsupportedSchema unless value == false
191
+
192
+ known = schema.fetch('properties', {}).keys.map(&:to_s)
193
+ pattern_indexes = schema.fetch('patternProperties', {}).keys.map do |pattern|
194
+ push_regexp(Regexp.new(Senko::Format.ecma_pattern_source(pattern.to_s)))
195
+ end
196
+ pattern_check = pattern_indexes.map { |index| "key.match?(regexps[#{index}])" }.join(' || ')
197
+ allowed = "#{known.inspect}.include?(key)"
198
+ allowed = "(#{allowed} || #{pattern_check})" unless pattern_check.empty?
199
+ "#{variable}.keys.all? { |key| #{allowed} }"
200
+ end
201
+
202
+ def compile_prefix_items(prefix_items, variable)
203
+ prefix_items.each_with_index.map do |subschema, index|
204
+ "(#{variable}.length <= #{index} || (#{compile_schema(subschema, "#{variable}[#{index}]")}))"
205
+ end
206
+ end
207
+
208
+ def compile_items(schema, variable)
209
+ start_index = Array(schema['prefixItems']).length
210
+ "#{variable}.each_with_index.all? { |item, index| index < #{start_index} || (#{compile_schema(
211
+ schema.fetch('items'), 'item'
212
+ )}) }"
213
+ end
214
+
215
+ def compile_contains(schema, variable)
216
+ min = schema.fetch('minContains', 1).to_i
217
+ max = schema.key?('maxContains') ? schema.fetch('maxContains').to_i : nil
218
+ count = "#{variable}.count { |item| #{compile_schema(schema.fetch('contains'), 'item')} }"
219
+ if max
220
+ "((__senko_count = #{count}) >= #{min} && __senko_count <= #{max})"
221
+ else
222
+ "(#{count} >= #{min})"
223
+ end
224
+ end
225
+
226
+ def compile_applicators(schema, variable)
227
+ checks = []
228
+ if schema.key?('allOf')
229
+ checks << schema['allOf'].map { |subschema|
230
+ "(#{compile_schema(subschema, variable)})"
231
+ }.join(' && ')
232
+ end
233
+ if schema.key?('anyOf')
234
+ checks << schema['anyOf'].map { |subschema|
235
+ "(#{compile_schema(subschema, variable)})"
236
+ }.join(' || ')
237
+ end
238
+ if schema.key?('oneOf')
239
+ checks << "(#{schema['oneOf'].map do |subschema|
240
+ "(#{compile_schema(subschema, variable)} ? 1 : 0)"
241
+ end.join(' + ')} == 1)"
242
+ end
243
+ checks << "!(#{compile_schema(schema['not'], variable)})" if schema.key?('not')
244
+ checks
245
+ end
246
+
247
+ def push_regexp(regexp)
248
+ @regexps << regexp
249
+ @regexps.length - 1
250
+ end
251
+
252
+ def push_constant(value)
253
+ @constants << value
254
+ @constants.length - 1
255
+ end
256
+
257
+ def deep_stringify(value)
258
+ case value
259
+ when Hash
260
+ value.each_with_object({}) do |(key, child), result|
261
+ result[key.to_s] = deep_stringify(child)
262
+ end
263
+ when Array
264
+ value.map { |child| deep_stringify(child) }
265
+ else
266
+ value
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senko
4
+ module Instructions
5
+ TYPE_NULL = 0b0000001
6
+ TYPE_BOOLEAN = 0b0000010
7
+ TYPE_INTEGER = 0b0000100
8
+ TYPE_NUMBER = 0b0001100
9
+ TYPE_STRING = 0b0010000
10
+ TYPE_ARRAY = 0b0100000
11
+ TYPE_OBJECT = 0b1000000
12
+
13
+ TYPE_MAP = {
14
+ 'null' => TYPE_NULL,
15
+ 'boolean' => TYPE_BOOLEAN,
16
+ 'integer' => TYPE_INTEGER,
17
+ 'number' => TYPE_NUMBER,
18
+ 'string' => TYPE_STRING,
19
+ 'array' => TYPE_ARRAY,
20
+ 'object' => TYPE_OBJECT
21
+ }.freeze
22
+
23
+ KEYWORDS = {
24
+ false_schema: 'false',
25
+ type: 'type',
26
+ enum: 'enum',
27
+ const: 'const',
28
+ multiple_of: 'multipleOf',
29
+ maximum: 'maximum',
30
+ minimum: 'minimum',
31
+ max_length: 'maxLength',
32
+ min_length: 'minLength',
33
+ pattern: 'pattern',
34
+ max_items: 'maxItems',
35
+ min_items: 'minItems',
36
+ unique_items: 'uniqueItems',
37
+ prefix_items: 'prefixItems',
38
+ items: 'items',
39
+ contains: 'contains',
40
+ max_properties: 'maxProperties',
41
+ min_properties: 'minProperties',
42
+ required: 'required',
43
+ properties: 'properties',
44
+ pattern_properties: 'patternProperties',
45
+ additional_properties: 'additionalProperties',
46
+ property_names: 'propertyNames',
47
+ dependent_required: 'dependentRequired',
48
+ dependent_schemas: 'dependentSchemas',
49
+ all_of: 'allOf',
50
+ any_of: 'anyOf',
51
+ one_of: 'oneOf',
52
+ not: 'not',
53
+ if_then_else: 'if',
54
+ discriminator: 'discriminator',
55
+ ref: '$ref',
56
+ dynamic_ref: '$dynamicRef',
57
+ dynamic_scope: '$dynamicAnchor',
58
+ unevaluated_properties: 'unevaluatedProperties',
59
+ unevaluated_items: 'unevaluatedItems',
60
+ format: 'format'
61
+ }.freeze
62
+ end
63
+
64
+ Instruction = Struct.new(:op, :payload, :keyword_location, :schema, keyword_init: true) do
65
+ def keyword
66
+ Instructions::KEYWORDS.fetch(op, op.to_s)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senko
4
+ class Compiler
5
+ class Optimizer
6
+ def optimize(instructions, seen = {})
7
+ return instructions if seen[instructions.object_id]
8
+
9
+ seen[instructions.object_id] = true
10
+ begin
11
+ instructions.flat_map do |instruction|
12
+ optimized = optimize_instruction(instruction, seen)
13
+ next [] if removable?(optimized)
14
+
15
+ inlineable?(optimized) ? optimized.payload[:schemas].first : [optimized]
16
+ end
17
+ ensure
18
+ seen.delete(instructions.object_id)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def optimize_instruction(instruction, seen)
25
+ payload = optimize_payload(instruction.payload, seen)
26
+ instruction.class.new(
27
+ op: instruction.op,
28
+ payload: payload,
29
+ keyword_location: instruction.keyword_location,
30
+ schema: instruction.schema
31
+ )
32
+ end
33
+
34
+ def optimize_payload(payload, seen)
35
+ return payload unless payload.is_a?(Hash)
36
+
37
+ payload.transform_values do |value|
38
+ case value
39
+ when Array
40
+ optimize_array_value(value, seen)
41
+ when Hash
42
+ optimize_hash_value(value, seen)
43
+ else
44
+ value
45
+ end
46
+ end
47
+ end
48
+
49
+ def optimize_array_value(value, seen)
50
+ if value.all?(Instruction)
51
+ optimize(value, seen)
52
+ elsif value.all? { |item| instruction_array?(item) }
53
+ value.map { |item| optimize(item, seen) }
54
+ else
55
+ value
56
+ end
57
+ end
58
+
59
+ def optimize_hash_value(value, seen)
60
+ value.transform_values do |child|
61
+ instruction_array?(child) ? optimize(child, seen) : child
62
+ end
63
+ end
64
+
65
+ def instruction_array?(value)
66
+ value.is_a?(Array) && value.all?(Instruction)
67
+ end
68
+
69
+ def removable?(instruction)
70
+ (instruction.op == :min_length && instruction.payload[:limit].zero?) ||
71
+ (instruction.op == :min_items && instruction.payload[:limit].zero?) ||
72
+ (instruction.op == :min_properties && instruction.payload[:limit].zero?)
73
+ end
74
+
75
+ def inlineable?(_instruction)
76
+ false
77
+ end
78
+ end
79
+ end
80
+ end