Skip to content

Commit

Permalink
feat: multifile generation
Browse files Browse the repository at this point in the history
  • Loading branch information
mattkhan committed Jan 27, 2025
1 parent 8cab1ee commit 5452db8
Show file tree
Hide file tree
Showing 13 changed files with 404 additions and 5 deletions.
6 changes: 5 additions & 1 deletion lib/anchor/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ class Schema
class << self
Register = Struct.new(:resources, :enums, keyword_init: true)

def register
Register.new(resources: @resources || [], enums: @enums || [])
end

def resource(resource)
@resources ||= []
@resources.push(resource)
Expand All @@ -21,7 +25,7 @@ def generate(context: {}, adapter: :type_script, include_all_fields: false, excl
end

adapter.call(
register: Register.new(resources: @resources || [], enums: @enums || []),
register:,
context:,
include_all_fields:,
exclude_fields:,
Expand Down
122 changes: 122 additions & 0 deletions lib/anchor/type_script/file_structure.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
module Anchor::TypeScript
class FileStructure
# @param file_name [String] name of file, e.g. model.ts
# @param type [Anchor::Types]
Import = Struct.new(:file_name, :type, keyword_init: true)
class FileUtils
def self.imports_to_code(imports)
imports.group_by(&:file_name).map do |file_name, file_imports|
named_imports = file_imports.map do |import|
import.type.try(:anchor_schema_name) || import.type.name
end.join(",")

"import { #{named_imports} } from \"./#{file_name[..-4]}\";"
end.join("\n") + "\n"
end

def self.def_to_code(identifier, object)
expression = Anchor::TypeScript::Serializer.type_string(object)
"type #{identifier} = #{expression};" + "\n"
end

def self.export_code(identifier)
"export { type #{identifier} };" + "\n"
end
end

def initialize(definition)
@definition = definition
@name = definition.name
@object = definition.object
end

def name
"#{@definition.name}.ts"
end

def to_code(manually_editable: false)
imports_string = FileUtils.imports_to_code(imports)
name = manually_editable ? "Model" : @name
typedef = FileUtils.def_to_code(name, @object)
export_string = FileUtils.export_code(@definition.name)

if manually_editable
start_autogen = "// START AUTOGEN\n"
end_autogen = "// END AUTOGEN\n"
unedited_export_def = "type #{@name} = Model;\n"
[start_autogen, imports_string, typedef, end_autogen, unedited_export_def, export_string].join("\n")
else
[imports_string, typedef, export_string].join("\n")
end
end

# @return [Array<Import>]
def imports
shared_imports + relationship_imports
end

private

# @return [Array<Import>]
def shared_imports
(utils_to_import + enums_to_import).map { |type| Import.new(file_name: "shared.ts", type:) }
end

# @return [Array<Import>]
def relationship_imports
relationships_to_import
.reject { |type| type.anchor_schema_name == @name }
.map { |type| Import.new(file_name: "#{type.anchor_schema_name}.ts", type:) }
end

def relationships_to_import
relationships = @object.properties.find { |p| p.name == :relationships }
return [] if relationships.nil? || relationships.type.try(:properties).nil?
relationships.type.properties.flat_map { |p| references_from_type(p.type) }.uniq.sort_by(&:anchor_schema_name)
end

def references_from_type(type)
case type
when Anchor::Types::Array, Anchor::Types::Maybe then references_from_type(type.type)
when Anchor::Types::Union then type.types.flat_map { |t| references_from_type(t) }
when Anchor::Types::Reference then [type]
end.uniq.sort_by(&:anchor_schema_name)
end

def utils_to_import
maybe_type = has_maybe?(@object).presence && Anchor::Types::Reference.new("Maybe")
[maybe_type].compact
end

def has_maybe?(type)
case type
when Anchor::Types::Maybe then true
when Anchor::Types::Array then has_maybe?(type.type)
when Anchor::Types::Union then type.types.any? { |t| has_maybe?(t) }
when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.any? do |p|
has_maybe?(p)
end
when Anchor::Types::Property then has_maybe?(type.type)
else false
end
end

def enums_to_import
enums_to_import_from_type(@object).uniq.sort_by(&:anchor_schema_name)
end

def enums_to_import_from_type(type)
case type
when Anchor::Types::Enum.singleton_class then [type]
when Anchor::Types::Array then enums_to_import_from_type(type.type)
when Anchor::Types::Maybe then enums_to_import_from_type(type.type)
when Anchor::Types::Union then type.types.flat_map { |t| enums_to_import_from_type(t) }
when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.flat_map do |p|
enums_to_import_from_type(p)
end
when Anchor::Types::Property then enums_to_import_from_type(type.type)
else []
end
end
end
end
18 changes: 15 additions & 3 deletions lib/anchor/type_script/resource.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
module Anchor::TypeScript
class Resource < Anchor::Resource
def express(context: {}, include_all_fields:, exclude_fields:)
Definition = Struct.new(:name, :object, keyword_init: true)

def express(...)
@object = object(...)
expression = Anchor::TypeScript::Serializer.type_string(@object)
"export type #{anchor_schema_name} = " + expression + ";"
end

def definition(...)
@object = object(...)
Definition.new(name: anchor_schema_name, object: @object)
end

def object(context: {}, include_all_fields:, exclude_fields:)
included_fields = schema_fetchable_fields(context:, include_all_fields:)
included_fields -= exclude_fields if exclude_fields

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

expression = Anchor::TypeScript::Serializer.type_string(Anchor::Types::Object.new(properties))
"export type #{anchor_schema_name} = " + expression + ";"
Anchor::Types::Object.new(properties)
end
end
end
44 changes: 44 additions & 0 deletions lib/anchor/type_script/schema_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,48 @@ def enums
@enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
end
end

class MultifileSchemaGenerator < Anchor::SchemaGenerator
def initialize(**opts)
super(**opts.except(:manually_editable))
@manually_editable = opts[:manually_editable] || false
end

def call
[shared_file] + resource_files
end

private

def shared_file
maybe_type = "export type Maybe<T> = T | null;"

enum_expressions = enums.map(&:express)
content = ([maybe_type] + enum_expressions).join("\n\n") + "\n"
{ name: "shared.ts", content: }
end

def resource_files
resources.map do |r|
definition = r.definition(
context: @context,
include_all_fields: @include_all_fields,
exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym],
)

file_structure = ::Anchor::TypeScript::FileStructure.new(definition)
content = file_structure.to_code(manually_editable: @manually_editable)
name = file_structure.name
{ name:, content: }
end
end

def resources
@resources ||= @register.resources.map { |r| Anchor::TypeScript::Resource.new(r) }
end

def enums
@enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
end
end
end
6 changes: 5 additions & 1 deletion lib/anchor/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ class Unknown; end
Array = Struct.new(:type)
Literal = Struct.new(:value)
Union = Struct.new(:types)
Reference = Struct.new(:name)
Reference = Struct.new(:name) do
def anchor_schema_name
name
end
end
Property = Struct.new(:name, :type, :optional, :description)
class Object
attr_reader :properties
Expand Down
1 change: 1 addition & 0 deletions lib/jsonapi-resources-anchor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
require "anchor/schema"
require "anchor/types/inference/jsonapi"
require "anchor/types/inference/active_record"
require "anchor/type_script/file_structure"
require "anchor/type_script/types"
require "anchor/type_script/schema_generator"
require "anchor/type_script/serializer"
Expand Down
32 changes: 32 additions & 0 deletions spec/anchor/example_multifile_schema_snapshot_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
require "rails_helper"

# rubocop:disable RSpec/EmptyExampleGroup
RSpec.describe "Example" do
def self.multifile_snapshot_test(filename, generate)
it "generates correct #{filename} schema" do
result = generate.call
result.each do |res|
filename = res[:name]
path = Rails.root.join("test/files/multifile", filename)
schema = res[:content]
unless File.file?(path)
File.open(path, "w") { |file| file.write(schema) }
end

SnapshotUpdate.prompt(path, schema) if ENV["THOR_MERGE"] && File.read(path) != schema
expect(File.read(path)).to eql(schema)
end
end
end

multifile_snapshot_test "schema.ts", -> {
Anchor::TypeScript::MultifileSchemaGenerator.call(
register: Schema.register,
context: {},
include_all_fields: true,
exclude_fields: nil,
manually_editable: true,
)
}
end
# rubocop:enable RSpec/EmptyExampleGroup
33 changes: 33 additions & 0 deletions spec/example/lib/tasks/anchor.rake
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,45 @@ namespace :anchor do
puts "✅ #{File.basename(path)}"
end

def write_to_multi(folder, force, generate)
FileUtils.mkdir_p("test/files/#{folder}")
result = generate.call
result.each do |res|
path = Rails.root.join("test/files/#{folder}", res[:name])
if force || !File.exist?(path)
File.open(path, "w") { |f| f.write(res[:content]) }
next
end

existing_content = File.read(path)
new_content =
if existing_content.starts_with?("// START AUTOGEN\n") && existing_content.include?("// END AUTOGEN\n")
after_end = existing_content.split("// END AUTOGEN\n").second
[res[:content].split("\n// END AUTOGEN\n").first, "// END AUTOGEN", after_end].join("\n")
else
res[:content]
end

File.open(path, "w") { |f| f.write(new_content) }
end
puts "✅ #{folder}"
end

write_to "schema.ts", -> { Schema.generate(include_all_fields: true) }
write_to "test_schema.ts", -> { Schema.generate(context: { role: "test" }) }
write_to "all_fields_false_schema.ts", -> { Schema.generate }
write_to "excluded_fields_schema.ts", -> {
Schema.generate(exclude_fields: { User: [:name, :posts] })
}
write_to_multi "multifile", false, -> {
Anchor::TypeScript::MultifileSchemaGenerator.call(
register: Schema.register,
context: {},
include_all_fields: true,
exclude_fields: nil,
manually_editable: true,
)
}
write_to "json_schema.json", -> { Schema.generate(adapter: :json_schema, include_all_fields: true) }
end
end
24 changes: 24 additions & 0 deletions spec/example/test/files/multifile/Comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// START AUTOGEN

import { Post } from "./Post";
import { User } from "./User";

type Model = {
id: number;
type: "comments";
text: string;
createdAt: string;
updatedAt: string;
relationships: {
/** Author of the comment. */
user: User;
deletedBy?: User;
commentable?: User | Post;
};
};

// END AUTOGEN

type Comment = Model;

export { type Comment };
Loading

0 comments on commit 5452db8

Please sign in to comment.