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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZodRails
4
+ VERSION = "0.1.4"
5
+ 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