typelizer 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0b21f5f78121c64717aa08d6556efbddc0b0b80a3ec6f272d63f699133fff12
4
- data.tar.gz: d8df7c3542950c82e6b25d3f9c39ea808aded01fab0e5b5eaada3801a99031a3
3
+ metadata.gz: d8a6cb6187a0670a589346d969c61a17ae0b9fd60a4e6926c74c2e72c9594130
4
+ data.tar.gz: 4ef1a1e6e728df5ab1d5198a0254cfa68e5e477da57fb9c3c6b7ae2f7efa945d
5
5
  SHA512:
6
- metadata.gz: cc42979e0f00e3aec338fdd4cf3848639ba12a2019293a46e864e908a1dfd8f2f2258ef62d07ed15ec3da66ca7ad740907ac63c6e04f9aae29f18db0eae46584
7
- data.tar.gz: c9504167e013d6aeff1208207b4531f012fe942018e72368296d2a676fb04460ece7b0582a5ef297cf6c037e071ea0868e39c4632765030bdb014fe9a49884ea
6
+ metadata.gz: 56296749f1d5478e98457afac0d2e8599e360d5b7864d596772c825e9970471de8b1d0103ada0120b9adeddab2805e65976c496ce0963785dfd39ddc3dffae91
7
+ data.tar.gz: 25f8667ef0f54560e1c84e8d906b50abc55e01ea761dacff012657bc7d94f7127263166b81c4e317350ee89219f92a070aa042004f42cfad3a6482fe31a0c86f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-02-26
11
+
12
+ ### Added
13
+
14
+ - Alba: nested attributes (`nested` / `nested_attribute`) now generate inline nested TypeScript types with full type inference support, including within traits. ([@pgiblock])
15
+
16
+ - OpenAPI: support for traits in schema generation. ([@skryukov])
17
+
18
+ - Union types in `typelize` for polymorphic associations. Supports serializer class references, pipe-delimited strings, and plain TypeScript type names. ([@skryukov])
19
+
20
+ ```ruby
21
+ typelize commentable: [UserResource, CommentResource]
22
+ typelize approver: "AuthorResource | null"
23
+ typelize content: "TextBlock | ImageBlock"
24
+ ```
25
+
26
+ ### Fixed
27
+
28
+ - OpenAPI: TypeScript-only types (`any`, `unknown`, `never`) and generic types (`Record<string, unknown>`, `Partial<T>`, etc.) no longer produce invalid `$ref` entries. They are mapped to `{type: :object}` instead. ([@skryukov])
29
+ - OpenAPI: fix nullable arrays producing incorrect schemas. ([@skryukov])
30
+ - Fix Typelizer not loading gracefully when required gems are missing at boot time. ([@skryukov])
31
+
32
+ ### Changed
33
+
34
+ - **Internal:** Union types are now stored as arrays of symbols instead of pipe-delimited strings. This fixes import resolution for serializer classes inside unions and eliminates redundant string splitting/joining across the DSL, Interface, and OpenAPI layers. ([@skryukov])
35
+
10
36
  ## [0.8.0] - 2026-02-19
11
37
 
12
38
  ### Added
@@ -381,12 +407,14 @@ and this project adheres to [Semantic Versioning].
381
407
  [@NOX73]: https://github.com/NOX73
382
408
  [@okuramasafumi]: https://github.com/okuramasafumi
383
409
  [@patvice]: https://github.com/patvice
410
+ [@pgiblock]: https://github.com/pgiblock
411
+ [@prog-supdex]: https://github.com/prog-supdex
384
412
  [@PedroAugustoRamalhoDuarte]: https://github.com/PedroAugustoRamalhoDuarte
385
413
  [@skryukov]: https://github.com/skryukov
386
- [@prog-supdex]: https://github.com/prog-supdex
387
414
  [@ventsislaf]: https://github.com/ventsislaf
388
415
 
389
- [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.8.0...HEAD
416
+ [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.9.0...HEAD
417
+ [0.9.0]: https://github.com/skryukov/typelizer/compare/v0.8.0...v0.9.0
390
418
  [0.8.0]: https://github.com/skryukov/typelizer/compare/v0.7.0...v0.8.0
391
419
  [0.7.0]: https://github.com/skryukov/typelizer/compare/v0.6.0...v0.7.0
392
420
  [0.6.0]: https://github.com/skryukov/typelizer/compare/v0.5.6...v0.6.0
data/README.md CHANGED
@@ -149,6 +149,57 @@ class PostResource < ApplicationResource
149
149
  end
150
150
  ```
151
151
 
152
+ Union types are supported for polymorphic associations. You can use serializer class references, which resolve to their generated type names:
153
+
154
+ ```ruby
155
+ class PostResource < ApplicationResource
156
+ attributes :id, :title
157
+
158
+ # Union of two serializers — resolves to generated type names
159
+ typelize commentable: [UserResource, CommentResource]
160
+ attribute :commentable
161
+
162
+ # Nullable union — extracts null and marks as nullable
163
+ typelize approver: "AuthorResource | null"
164
+ attribute :approver
165
+
166
+ # Pipe-delimited string with serializer names
167
+ typelize target: "UserResource | CommentResource"
168
+ attribute :target
169
+
170
+ # String and class constant can be mixed
171
+ typelize item: ["Namespace::UserResource", CommentResource]
172
+ attribute :item
173
+ end
174
+ ```
175
+
176
+ You can also use plain TypeScript type names for custom types that aren't backed by serializers:
177
+
178
+ ```ruby
179
+ class PostResource < ApplicationResource
180
+ attributes :id, :title
181
+
182
+ # Plain type names — passed through as-is to TypeScript
183
+ typelize content: "TextBlock | ImageBlock"
184
+ attribute :content
185
+
186
+ # Works with arrays too
187
+ typelize sections: ["TextBlock", "ImageBlock"]
188
+ attribute :sections
189
+ end
190
+ ```
191
+
192
+ This generates:
193
+
194
+ ```typescript
195
+ type Post = {
196
+ id: number;
197
+ title: string;
198
+ content: TextBlock | ImageBlock;
199
+ sections: TextBlock | ImageBlock;
200
+ }
201
+ ```
202
+
152
203
  For more complex type definitions, use the full API:
153
204
 
154
205
  ```ruby
@@ -24,6 +24,8 @@ namespace :typelizer do
24
24
  end
25
25
 
26
26
  interfaces = Typelizer.interfaces
27
+ raise ArgumentError, "No serializers found. Please ensure all your serializers include Typelizer::DSL." if interfaces.empty?
28
+
27
29
  puts "Finished in #{time} seconds"
28
30
  puts "Found #{interfaces.size} serializers:"
29
31
  puts interfaces.map { |i| "\t#{i.name}" }.join("\n")
@@ -15,7 +15,7 @@ module Typelizer
15
15
  module Hook
16
16
  def delegate(*methods, to:, allow_nil: nil, prefix: nil, **)
17
17
  super.tap do
18
- next unless is_a?(Class) && defined?(ActiveRecord::Base) && self < ActiveRecord::Base
18
+ next unless is_a?(Class) && defined?(ActiveRecord::Base) && !ActiveRecord.autoload?(:Base) && self < ActiveRecord::Base
19
19
 
20
20
  method_prefix = if prefix == true
21
21
  "#{to}_"
data/lib/typelizer/dsl.rb CHANGED
@@ -104,16 +104,16 @@ module Typelizer
104
104
  options = attrs.last.is_a?(Hash) ? attrs.pop : {}
105
105
 
106
106
  if attrs.any?
107
- # Parse type shortcuts and merge options
108
107
  parsed_types = attrs.map { |t| TypeParser.parse(t) }
109
- type_names = parsed_types.map { |p| p[:type] }
110
- options[:type] = type_names.join(" | ")
111
-
112
- # Merge modifier flags from all parsed types
108
+ all_types = parsed_types.flat_map { |p| Array(p[:type]) }
113
109
  parsed_types.each do |parsed|
114
110
  options[:optional] = true if parsed[:optional]
115
111
  options[:multi] = true if parsed[:multi]
112
+ options[:nullable] = true if parsed[:nullable]
116
113
  end
114
+ options[:nullable] = true if all_types.delete(:null)
115
+ # Unwrap single-element arrays: typelize field: ["string"] behaves like typelize field: "string"
116
+ options[:type] = (all_types.size == 1) ? all_types.first : all_types
117
117
  end
118
118
 
119
119
  instance_variable_get(instance_variable)[name.to_sym] ||= {}
@@ -11,7 +11,7 @@ module Typelizer
11
11
 
12
12
  Typelizer.configuration.writers.each do |writer_name, writer_config|
13
13
  interfaces = Typelizer.interfaces(writer_name: writer_name)
14
- raise ArgumentError, "No serializers found. Please ensure all your serializers include Typelizer::DSL." if interfaces.empty?
14
+ next if interfaces.empty?
15
15
 
16
16
  Writer.new(writer_config).call(interfaces, force: force)
17
17
  end
@@ -1,5 +1,9 @@
1
+ require_relative "type_inference"
2
+
1
3
  module Typelizer
2
4
  class Interface
5
+ include TypeInference
6
+
3
7
  attr_reader :serializer, :context
4
8
 
5
9
  def initialize(serializer:, context:)
@@ -60,7 +64,7 @@ module Typelizer
60
64
 
61
65
  def enum_types
62
66
  @enum_types ||= begin
63
- all_properties = properties + trait_interfaces.flat_map(&:properties)
67
+ all_properties = collect_all_properties(properties + trait_interfaces.flat_map(&:properties))
64
68
  all_properties
65
69
  .select(&:enum_definition)
66
70
  .uniq(&:enum_type_name)
@@ -104,12 +108,12 @@ module Typelizer
104
108
 
105
109
  def imports
106
110
  @imports ||= begin
107
- # Include both main properties and trait properties for import collection
108
- all_properties = properties_to_print + trait_interfaces.flat_map(&:properties)
111
+ # Include both main properties and trait properties for import collection,
112
+ # recursively including nested sub-properties
113
+ all_properties = collect_all_properties(properties_to_print + trait_interfaces.flat_map(&:properties))
109
114
 
110
- association_serializers, attribute_types = all_properties.filter_map(&:type)
111
- .uniq
112
- .partition { |type| type.is_a?(Interface) }
115
+ flat_types = all_properties.filter_map(&:type).flat_map { |t| Array(t) }.uniq
116
+ association_serializers, attribute_types = flat_types.partition { |type| type.is_a?(Interface) }
113
117
 
114
118
  serializer_types = association_serializers
115
119
  .filter_map { |interface| interface.name if interface.name != name && !interface.inline? }
@@ -158,6 +162,16 @@ module Typelizer
158
162
 
159
163
  private
160
164
 
165
+ def collect_all_properties(props)
166
+ props.flat_map do |prop|
167
+ if prop.nested_properties&.any?
168
+ [prop] + collect_all_properties(prop.nested_properties)
169
+ else
170
+ [prop]
171
+ end
172
+ end
173
+ end
174
+
161
175
  def self_type_name
162
176
  serializer.name.match(/(\w+::)?(\w+)(Serializer|Resource)/)[2]
163
177
  end
@@ -182,6 +196,7 @@ module Typelizer
182
196
  .then { |p| has_dsl ? p : apply_model_inference(p) }
183
197
  .then { |p| apply_multi_flag(p, multi_attrs) }
184
198
  .then { |p| apply_metadata(p) }
199
+ .then { |p| infer_nested_property_types(p) }
185
200
  end
186
201
  end
187
202
 
@@ -199,47 +214,38 @@ module Typelizer
199
214
 
200
215
  def resolve_class_type(attrs)
201
216
  type = attrs[:type]
202
- return attrs unless type.is_a?(String) || type.is_a?(Symbol)
203
-
204
- klass = Object.const_get(type.to_s)
205
- return attrs unless klass.respond_to?(:typelizer_config)
206
217
 
207
- attrs.merge(type: context.interface_for(klass))
208
- rescue NameError
209
- attrs
218
+ case type
219
+ when Array
220
+ resolve_union_class_types(attrs)
221
+ when String, Symbol
222
+ resolve_single_class_type(attrs)
223
+ else
224
+ attrs
225
+ end
210
226
  end
211
227
 
212
- def apply_model_inference(prop)
213
- model_plugin.infer_types(prop)
228
+ def resolve_single_class_type(attrs)
229
+ attrs.merge(type: resolve_type_part(attrs[:type]))
214
230
  end
215
231
 
216
- def apply_multi_flag(prop, multi_attrs)
217
- return prop unless multi_attrs.include?(prop.column_name.to_sym)
218
-
219
- prop.with(multi: true)
232
+ def resolve_union_class_types(attrs)
233
+ resolved = attrs[:type].map { |part| resolve_type_part(part) }
234
+ # Unwrap single-element arrays (e.g., after null extraction from ["Serializer", null])
235
+ attrs.merge(type: (resolved.size == 1) ? resolved.first : resolved)
220
236
  end
221
237
 
222
- def apply_metadata(prop)
223
- prop.tap do |p|
224
- p.comment ||= model_plugin.comment_for(p) if config.comments && p.comment != false
225
- p.enum ||= model_plugin.enum_for(p) if p.enum != false
226
- end
238
+ def resolve_type_part(part)
239
+ klass = Object.const_get(part.to_s)
240
+ klass.respond_to?(:typelizer_config) ? context.interface_for(klass) : part
241
+ rescue NameError
242
+ part
227
243
  end
228
244
 
229
- def model_class
230
- return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
231
-
232
- # Execute the `serializer_model_mapper` lambda in the context of the `config` object
233
- # This giving a possibility to access other lambdas, for example, `serializer_name_mapper`
234
- config.instance_exec(serializer, &config.serializer_model_mapper)
235
- rescue NameError => e
236
- Typelizer.logger.debug("model_mapper failed for serializer #{serializer.name}: #{e.class}: #{e.message}")
237
-
238
- nil
239
- end
245
+ def apply_multi_flag(prop, multi_attrs)
246
+ return prop unless multi_attrs.include?(prop.column_name.to_sym)
240
247
 
241
- def model_plugin
242
- @model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
248
+ prop.with(multi: true)
243
249
  end
244
250
  end
245
251
  end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typelizer
4
- class OpenAPI
4
+ module OpenAPI
5
5
  SUPPORTED_VERSIONS = ["3.0", "3.1"].freeze
6
+
6
7
  OPENAPI_TYPES = %i[integer number string boolean object array null].freeze
8
+ TS_OBJECT_TYPES = %w[any unknown never Record Partial Pick Omit].freeze
7
9
 
8
10
  COLUMN_TYPE_MAP = {
9
11
  integer: {type: :integer},
@@ -25,100 +27,188 @@ module Typelizer
25
27
  cidr: {type: :string}
26
28
  }.freeze
27
29
 
28
- def self.schema_for(interface, openapi_version: "3.0")
29
- raise ArgumentError, "Unsupported openapi_version: #{openapi_version}. Must be one of: #{SUPPORTED_VERSIONS.join(", ")}" unless SUPPORTED_VERSIONS.include?(openapi_version.to_s)
30
+ class << self
31
+ def schema_for(interface, openapi_version: "3.0")
32
+ validate_version!(openapi_version)
30
33
 
31
- required_props = interface.properties.reject(&:optional).map(&:name)
32
- schema = {
33
- type: :object,
34
- properties: interface.properties.to_h { |prop| [prop.name, property_schema(prop, openapi_version: openapi_version)] }
35
- }
36
- schema[:required] = required_props if required_props.any?
37
- schema
38
- end
34
+ required_props = interface.properties.reject(&:optional).map(&:name)
35
+ schema = {
36
+ type: :object,
37
+ properties: interface.properties.to_h { |prop| [prop.name, property_schema(prop, openapi_version: openapi_version)] }
38
+ }
39
+ schema[:required] = required_props if required_props.any?
40
+ schema
41
+ end
39
42
 
40
- def self.property_schema(property, openapi_version: "3.0")
41
- raise ArgumentError, "Unsupported openapi_version: #{openapi_version}. Must be one of: #{SUPPORTED_VERSIONS.join(", ")}" unless SUPPORTED_VERSIONS.include?(openapi_version.to_s)
43
+ def property_schema(property, openapi_version: "3.0")
44
+ if property.type.is_a?(Array)
45
+ return union_schema(property, openapi_version: openapi_version)
46
+ end
42
47
 
43
- definition = base_type(property)
44
- ref = definition.delete("$ref")
48
+ definition = base_type(property, openapi_version: openapi_version)
49
+ ref = definition.delete("$ref")
50
+
51
+ definition = if ref
52
+ ref_schema(ref, property, openapi_version: openapi_version)
53
+ else
54
+ inline_schema(definition, property, openapi_version: openapi_version)
55
+ end
45
56
 
46
- definition = if ref
47
- ref_schema(ref, property, openapi_version: openapi_version)
48
- else
49
- inline_schema(definition, property, openapi_version: openapi_version)
57
+ definition = wrap_traits(definition, property, openapi_version: openapi_version)
58
+ wrap_multi(definition, property, openapi_version: openapi_version)
50
59
  end
51
60
 
52
- if property.multi
53
- definition = {type: :array, items: definition}
54
- if property.nullable
55
- if openapi_version.to_s >= "3.1"
56
- definition[:type] = [:array, :null]
61
+ private
62
+
63
+ def ref_schema(ref, property, openapi_version:)
64
+ ref_obj = {"$ref" => ref}
65
+ item_nullable = !property.multi && property.nullable
66
+
67
+ if v31?(openapi_version)
68
+ definition = item_nullable ? {oneOf: [ref_obj, {type: :null}]} : ref_obj
69
+ else
70
+ needs_wrapper = item_nullable || (!property.multi && (property.comment.is_a?(String) || property.deprecated))
71
+ definition = needs_wrapper ? {allOf: [ref_obj]} : ref_obj
72
+ definition[:nullable] = true if item_nullable
73
+ end
74
+
75
+ apply_metadata(definition, property) unless property.multi
76
+ definition
77
+ end
78
+
79
+ def inline_schema(definition, property, openapi_version:)
80
+ unless property.multi
81
+ apply_nullable(definition, property, openapi_version: openapi_version)
82
+ apply_metadata(definition, property)
83
+ end
84
+ if property.enum.is_a?(Array)
85
+ items_nullable = !property.multi && property.nullable
86
+ definition[:enum] = (items_nullable && !property.enum.include?(nil)) ? property.enum + [nil] : property.enum
87
+ end
88
+ definition
89
+ end
90
+
91
+ def union_schema(property, openapi_version:)
92
+ schemas = property.type.map { |part| union_member_schema(part) }
93
+
94
+ definition = {anyOf: schemas}
95
+
96
+ unless property.multi
97
+ apply_nullable(definition, property, openapi_version: openapi_version)
98
+ apply_metadata(definition, property)
99
+ end
100
+
101
+ wrap_multi(definition, property, openapi_version: openapi_version)
102
+ end
103
+
104
+ def union_member_schema(type)
105
+ if type.respond_to?(:properties)
106
+ {"$ref" => "#/components/schemas/#{type.name}"}
107
+ else
108
+ sym = type.to_sym
109
+ if OPENAPI_TYPES.include?(sym)
110
+ {type: sym}
111
+ elsif ts_only_type?(type.to_s)
112
+ {type: :object}
57
113
  else
58
- definition[:nullable] = true
114
+ {"$ref" => "#/components/schemas/#{type}"}
59
115
  end
60
116
  end
61
117
  end
62
118
 
63
- definition
64
- end
119
+ def wrap_traits(definition, property, openapi_version:)
120
+ return definition unless property.respond_to?(:with_traits) && property.with_traits&.any? && property.type.respond_to?(:name)
65
121
 
66
- def self.ref_schema(ref, property, openapi_version:)
67
- has_siblings = property.nullable || property.comment.is_a?(String) || property.deprecated
68
- ref_obj = {"$ref" => ref}
122
+ trait_refs = property.with_traits.map do |t|
123
+ {"$ref" => "#/components/schemas/#{property.type.name}#{t.to_s.camelize}Trait"}
124
+ end
69
125
 
70
- if openapi_version.to_s >= "3.1"
71
- definition = property.nullable ? {oneOf: [ref_obj, {type: :null}]} : ref_obj
72
- else
73
- # In 3.0, $ref must stand alone — use allOf wrapper when siblings are needed
74
- definition = has_siblings ? {allOf: [ref_obj]} : ref_obj
75
- definition[:nullable] = true if property.nullable
126
+ base_ref = definition.delete("$ref")
127
+ if base_ref
128
+ definition = {allOf: [{"$ref" => base_ref}] + trait_refs}
129
+ elsif definition[:oneOf]
130
+ non_null = definition[:oneOf].reject { |s| s[:type] == :null }
131
+ null_schemas = definition[:oneOf].select { |s| s[:type] == :null }
132
+ all_of = non_null + trait_refs
133
+ definition = null_schemas.any? ? {oneOf: [{allOf: all_of}, *null_schemas]} : {allOf: all_of}
134
+ elsif definition[:allOf]
135
+ definition[:allOf].concat(trait_refs)
136
+ else
137
+ raise ArgumentError, "Unexpected schema shape for traits on property #{property.name}: #{definition.inspect}"
138
+ end
139
+
140
+ definition[:nullable] = true if !v31?(openapi_version) && property.nullable
141
+ definition
76
142
  end
77
143
 
78
- definition[:description] = property.comment if property.comment.is_a?(String)
79
- definition[:deprecated] = true if property.deprecated
80
- definition
81
- end
144
+ def apply_metadata(definition, property)
145
+ definition[:description] = property.comment if property.comment.is_a?(String)
146
+ definition[:deprecated] = true if property.deprecated
147
+ end
82
148
 
83
- def self.inline_schema(definition, property, openapi_version:)
84
- # For multi properties, nullable is applied to the array container in property_schema
85
- unless property.multi
149
+ def apply_nullable(definition, property, openapi_version:)
150
+ return unless property.nullable
151
+
152
+ if definition[:anyOf]
153
+ v31?(openapi_version) ? definition[:anyOf] << {type: :null} : definition[:nullable] = true
154
+ elsif definition[:type]
155
+ v31?(openapi_version) ? definition[:type] = [definition[:type], :null] : definition[:nullable] = true
156
+ end
157
+ end
158
+
159
+ def wrap_multi(definition, property, openapi_version:)
160
+ return definition unless property.multi
161
+
162
+ definition = {type: :array, items: definition}
163
+ apply_metadata(definition, property)
86
164
  if property.nullable
87
- if openapi_version.to_s >= "3.1"
88
- definition[:type] = [definition[:type], :null]
165
+ v31?(openapi_version) ? definition[:type] = [:array, :null] : definition[:nullable] = true
166
+ end
167
+ definition
168
+ end
169
+
170
+ def base_type(property, openapi_version:)
171
+ if property.type.respond_to?(:properties)
172
+ if property.type.respond_to?(:inline?) && property.type.inline?
173
+ schema_for(property.type, openapi_version: openapi_version)
89
174
  else
90
- definition[:nullable] = true
175
+ {"$ref" => "#/components/schemas/#{property.type.name}"}
91
176
  end
177
+ elsif property.type.nil? && property.respond_to?(:nested_properties) && property.nested_properties&.any?
178
+ nested_schema(property, openapi_version: openapi_version)
179
+ elsif property.column_type && COLUMN_TYPE_MAP.key?(property.column_type)
180
+ result = COLUMN_TYPE_MAP[property.column_type].dup
181
+ result[:type] = :string if property.enum
182
+ result
183
+ elsif (property.type.is_a?(String) || property.type.is_a?(Symbol)) && !OPENAPI_TYPES.include?(property.type.to_sym) && !ts_only_type?(property.type.to_s)
184
+ {"$ref" => "#/components/schemas/#{property.type}"}
185
+ else
186
+ type = property.type.to_s.to_sym
187
+ OPENAPI_TYPES.include?(type) ? {type: type} : {type: :object}
92
188
  end
93
189
  end
94
- definition[:description] = property.comment if property.comment.is_a?(String)
95
- if property.enum.is_a?(Array)
96
- items_nullable = !property.multi && property.nullable
97
- definition[:enum] = (items_nullable && !property.enum.include?(nil)) ? property.enum + [nil] : property.enum
190
+
191
+ def nested_schema(property, openapi_version:)
192
+ required = property.nested_properties.reject(&:optional).map(&:name)
193
+ schema = {
194
+ type: :object,
195
+ properties: property.nested_properties.to_h { |p| [p.name, property_schema(p, openapi_version: openapi_version)] }
196
+ }
197
+ schema[:required] = required if required.any?
198
+ schema
98
199
  end
99
- definition[:deprecated] = true if property.deprecated
100
- definition
101
- end
102
- private_class_method :ref_schema, :inline_schema
103
-
104
- def self.base_type(property)
105
- if property.type.respond_to?(:properties)
106
- {"$ref" => "#/components/schemas/#{property.type.name}"}
107
- elsif property.column_type && COLUMN_TYPE_MAP.key?(property.column_type)
108
- result = COLUMN_TYPE_MAP[property.column_type].dup
109
- result[:type] = :string if property.enum
110
- result
111
- elsif property.type.is_a?(String) && !OPENAPI_TYPES.include?(property.type.to_sym)
112
- {"$ref" => "#/components/schemas/#{property.type}"}
113
- else
114
- type = property.type.to_s.to_sym
115
- if OPENAPI_TYPES.include?(type)
116
- {type: type}
117
- else
118
- {type: :object}
119
- end
200
+
201
+ def v31?(openapi_version)
202
+ openapi_version.to_s == "3.1"
203
+ end
204
+
205
+ def ts_only_type?(type_str)
206
+ type_str.start_with?("{") || type_str.include?("<") || TS_OBJECT_TYPES.include?(type_str)
207
+ end
208
+
209
+ def validate_version!(openapi_version)
210
+ raise ArgumentError, "Unsupported openapi_version: #{openapi_version}. Must be one of: #{SUPPORTED_VERSIONS.join(", ")}" unless SUPPORTED_VERSIONS.include?(openapi_version.to_s)
120
211
  end
121
212
  end
122
- private_class_method :base_type
123
213
  end
124
214
  end
@@ -2,7 +2,7 @@ module Typelizer
2
2
  Property = Struct.new(
3
3
  :name, :type, :optional, :nullable,
4
4
  :multi, :column_name, :column_type, :comment, :enum, :enum_type_name, :deprecated,
5
- :with_traits,
5
+ :with_traits, :nested_properties, :nested_typelizes,
6
6
  keyword_init: true
7
7
  ) do
8
8
  def with(**attrs)
@@ -52,8 +52,13 @@ module Typelizer
52
52
  def fingerprint
53
53
  # Use array format for consistent output across Ruby versions
54
54
  # (Hash#inspect format changed in Ruby 3.4)
55
- # Exclude fields that do not affect generated TypeScript output
56
- to_h.except(:column_type).merge(type: UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical))
55
+ # Exclude fields that do not affect generated TypeScript output.
56
+ # Exclude nested_properties/nested_typelizes from to_h to avoid changing
57
+ # fingerprints for properties that don't use them.
58
+ # nested_typelizes is excluded entirely as it only affects inference, not output.
59
+ to_h.except(:column_type, :nested_properties, :nested_typelizes)
60
+ .merge(type: UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical))
61
+ .then { |h| nested_properties&.any? ? h.merge(nested_properties: nested_properties.map(&:fingerprint)) : h }
57
62
  .to_a.inspect
58
63
  end
59
64
 
@@ -90,7 +95,20 @@ module Typelizer
90
95
  return enum_values.join(" | ")
91
96
  end
92
97
 
93
- type.respond_to?(:name) ? type.name : type || "unknown"
98
+ if type.nil? && nested_properties&.any?
99
+ inner = nested_properties.map { |p|
100
+ rendered = p.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) + ";"
101
+ rendered.gsub(/^/, " ")
102
+ }.join("\n")
103
+ return "{\n#{inner}\n}"
104
+ end
105
+
106
+ case type
107
+ when Array
108
+ type.map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(" | ")
109
+ else
110
+ type.respond_to?(:name) ? type.name : type&.to_s || "unknown"
111
+ end
94
112
  end
95
113
  end
96
114
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Typelizer
4
4
  module SerializerPlugins
5
- class Alba::TraitAttributeCollector
5
+ class Alba::BlockAttributeCollector
6
6
  attr_reader :collected_attributes, :collected_typelizes
7
7
 
8
8
  def initialize
@@ -53,15 +53,26 @@ module Typelizer
53
53
  end
54
54
  end
55
55
 
56
- # Simple struct to hold association info from traits
57
- TraitAssociation = Struct.new(:name, :resource, :with_traits, :multi, :key, keyword_init: true)
56
+ # Simple struct to hold association info from blocks
57
+ BlockAssociation = Struct.new(:name, :resource, :with_traits, :multi, :key, keyword_init: true)
58
+
59
+ # Struct to hold nested attribute info captured within a block
60
+ BlockNestedAttribute = Struct.new(:name, :block, keyword_init: true)
61
+
62
+ def nested_attribute(name, **options, &block)
63
+ raise ArgumentError, "Block is required for nested_attribute" unless block
64
+
65
+ @collected_attributes[name] = BlockNestedAttribute.new(name: name, block: block)
66
+ end
67
+
68
+ alias_method :nested, :nested_attribute
58
69
 
59
70
  # Support association methods that might be used in traits
60
71
  def one(name, **options, &block)
61
72
  resource = options[:resource] || options[:serializer]
62
73
  with_traits = options[:with_traits]
63
74
  key = options[:key] || name
64
- @collected_attributes[key] = TraitAssociation.new(
75
+ @collected_attributes[key] = BlockAssociation.new(
65
76
  name: name,
66
77
  resource: resource,
67
78
  with_traits: with_traits,
@@ -77,7 +88,7 @@ module Typelizer
77
88
  resource = options[:resource] || options[:serializer]
78
89
  with_traits = options[:with_traits]
79
90
  key = options[:key] || name
80
- @collected_attributes[key] = TraitAssociation.new(
91
+ @collected_attributes[key] = BlockAssociation.new(
81
92
  name: name,
82
93
  resource: resource,
83
94
  with_traits: with_traits,
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../type_inference"
4
+
3
5
  module Typelizer
4
6
  module SerializerPlugins
5
7
  class Alba::TraitInterface
8
+ include TypeInference
9
+
6
10
  attr_reader :serializer, :trait_name, :context, :plugin
7
11
 
8
12
  def initialize(serializer:, trait_name:, context:, plugin:)
@@ -33,31 +37,13 @@ module Typelizer
33
37
 
34
38
  def infer_types(props, typelizes)
35
39
  props.map do |prop|
36
- # First check for typelize DSL in the trait
37
40
  dsl_type = typelizes[prop.column_name.to_sym] || typelizes[prop.name.to_sym]
38
- if dsl_type&.any?
39
- next prop.with(**dsl_type).tap do |property|
40
- property.comment ||= model_plugin.comment_for(property) if config.comments && property.comment != false
41
- property.enum ||= model_plugin.enum_for(property) if property.enum != false
42
- end
43
- end
44
-
45
- # Fall back to model plugin for type inference
46
- model_plugin.infer_types(prop)
41
+ prop
42
+ .then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
43
+ .then { |p| apply_metadata(p) }
44
+ .then { |p| infer_nested_property_types(p) }
47
45
  end
48
46
  end
49
-
50
- def model_class
51
- return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
52
-
53
- config.instance_exec(serializer, &config.serializer_model_mapper)
54
- rescue NameError
55
- nil
56
- end
57
-
58
- def model_plugin
59
- @model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
60
- end
61
47
  end
62
48
  end
63
49
  end
@@ -48,19 +48,19 @@ module Typelizer
48
48
  return [], {} unless trait_block
49
49
 
50
50
  # Create a collector to capture attributes defined in the trait block
51
- collector = TraitAttributeCollector.new
51
+ collector = BlockAttributeCollector.new
52
52
  collector.instance_exec(&trait_block)
53
53
 
54
54
  props = collector.collected_attributes.map do |name, attr|
55
- build_trait_property(name.is_a?(Symbol) ? name.name : name, attr)
55
+ build_collected_property(name.is_a?(Symbol) ? name.name : name, attr)
56
56
  end
57
57
 
58
58
  [props, collector.collected_typelizes]
59
59
  end
60
60
 
61
- def build_trait_property(name, attr)
61
+ def build_collected_property(name, attr)
62
62
  case attr
63
- when TraitAttributeCollector::TraitAssociation
63
+ when BlockAttributeCollector::BlockAssociation
64
64
  with_traits = Array(attr.with_traits) if attr.with_traits
65
65
  resource = attr.resource || infer_resource_from_name(name)
66
66
 
@@ -73,6 +73,19 @@ module Typelizer
73
73
  column_name: name,
74
74
  with_traits: with_traits
75
75
  )
76
+ when BlockAttributeCollector::BlockNestedAttribute
77
+ prop_name = has_transform_key?(serializer) ? fetch_key(serializer, name) : name
78
+ nested_props, nested_typelizes = collect_nested_block(attr.block)
79
+ Property.new(
80
+ name: prop_name,
81
+ type: nil,
82
+ optional: false,
83
+ nullable: false,
84
+ multi: false,
85
+ column_name: name,
86
+ nested_properties: nested_props,
87
+ nested_typelizes: nested_typelizes
88
+ )
76
89
  else
77
90
  build_property(name, attr)
78
91
  end
@@ -95,7 +108,7 @@ module Typelizer
95
108
  end
96
109
 
97
110
  def trait_interfaces
98
- traits.map do |trait_name, _|
111
+ @trait_interfaces ||= traits.map do |trait_name, _|
99
112
  TraitInterface.new(
100
113
  serializer: serializer,
101
114
  trait_name: trait_name,
@@ -164,6 +177,8 @@ module Typelizer
164
177
  **options
165
178
  )
166
179
  when ::Alba::NestedAttribute
180
+ block = attr.instance_variable_get(:@block)
181
+ nested_props, nested_typelizes = collect_nested_block(block)
167
182
  Property.new(
168
183
  name: name,
169
184
  type: nil,
@@ -171,6 +186,8 @@ module Typelizer
171
186
  nullable: false,
172
187
  multi: false,
173
188
  column_name: column_name,
189
+ nested_properties: nested_props,
190
+ nested_typelizes: nested_typelizes,
174
191
  **options
175
192
  )
176
193
  when ::Alba::ConditionalAttribute
@@ -192,14 +209,24 @@ module Typelizer
192
209
  ::Alba.transform_key(key, transform_type: serializer._transform_type)
193
210
  end
194
211
 
195
- private
196
-
197
212
  def ts_mapper
198
213
  config.plugin_configs.dig(:alba, :ts_mapper) || ALBA_TS_MAPPER
199
214
  end
215
+
216
+ def collect_nested_block(block)
217
+ collector = BlockAttributeCollector.new
218
+ collector.instance_exec(&block)
219
+
220
+ props = collector.collected_attributes.map do |attr_name, attr|
221
+ attr_name_str = attr_name.is_a?(Symbol) ? attr_name.name : attr_name
222
+ build_collected_property(attr_name_str, attr)
223
+ end
224
+
225
+ [props, collector.collected_typelizes]
226
+ end
200
227
  end
201
228
  end
202
229
  end
203
230
 
204
- require_relative "alba/trait_attribute_collector"
231
+ require_relative "alba/block_attribute_collector"
205
232
  require_relative "alba/trait_interface"
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module TypeInference
5
+ private
6
+
7
+ def apply_model_inference(prop)
8
+ model_plugin.infer_types(prop)
9
+ end
10
+
11
+ def apply_metadata(prop)
12
+ prop.tap do |p|
13
+ p.comment ||= model_plugin.comment_for(p) if config.comments && p.comment != false
14
+ p.enum ||= model_plugin.enum_for(p) if p.enum != false
15
+ end
16
+ end
17
+
18
+ def infer_nested_property_types(prop)
19
+ return prop unless prop.nested_properties&.any?
20
+
21
+ typelizes = prop.nested_typelizes || {}
22
+ inferred = prop.nested_properties.map do |sub_prop|
23
+ dsl_type = typelizes[sub_prop.column_name.to_sym] || typelizes[sub_prop.name.to_sym]
24
+ sub_prop
25
+ .then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
26
+ .then { |p| apply_metadata(p) }
27
+ .then { |p| infer_nested_property_types(p) }
28
+ end
29
+
30
+ prop.with(nested_properties: inferred)
31
+ end
32
+
33
+ def model_class
34
+ return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
35
+
36
+ config.instance_exec(serializer, &config.serializer_model_mapper)
37
+ rescue NameError => e
38
+ Typelizer.logger.debug("model_mapper failed for serializer #{serializer.name}: #{e.class}: #{e.message}")
39
+
40
+ nil
41
+ end
42
+
43
+ def model_plugin
44
+ @model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
45
+ end
46
+ end
47
+ end
@@ -14,6 +14,8 @@ module Typelizer
14
14
  return options if type_def.nil?
15
15
 
16
16
  type_str = type_def.to_s
17
+ return parse_union(type_str, **options) if type_str.include?("|")
18
+
17
19
  match = TYPE_PATTERN.match(type_str)
18
20
 
19
21
  return {type: type_def}.merge(options) unless match
@@ -34,6 +36,18 @@ module Typelizer
34
36
  type_str = type_def.to_s
35
37
  type_str.end_with?("?", "[]")
36
38
  end
39
+
40
+ private
41
+
42
+ def parse_union(type_str, **options)
43
+ parts = type_str.split(/\s*\|\s*/)
44
+ options[:nullable] = true if parts.delete("null")
45
+ if parts.size == 1
46
+ parse(parts.first, **options)
47
+ else
48
+ {type: parts.map(&:to_sym)}.merge(options)
49
+ end
50
+ end
37
51
  end
38
52
  end
39
53
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typelizer
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/typelizer.rb CHANGED
@@ -72,7 +72,14 @@ module Typelizer
72
72
  end
73
73
 
74
74
  def openapi_schemas(writer_name: nil, openapi_version: "3.0")
75
- interfaces(writer_name: writer_name).to_h { |i| [i.name, OpenAPI.schema_for(i, openapi_version: openapi_version)] }
75
+ result = {}
76
+ interfaces(writer_name: writer_name).each do |i|
77
+ result[i.name] = OpenAPI.schema_for(i, openapi_version: openapi_version)
78
+ i.trait_interfaces.each do |trait|
79
+ result[trait.name] = OpenAPI.schema_for(trait, openapi_version: openapi_version)
80
+ end
81
+ end
82
+ result
76
83
  end
77
84
 
78
85
  private
@@ -85,7 +92,10 @@ module Typelizer
85
92
  resolved = base_classes.filter_map do |base_class|
86
93
  Object.const_get(base_class) if Object.const_defined?(base_class)
87
94
  end
88
- raise ArgumentError, "No serializers found. Please ensure all your serializers include Typelizer::DSL." if base_classes.any? && resolved.none?
95
+ if base_classes.any? && resolved.none?
96
+ logger.warn("Typelizer: No serializers found. Ensure your serializers include Typelizer::DSL.")
97
+ return []
98
+ end
89
99
 
90
100
  (resolved + resolved.flat_map(&:descendants)).uniq
91
101
  .reject { |serializer| reject_class.call(serializer: serializer) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typelizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -72,7 +72,7 @@ files:
72
72
  - lib/typelizer/renderer.rb
73
73
  - lib/typelizer/serializer_config_layer.rb
74
74
  - lib/typelizer/serializer_plugins/alba.rb
75
- - lib/typelizer/serializer_plugins/alba/trait_attribute_collector.rb
75
+ - lib/typelizer/serializer_plugins/alba/block_attribute_collector.rb
76
76
  - lib/typelizer/serializer_plugins/alba/trait_interface.rb
77
77
  - lib/typelizer/serializer_plugins/ams.rb
78
78
  - lib/typelizer/serializer_plugins/auto.rb
@@ -86,6 +86,7 @@ files:
86
86
  - lib/typelizer/templates/inheritance.ts.erb
87
87
  - lib/typelizer/templates/inline_type.ts.erb
88
88
  - lib/typelizer/templates/interface.ts.erb
89
+ - lib/typelizer/type_inference.rb
89
90
  - lib/typelizer/type_parser.rb
90
91
  - lib/typelizer/union_type_sorter.rb
91
92
  - lib/typelizer/version.rb