Skip to content

Commit 5452db8

Browse files
committed
feat: multifile generation
1 parent 8cab1ee commit 5452db8

File tree

13 files changed

+404
-5
lines changed

13 files changed

+404
-5
lines changed

lib/anchor/schema.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ class Schema
33
class << self
44
Register = Struct.new(:resources, :enums, keyword_init: true)
55

6+
def register
7+
Register.new(resources: @resources || [], enums: @enums || [])
8+
end
9+
610
def resource(resource)
711
@resources ||= []
812
@resources.push(resource)
@@ -21,7 +25,7 @@ def generate(context: {}, adapter: :type_script, include_all_fields: false, excl
2125
end
2226

2327
adapter.call(
24-
register: Register.new(resources: @resources || [], enums: @enums || []),
28+
register:,
2529
context:,
2630
include_all_fields:,
2731
exclude_fields:,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
module Anchor::TypeScript
2+
class FileStructure
3+
# @param file_name [String] name of file, e.g. model.ts
4+
# @param type [Anchor::Types]
5+
Import = Struct.new(:file_name, :type, keyword_init: true)
6+
class FileUtils
7+
def self.imports_to_code(imports)
8+
imports.group_by(&:file_name).map do |file_name, file_imports|
9+
named_imports = file_imports.map do |import|
10+
import.type.try(:anchor_schema_name) || import.type.name
11+
end.join(",")
12+
13+
"import { #{named_imports} } from \"./#{file_name[..-4]}\";"
14+
end.join("\n") + "\n"
15+
end
16+
17+
def self.def_to_code(identifier, object)
18+
expression = Anchor::TypeScript::Serializer.type_string(object)
19+
"type #{identifier} = #{expression};" + "\n"
20+
end
21+
22+
def self.export_code(identifier)
23+
"export { type #{identifier} };" + "\n"
24+
end
25+
end
26+
27+
def initialize(definition)
28+
@definition = definition
29+
@name = definition.name
30+
@object = definition.object
31+
end
32+
33+
def name
34+
"#{@definition.name}.ts"
35+
end
36+
37+
def to_code(manually_editable: false)
38+
imports_string = FileUtils.imports_to_code(imports)
39+
name = manually_editable ? "Model" : @name
40+
typedef = FileUtils.def_to_code(name, @object)
41+
export_string = FileUtils.export_code(@definition.name)
42+
43+
if manually_editable
44+
start_autogen = "// START AUTOGEN\n"
45+
end_autogen = "// END AUTOGEN\n"
46+
unedited_export_def = "type #{@name} = Model;\n"
47+
[start_autogen, imports_string, typedef, end_autogen, unedited_export_def, export_string].join("\n")
48+
else
49+
[imports_string, typedef, export_string].join("\n")
50+
end
51+
end
52+
53+
# @return [Array<Import>]
54+
def imports
55+
shared_imports + relationship_imports
56+
end
57+
58+
private
59+
60+
# @return [Array<Import>]
61+
def shared_imports
62+
(utils_to_import + enums_to_import).map { |type| Import.new(file_name: "shared.ts", type:) }
63+
end
64+
65+
# @return [Array<Import>]
66+
def relationship_imports
67+
relationships_to_import
68+
.reject { |type| type.anchor_schema_name == @name }
69+
.map { |type| Import.new(file_name: "#{type.anchor_schema_name}.ts", type:) }
70+
end
71+
72+
def relationships_to_import
73+
relationships = @object.properties.find { |p| p.name == :relationships }
74+
return [] if relationships.nil? || relationships.type.try(:properties).nil?
75+
relationships.type.properties.flat_map { |p| references_from_type(p.type) }.uniq.sort_by(&:anchor_schema_name)
76+
end
77+
78+
def references_from_type(type)
79+
case type
80+
when Anchor::Types::Array, Anchor::Types::Maybe then references_from_type(type.type)
81+
when Anchor::Types::Union then type.types.flat_map { |t| references_from_type(t) }
82+
when Anchor::Types::Reference then [type]
83+
end.uniq.sort_by(&:anchor_schema_name)
84+
end
85+
86+
def utils_to_import
87+
maybe_type = has_maybe?(@object).presence && Anchor::Types::Reference.new("Maybe")
88+
[maybe_type].compact
89+
end
90+
91+
def has_maybe?(type)
92+
case type
93+
when Anchor::Types::Maybe then true
94+
when Anchor::Types::Array then has_maybe?(type.type)
95+
when Anchor::Types::Union then type.types.any? { |t| has_maybe?(t) }
96+
when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.any? do |p|
97+
has_maybe?(p)
98+
end
99+
when Anchor::Types::Property then has_maybe?(type.type)
100+
else false
101+
end
102+
end
103+
104+
def enums_to_import
105+
enums_to_import_from_type(@object).uniq.sort_by(&:anchor_schema_name)
106+
end
107+
108+
def enums_to_import_from_type(type)
109+
case type
110+
when Anchor::Types::Enum.singleton_class then [type]
111+
when Anchor::Types::Array then enums_to_import_from_type(type.type)
112+
when Anchor::Types::Maybe then enums_to_import_from_type(type.type)
113+
when Anchor::Types::Union then type.types.flat_map { |t| enums_to_import_from_type(t) }
114+
when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.flat_map do |p|
115+
enums_to_import_from_type(p)
116+
end
117+
when Anchor::Types::Property then enums_to_import_from_type(type.type)
118+
else []
119+
end
120+
end
121+
end
122+
end

lib/anchor/type_script/resource.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
module Anchor::TypeScript
22
class Resource < Anchor::Resource
3-
def express(context: {}, include_all_fields:, exclude_fields:)
3+
Definition = Struct.new(:name, :object, keyword_init: true)
4+
5+
def express(...)
6+
@object = object(...)
7+
expression = Anchor::TypeScript::Serializer.type_string(@object)
8+
"export type #{anchor_schema_name} = " + expression + ";"
9+
end
10+
11+
def definition(...)
12+
@object = object(...)
13+
Definition.new(name: anchor_schema_name, object: @object)
14+
end
15+
16+
def object(context: {}, include_all_fields:, exclude_fields:)
417
included_fields = schema_fetchable_fields(context:, include_all_fields:)
518
included_fields -= exclude_fields if exclude_fields
619

@@ -14,8 +27,7 @@ def express(context: {}, include_all_fields:, exclude_fields:)
1427
Array.wrap(relationships_property) +
1528
[anchor_meta_property].compact + [anchor_links_property].compact
1629

17-
expression = Anchor::TypeScript::Serializer.type_string(Anchor::Types::Object.new(properties))
18-
"export type #{anchor_schema_name} = " + expression + ";"
30+
Anchor::Types::Object.new(properties)
1931
end
2032
end
2133
end

lib/anchor/type_script/schema_generator.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,48 @@ def enums
2525
@enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
2626
end
2727
end
28+
29+
class MultifileSchemaGenerator < Anchor::SchemaGenerator
30+
def initialize(**opts)
31+
super(**opts.except(:manually_editable))
32+
@manually_editable = opts[:manually_editable] || false
33+
end
34+
35+
def call
36+
[shared_file] + resource_files
37+
end
38+
39+
private
40+
41+
def shared_file
42+
maybe_type = "export type Maybe<T> = T | null;"
43+
44+
enum_expressions = enums.map(&:express)
45+
content = ([maybe_type] + enum_expressions).join("\n\n") + "\n"
46+
{ name: "shared.ts", content: }
47+
end
48+
49+
def resource_files
50+
resources.map do |r|
51+
definition = r.definition(
52+
context: @context,
53+
include_all_fields: @include_all_fields,
54+
exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym],
55+
)
56+
57+
file_structure = ::Anchor::TypeScript::FileStructure.new(definition)
58+
content = file_structure.to_code(manually_editable: @manually_editable)
59+
name = file_structure.name
60+
{ name:, content: }
61+
end
62+
end
63+
64+
def resources
65+
@resources ||= @register.resources.map { |r| Anchor::TypeScript::Resource.new(r) }
66+
end
67+
68+
def enums
69+
@enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
70+
end
71+
end
2872
end

lib/anchor/types.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ class Unknown; end
1212
Array = Struct.new(:type)
1313
Literal = Struct.new(:value)
1414
Union = Struct.new(:types)
15-
Reference = Struct.new(:name)
15+
Reference = Struct.new(:name) do
16+
def anchor_schema_name
17+
name
18+
end
19+
end
1620
Property = Struct.new(:name, :type, :optional, :description)
1721
class Object
1822
attr_reader :properties

lib/jsonapi-resources-anchor.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
require "anchor/schema"
1313
require "anchor/types/inference/jsonapi"
1414
require "anchor/types/inference/active_record"
15+
require "anchor/type_script/file_structure"
1516
require "anchor/type_script/types"
1617
require "anchor/type_script/schema_generator"
1718
require "anchor/type_script/serializer"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
require "rails_helper"
2+
3+
# rubocop:disable RSpec/EmptyExampleGroup
4+
RSpec.describe "Example" do
5+
def self.multifile_snapshot_test(filename, generate)
6+
it "generates correct #{filename} schema" do
7+
result = generate.call
8+
result.each do |res|
9+
filename = res[:name]
10+
path = Rails.root.join("test/files/multifile", filename)
11+
schema = res[:content]
12+
unless File.file?(path)
13+
File.open(path, "w") { |file| file.write(schema) }
14+
end
15+
16+
SnapshotUpdate.prompt(path, schema) if ENV["THOR_MERGE"] && File.read(path) != schema
17+
expect(File.read(path)).to eql(schema)
18+
end
19+
end
20+
end
21+
22+
multifile_snapshot_test "schema.ts", -> {
23+
Anchor::TypeScript::MultifileSchemaGenerator.call(
24+
register: Schema.register,
25+
context: {},
26+
include_all_fields: true,
27+
exclude_fields: nil,
28+
manually_editable: true,
29+
)
30+
}
31+
end
32+
# rubocop:enable RSpec/EmptyExampleGroup

spec/example/lib/tasks/anchor.rake

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,45 @@ namespace :anchor do
99
puts "✅ #{File.basename(path)}"
1010
end
1111

12+
def write_to_multi(folder, force, generate)
13+
FileUtils.mkdir_p("test/files/#{folder}")
14+
result = generate.call
15+
result.each do |res|
16+
path = Rails.root.join("test/files/#{folder}", res[:name])
17+
if force || !File.exist?(path)
18+
File.open(path, "w") { |f| f.write(res[:content]) }
19+
next
20+
end
21+
22+
existing_content = File.read(path)
23+
new_content =
24+
if existing_content.starts_with?("// START AUTOGEN\n") && existing_content.include?("// END AUTOGEN\n")
25+
after_end = existing_content.split("// END AUTOGEN\n").second
26+
[res[:content].split("\n// END AUTOGEN\n").first, "// END AUTOGEN", after_end].join("\n")
27+
else
28+
res[:content]
29+
end
30+
31+
File.open(path, "w") { |f| f.write(new_content) }
32+
end
33+
puts "✅ #{folder}"
34+
end
35+
1236
write_to "schema.ts", -> { Schema.generate(include_all_fields: true) }
1337
write_to "test_schema.ts", -> { Schema.generate(context: { role: "test" }) }
1438
write_to "all_fields_false_schema.ts", -> { Schema.generate }
1539
write_to "excluded_fields_schema.ts", -> {
1640
Schema.generate(exclude_fields: { User: [:name, :posts] })
1741
}
42+
write_to_multi "multifile", false, -> {
43+
Anchor::TypeScript::MultifileSchemaGenerator.call(
44+
register: Schema.register,
45+
context: {},
46+
include_all_fields: true,
47+
exclude_fields: nil,
48+
manually_editable: true,
49+
)
50+
}
1851
write_to "json_schema.json", -> { Schema.generate(adapter: :json_schema, include_all_fields: true) }
1952
end
2053
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// START AUTOGEN
2+
3+
import { Post } from "./Post";
4+
import { User } from "./User";
5+
6+
type Model = {
7+
id: number;
8+
type: "comments";
9+
text: string;
10+
createdAt: string;
11+
updatedAt: string;
12+
relationships: {
13+
/** Author of the comment. */
14+
user: User;
15+
deletedBy?: User;
16+
commentable?: User | Post;
17+
};
18+
};
19+
20+
// END AUTOGEN
21+
22+
type Comment = Model;
23+
24+
export { type Comment };

0 commit comments

Comments
 (0)