zod_rails 0.1.4
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 +7 -0
- data/.github/workflows/ci.yml +32 -0
- data/.github/workflows/release.yml +33 -0
- data/LICENSE +21 -0
- data/README.md +282 -0
- data/Rakefile +12 -0
- data/ZOD_RAILS.md +1178 -0
- data/lib/tasks/zod_rails.rake +36 -0
- data/lib/zod_rails/configuration.rb +17 -0
- data/lib/zod_rails/generation/file_writer.rb +37 -0
- data/lib/zod_rails/generation/schema_builder.rb +89 -0
- data/lib/zod_rails/generation/typescript_emitter.rb +42 -0
- data/lib/zod_rails/generator.rb +39 -0
- data/lib/zod_rails/introspection/column_info.rb +39 -0
- data/lib/zod_rails/introspection/model_inspector.rb +34 -0
- data/lib/zod_rails/introspection/validation_info.rb +48 -0
- data/lib/zod_rails/mapping/enum_mapper.rb +31 -0
- data/lib/zod_rails/mapping/type_mapper.rb +46 -0
- data/lib/zod_rails/mapping/validation_mapper.rb +163 -0
- data/lib/zod_rails/railtie.rb +19 -0
- data/lib/zod_rails/version.rb +5 -0
- data/lib/zod_rails.rb +40 -0
- data/sig/zod_rails.rbs +4 -0
- data/zod_rails.png +0 -0
- metadata +85 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :zod_rails do
|
|
4
|
+
desc "Generate Zod schemas for configured models"
|
|
5
|
+
task generate: :environment do
|
|
6
|
+
config = ZodRails.configuration
|
|
7
|
+
generator = ZodRails::Generator.new(output_dir: config.output_dir)
|
|
8
|
+
|
|
9
|
+
models = config.models.map(&:constantize)
|
|
10
|
+
|
|
11
|
+
if models.empty?
|
|
12
|
+
puts "No models configured. Add models to ZodRails.configure { |c| c.models = ['User', 'Article'] }"
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
generated = generator.generate_all(models)
|
|
17
|
+
puts "Generated #{generated.size} schema file(s):"
|
|
18
|
+
generated.each { |f| puts " - #{f}" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Generate Zod schema for a specific model"
|
|
22
|
+
task :generate_model, [:model_name] => :environment do |_t, args|
|
|
23
|
+
model_name = args[:model_name]
|
|
24
|
+
unless model_name
|
|
25
|
+
puts "Usage: rails zod_rails:generate_model[ModelName]"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
config = ZodRails.configuration
|
|
30
|
+
generator = ZodRails::Generator.new(output_dir: config.output_dir)
|
|
31
|
+
|
|
32
|
+
model_class = model_name.constantize
|
|
33
|
+
filename = generator.generate(model_class)
|
|
34
|
+
puts "Generated: #{filename}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :output_dir, :schema_suffix, :input_schema_suffix,
|
|
6
|
+
:generate_input_schemas, :excluded_columns, :models
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@output_dir = "app/javascript/schemas"
|
|
10
|
+
@schema_suffix = "Schema"
|
|
11
|
+
@input_schema_suffix = "InputSchema"
|
|
12
|
+
@generate_input_schemas = true
|
|
13
|
+
@excluded_columns = %w[id created_at updated_at]
|
|
14
|
+
@models = []
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module ZodRails
|
|
6
|
+
module Generation
|
|
7
|
+
class FileWriter
|
|
8
|
+
attr_reader :output_dir
|
|
9
|
+
|
|
10
|
+
def initialize(output_dir:)
|
|
11
|
+
@output_dir = output_dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(filename:, content:)
|
|
15
|
+
full_path = File.join(output_dir, filename)
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(full_path))
|
|
17
|
+
File.write(full_path, content)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def output_path_for(model_name)
|
|
21
|
+
parts = model_name.split("::")
|
|
22
|
+
filename = underscore(parts.pop)
|
|
23
|
+
path_parts = parts.map { |p| underscore(p) }
|
|
24
|
+
path_parts << "#{filename}.ts"
|
|
25
|
+
path_parts.join("/")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def underscore(str)
|
|
31
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
32
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
33
|
+
.downcase
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Generation
|
|
5
|
+
class SchemaBuilder
|
|
6
|
+
attr_reader :inspector, :excluded_columns
|
|
7
|
+
|
|
8
|
+
def initialize(inspector, excluded_columns: [])
|
|
9
|
+
@inspector = inspector
|
|
10
|
+
@excluded_columns = excluded_columns.map(&:to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(input_schema: false)
|
|
14
|
+
columns = input_schema ? filtered_columns : inspector.columns
|
|
15
|
+
fields = columns.map do |column|
|
|
16
|
+
field_definition(column, input_schema: input_schema)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
"z.object({\n #{fields.join(",\n ")}\n})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def schema_name(input_schema: false)
|
|
23
|
+
suffix = input_schema ? "InputSchema" : "Schema"
|
|
24
|
+
"#{inspector.model_name}#{suffix}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def field_definition(column, input_schema:)
|
|
30
|
+
type_str = build_type_string(column, input_schema: input_schema)
|
|
31
|
+
"#{column.name}: #{type_str}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_type_string(column, input_schema:)
|
|
35
|
+
if enum_column?(column.name)
|
|
36
|
+
build_enum_type(column, input_schema: input_schema)
|
|
37
|
+
else
|
|
38
|
+
build_regular_type(column, input_schema: input_schema)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_enum_type(column, input_schema:)
|
|
43
|
+
values = inspector.enums[column.name]
|
|
44
|
+
Mapping::EnumMapper.call(
|
|
45
|
+
values,
|
|
46
|
+
nullable: column.nullable,
|
|
47
|
+
input_schema: input_schema,
|
|
48
|
+
has_default: column.has_default
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_regular_type(column, input_schema:)
|
|
53
|
+
validations = inspector.validations_for(column.name)
|
|
54
|
+
base_type = Mapping::TypeMapper.call(
|
|
55
|
+
column.type,
|
|
56
|
+
nullable: column.nullable,
|
|
57
|
+
input_schema: input_schema,
|
|
58
|
+
has_default: column.has_default
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
validation_chain = Mapping::ValidationMapper.call_all(validations, base_type: column.type)
|
|
62
|
+
insert_validation_chain(base_type, validation_chain)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def insert_validation_chain(base_type, validation_chain)
|
|
66
|
+
return base_type if validation_chain.empty?
|
|
67
|
+
|
|
68
|
+
if base_type.include?(".nullable()") || base_type.include?(".nullish()") || base_type.include?(".optional()")
|
|
69
|
+
suffix_match = base_type.match(/(\.(nullable|nullish|optional)\(\))$/)
|
|
70
|
+
if suffix_match
|
|
71
|
+
base_type.sub(suffix_match[0], "#{validation_chain}#{suffix_match[0]}")
|
|
72
|
+
else
|
|
73
|
+
"#{base_type}#{validation_chain}"
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
"#{base_type}#{validation_chain}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def enum_column?(column_name)
|
|
81
|
+
inspector.enums.key?(column_name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def filtered_columns
|
|
85
|
+
inspector.columns.reject { |col| excluded_columns.include?(col.name) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Generation
|
|
5
|
+
class TypescriptEmitter
|
|
6
|
+
def emit(schema_name:, schema_body:)
|
|
7
|
+
type_name = derive_type_name(schema_name)
|
|
8
|
+
|
|
9
|
+
<<~TYPESCRIPT
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export const #{schema_name} = #{schema_body};
|
|
13
|
+
|
|
14
|
+
export type #{type_name} = z.infer<typeof #{schema_name}>;
|
|
15
|
+
TYPESCRIPT
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def emit_combined(response:, input:)
|
|
19
|
+
response_type = derive_type_name(response[:name])
|
|
20
|
+
input_type = derive_type_name(input[:name])
|
|
21
|
+
|
|
22
|
+
<<~TYPESCRIPT
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
export const #{response[:name]} = #{response[:body]};
|
|
26
|
+
|
|
27
|
+
export type #{response_type} = z.infer<typeof #{response[:name]}>;
|
|
28
|
+
|
|
29
|
+
export const #{input[:name]} = #{input[:body]};
|
|
30
|
+
|
|
31
|
+
export type #{input_type} = z.infer<typeof #{input[:name]}>;
|
|
32
|
+
TYPESCRIPT
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def derive_type_name(schema_name)
|
|
38
|
+
schema_name.sub(/Schema$/, "").sub(/InputSchema$/, "Input")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
class Generator
|
|
5
|
+
attr_reader :output_dir, :file_writer, :emitter
|
|
6
|
+
|
|
7
|
+
def initialize(output_dir:)
|
|
8
|
+
@output_dir = output_dir
|
|
9
|
+
@file_writer = Generation::FileWriter.new(output_dir: output_dir)
|
|
10
|
+
@emitter = Generation::TypescriptEmitter.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate(model_class)
|
|
14
|
+
inspector = Introspection::ModelInspector.new(model_class)
|
|
15
|
+
excluded = ZodRails.configuration.excluded_columns
|
|
16
|
+
builder = Generation::SchemaBuilder.new(inspector, excluded_columns: excluded)
|
|
17
|
+
|
|
18
|
+
response_schema = {
|
|
19
|
+
name: builder.schema_name,
|
|
20
|
+
body: builder.build
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
input_schema = {
|
|
24
|
+
name: builder.schema_name(input_schema: true),
|
|
25
|
+
body: builder.build(input_schema: true)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
content = emitter.emit_combined(response: response_schema, input: input_schema)
|
|
29
|
+
filename = file_writer.output_path_for(inspector.model_name)
|
|
30
|
+
|
|
31
|
+
file_writer.write(filename: filename, content: content)
|
|
32
|
+
filename
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generate_all(model_classes)
|
|
36
|
+
model_classes.map { |klass| generate(klass) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Introspection
|
|
5
|
+
class ColumnInfo
|
|
6
|
+
attr_reader :name, :type, :nullable, :has_default
|
|
7
|
+
|
|
8
|
+
def initialize(name:, type:, nullable:, has_default:)
|
|
9
|
+
@name = name
|
|
10
|
+
@type = type
|
|
11
|
+
@nullable = nullable
|
|
12
|
+
@has_default = has_default
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_column(column)
|
|
17
|
+
new(
|
|
18
|
+
name: column.name,
|
|
19
|
+
type: column.type,
|
|
20
|
+
nullable: column.null,
|
|
21
|
+
has_default: !column.default.nil?
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(self.class) &&
|
|
27
|
+
name == other.name &&
|
|
28
|
+
type == other.type &&
|
|
29
|
+
nullable == other.nullable &&
|
|
30
|
+
has_default == other.has_default
|
|
31
|
+
end
|
|
32
|
+
alias eql? ==
|
|
33
|
+
|
|
34
|
+
def hash
|
|
35
|
+
[self.class, name, type, nullable, has_default].hash
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Introspection
|
|
5
|
+
class ModelInspector
|
|
6
|
+
attr_reader :model_class
|
|
7
|
+
|
|
8
|
+
def initialize(model_class)
|
|
9
|
+
@model_class = model_class
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def columns
|
|
13
|
+
@columns ||= model_class.columns.map { |col| ColumnInfo.from_column(col) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validations_for(attribute)
|
|
17
|
+
attr_sym = attribute.to_sym
|
|
18
|
+
model_class.validators.each_with_object([]) do |validator, result|
|
|
19
|
+
next unless validator.attributes.include?(attr_sym)
|
|
20
|
+
|
|
21
|
+
result << ValidationInfo.from_validator(validator, attribute)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enums
|
|
26
|
+
@enums ||= model_class.defined_enums
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def model_name
|
|
30
|
+
model_class.name
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Introspection
|
|
5
|
+
class ValidationInfo
|
|
6
|
+
CONDITIONAL_KEYS = %i[if unless on].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :kind, :attribute, :options
|
|
9
|
+
|
|
10
|
+
def initialize(kind:, attribute:, options:, conditional:)
|
|
11
|
+
@kind = kind
|
|
12
|
+
@attribute = attribute
|
|
13
|
+
@options = options
|
|
14
|
+
@conditional = conditional
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_validator(validator, attribute)
|
|
19
|
+
opts = validator.options.dup
|
|
20
|
+
conditional = CONDITIONAL_KEYS.any? { |key| opts.key?(key) }
|
|
21
|
+
|
|
22
|
+
new(
|
|
23
|
+
kind: validator.kind,
|
|
24
|
+
attribute: attribute,
|
|
25
|
+
options: opts.except(*CONDITIONAL_KEYS),
|
|
26
|
+
conditional: conditional
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def conditional?
|
|
31
|
+
@conditional
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(self.class) &&
|
|
36
|
+
kind == other.kind &&
|
|
37
|
+
attribute == other.attribute &&
|
|
38
|
+
options == other.options &&
|
|
39
|
+
conditional? == other.conditional?
|
|
40
|
+
end
|
|
41
|
+
alias eql? ==
|
|
42
|
+
|
|
43
|
+
def hash
|
|
44
|
+
[self.class, kind, attribute, options, @conditional].hash
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Mapping
|
|
5
|
+
class EnumMapper
|
|
6
|
+
def self.call(values, nullable: false, input_schema: false, has_default: false)
|
|
7
|
+
keys = values.keys.map { |k| "\"#{escape_quotes(k)}\"" }
|
|
8
|
+
base = "z.enum([#{keys.join(", ")}])"
|
|
9
|
+
|
|
10
|
+
suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
|
|
11
|
+
"#{base}#{suffix}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.escape_quotes(str)
|
|
15
|
+
str.to_s.gsub('"', '\\"')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.determine_suffix(nullable:, input_schema:, has_default:)
|
|
19
|
+
return "" unless nullable || has_default
|
|
20
|
+
|
|
21
|
+
if input_schema
|
|
22
|
+
nullable ? ".nullish()" : ".optional()"
|
|
23
|
+
else
|
|
24
|
+
nullable ? ".nullable()" : ""
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private_class_method :escape_quotes, :determine_suffix
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Mapping
|
|
5
|
+
class TypeMapper
|
|
6
|
+
TYPE_MAP = {
|
|
7
|
+
string: "z.string()",
|
|
8
|
+
text: "z.string()",
|
|
9
|
+
integer: "z.int()",
|
|
10
|
+
bigint: "z.string()",
|
|
11
|
+
float: "z.number()",
|
|
12
|
+
decimal: "z.string()",
|
|
13
|
+
boolean: "z.boolean()",
|
|
14
|
+
date: "z.iso.date()",
|
|
15
|
+
datetime: "z.iso.datetime()",
|
|
16
|
+
time: "z.string()",
|
|
17
|
+
json: "z.json()",
|
|
18
|
+
jsonb: "z.json()",
|
|
19
|
+
uuid: "z.uuid()",
|
|
20
|
+
binary: "z.string()"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.call(type, nullable: false, input_schema: false, has_default: false)
|
|
24
|
+
base = TYPE_MAP.fetch(type.to_sym) do
|
|
25
|
+
ZodRails.logger.warn("ZodRails: Unknown type '#{type}', falling back to z.unknown()")
|
|
26
|
+
"z.unknown()"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
|
|
30
|
+
"#{base}#{suffix}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.determine_suffix(nullable:, input_schema:, has_default:)
|
|
34
|
+
return "" unless nullable || has_default
|
|
35
|
+
|
|
36
|
+
if input_schema
|
|
37
|
+
nullable ? ".nullish()" : ".optional()"
|
|
38
|
+
else
|
|
39
|
+
nullable ? ".nullable()" : ""
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :determine_suffix
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZodRails
|
|
4
|
+
module Mapping
|
|
5
|
+
class ValidationMapper
|
|
6
|
+
NUMERICALITY_MAP = {
|
|
7
|
+
greater_than: "gt",
|
|
8
|
+
greater_than_or_equal_to: "gte",
|
|
9
|
+
less_than: "lt",
|
|
10
|
+
less_than_or_equal_to: "lte"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.call(validation, base_type:)
|
|
14
|
+
return "" if validation.conditional?
|
|
15
|
+
|
|
16
|
+
case validation.kind
|
|
17
|
+
when :presence then map_presence(validation, base_type)
|
|
18
|
+
when :length then map_length(validation)
|
|
19
|
+
when :numericality then map_numericality(validation)
|
|
20
|
+
when :format then map_format(validation)
|
|
21
|
+
when :inclusion then map_inclusion(validation)
|
|
22
|
+
else ""
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.call_all(validations, base_type:)
|
|
27
|
+
constraints = { min: nil, max: nil, length: nil, others: [] }
|
|
28
|
+
|
|
29
|
+
validations.each do |v|
|
|
30
|
+
collect_constraints(v, base_type, constraints)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
build_chain(constraints)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.map_presence(_validation, base_type)
|
|
37
|
+
base_type == :string ? ".min(1)" : ""
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.map_length(validation)
|
|
41
|
+
parts = []
|
|
42
|
+
opts = validation.options
|
|
43
|
+
|
|
44
|
+
if opts[:is]
|
|
45
|
+
parts << ".length(#{opts[:is]})"
|
|
46
|
+
else
|
|
47
|
+
parts << ".min(#{opts[:minimum]})" if opts[:minimum]
|
|
48
|
+
parts << ".max(#{opts[:maximum]})" if opts[:maximum]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
parts.join
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.map_numericality(validation)
|
|
55
|
+
validation.options.filter_map do |key, value|
|
|
56
|
+
method = NUMERICALITY_MAP[key]
|
|
57
|
+
".#{method}(#{value})" if method
|
|
58
|
+
end.join
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.map_format(validation)
|
|
62
|
+
regex = validation.options[:with]
|
|
63
|
+
return "" unless regex
|
|
64
|
+
|
|
65
|
+
js_pattern = convert_ruby_regex_to_js(regex)
|
|
66
|
+
".regex(/#{js_pattern}/)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.map_inclusion(validation)
|
|
70
|
+
values = validation.options[:in] || validation.options[:within]
|
|
71
|
+
return "" unless values.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.convert_ruby_regex_to_js(regex)
|
|
77
|
+
pattern = regex.source
|
|
78
|
+
pattern = pattern.gsub("\\A", "^")
|
|
79
|
+
pattern.gsub(/\\z/i, "$")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.collect_constraints(validation, base_type, constraints)
|
|
83
|
+
return if validation.conditional?
|
|
84
|
+
|
|
85
|
+
case validation.kind
|
|
86
|
+
when :presence then handle_presence_constraint(base_type, constraints)
|
|
87
|
+
when :length then handle_length_constraint(validation, constraints)
|
|
88
|
+
when :numericality then handle_numericality_constraint(validation, constraints)
|
|
89
|
+
when :format then handle_format_constraint(validation, constraints)
|
|
90
|
+
when :inclusion then handle_inclusion_constraint(validation, constraints)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.handle_presence_constraint(base_type, constraints)
|
|
95
|
+
constraints[:min] = [constraints[:min] || 0, 1].max if base_type == :string
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.handle_length_constraint(validation, constraints)
|
|
99
|
+
opts = validation.options
|
|
100
|
+
constraints[:length] = opts[:is] if opts[:is]
|
|
101
|
+
constraints[:min] = [constraints[:min] || 0, opts[:minimum]].max if opts[:minimum]
|
|
102
|
+
constraints[:max] = [constraints[:max] || Float::INFINITY, opts[:maximum]].min if opts[:maximum]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.handle_format_constraint(validation, constraints)
|
|
106
|
+
regex = validation.options[:with]
|
|
107
|
+
return unless regex
|
|
108
|
+
|
|
109
|
+
js_pattern = convert_ruby_regex_to_js(regex)
|
|
110
|
+
constraints[:others] << ".regex(/#{js_pattern}/)"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.build_chain(constraints)
|
|
114
|
+
parts = []
|
|
115
|
+
|
|
116
|
+
if constraints[:length]
|
|
117
|
+
parts << ".length(#{constraints[:length]})"
|
|
118
|
+
else
|
|
119
|
+
parts << ".min(#{constraints[:min]})" if constraints[:min]&.positive?
|
|
120
|
+
parts << ".max(#{constraints[:max].to_i})" if constraints[:max] && constraints[:max] != Float::INFINITY
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
parts.concat(constraints[:others])
|
|
124
|
+
parts.join
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.handle_inclusion_constraint(validation, constraints)
|
|
128
|
+
values = validation.options[:in] || validation.options[:within]
|
|
129
|
+
return unless values
|
|
130
|
+
|
|
131
|
+
case values
|
|
132
|
+
when Range
|
|
133
|
+
if values.begin.is_a?(Numeric) && values.end.is_a?(Numeric)
|
|
134
|
+
constraints[:min] = [constraints[:min] || 0, values.begin].max
|
|
135
|
+
constraints[:max] = [constraints[:max] || Float::INFINITY, values.end].min
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.handle_numericality_constraint(validation, constraints)
|
|
141
|
+
validation.options.each do |key, value|
|
|
142
|
+
if key == :in
|
|
143
|
+
apply_numeric_range(value, constraints)
|
|
144
|
+
elsif (method = NUMERICALITY_MAP[key])
|
|
145
|
+
constraints[:others] << ".#{method}(#{value})"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.apply_numeric_range(range, constraints)
|
|
151
|
+
return unless range.is_a?(Range) && range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
|
|
152
|
+
|
|
153
|
+
constraints[:min] = [constraints[:min] || 0, range.begin].max
|
|
154
|
+
constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private_class_method :map_presence, :map_length, :map_numericality, :map_format, :map_inclusion,
|
|
158
|
+
:convert_ruby_regex_to_js, :collect_constraints, :build_chain,
|
|
159
|
+
:handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
|
|
160
|
+
:handle_inclusion_constraint, :handle_numericality_constraint, :apply_numeric_range
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module ZodRails
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
railtie_name :zod_rails
|
|
8
|
+
|
|
9
|
+
rake_tasks do
|
|
10
|
+
load "tasks/zod_rails.rake"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "zod_rails.configure" do
|
|
14
|
+
ZodRails.configure do |config|
|
|
15
|
+
config.output_dir ||= Rails.root.join("app/javascript/schemas").to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/zod_rails.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require_relative "zod_rails/version"
|
|
5
|
+
require_relative "zod_rails/configuration"
|
|
6
|
+
require_relative "zod_rails/mapping/type_mapper"
|
|
7
|
+
require_relative "zod_rails/mapping/validation_mapper"
|
|
8
|
+
require_relative "zod_rails/mapping/enum_mapper"
|
|
9
|
+
require_relative "zod_rails/introspection/column_info"
|
|
10
|
+
require_relative "zod_rails/introspection/validation_info"
|
|
11
|
+
require_relative "zod_rails/introspection/model_inspector"
|
|
12
|
+
require_relative "zod_rails/generation/schema_builder"
|
|
13
|
+
require_relative "zod_rails/generation/typescript_emitter"
|
|
14
|
+
require_relative "zod_rails/generation/file_writer"
|
|
15
|
+
require_relative "zod_rails/generator"
|
|
16
|
+
require_relative "zod_rails/railtie" if defined?(Rails::Railtie)
|
|
17
|
+
|
|
18
|
+
module ZodRails
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def logger
|
|
23
|
+
@logger ||= Logger.new($stdout, level: Logger::WARN)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_writer :logger
|
|
27
|
+
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield(configuration)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset_configuration!
|
|
37
|
+
@configuration = Configuration.new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|