From 5452db8913ca4d2dbc42c3cc7186781dabc96db2 Mon Sep 17 00:00:00 2001 From: mattkhan <86168986+mattkhan@users.noreply.github.com> Date: Sun, 3 Nov 2024 15:52:08 -0800 Subject: [PATCH] feat: multifile generation --- lib/anchor/schema.rb | 6 +- lib/anchor/type_script/file_structure.rb | 122 ++++++++++++++++++ lib/anchor/type_script/resource.rb | 18 ++- lib/anchor/type_script/schema_generator.rb | 44 +++++++ lib/anchor/types.rb | 6 +- lib/jsonapi-resources-anchor.rb | 1 + .../example_multifile_schema_snapshot_spec.rb | 32 +++++ spec/example/lib/tasks/anchor.rake | 33 +++++ spec/example/test/files/multifile/Comment.ts | 24 ++++ .../test/files/multifile/Exhaustive.ts | 71 ++++++++++ spec/example/test/files/multifile/Post.ts | 21 +++ spec/example/test/files/multifile/User.ts | 22 ++++ spec/example/test/files/multifile/shared.ts | 9 ++ 13 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 lib/anchor/type_script/file_structure.rb create mode 100644 spec/anchor/example_multifile_schema_snapshot_spec.rb create mode 100644 spec/example/test/files/multifile/Comment.ts create mode 100644 spec/example/test/files/multifile/Exhaustive.ts create mode 100644 spec/example/test/files/multifile/Post.ts create mode 100644 spec/example/test/files/multifile/User.ts create mode 100644 spec/example/test/files/multifile/shared.ts diff --git a/lib/anchor/schema.rb b/lib/anchor/schema.rb index 720de53..de0c041 100644 --- a/lib/anchor/schema.rb +++ b/lib/anchor/schema.rb @@ -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) @@ -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:, diff --git a/lib/anchor/type_script/file_structure.rb b/lib/anchor/type_script/file_structure.rb new file mode 100644 index 0000000..0e0f774 --- /dev/null +++ b/lib/anchor/type_script/file_structure.rb @@ -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] + def imports + shared_imports + relationship_imports + end + + private + + # @return [Array] + def shared_imports + (utils_to_import + enums_to_import).map { |type| Import.new(file_name: "shared.ts", type:) } + end + + # @return [Array] + 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 diff --git a/lib/anchor/type_script/resource.rb b/lib/anchor/type_script/resource.rb index b9ca0d4..5dbbb18 100644 --- a/lib/anchor/type_script/resource.rb +++ b/lib/anchor/type_script/resource.rb @@ -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 @@ -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 diff --git a/lib/anchor/type_script/schema_generator.rb b/lib/anchor/type_script/schema_generator.rb index 68031d7..34f49d8 100644 --- a/lib/anchor/type_script/schema_generator.rb +++ b/lib/anchor/type_script/schema_generator.rb @@ -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 | 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 diff --git a/lib/anchor/types.rb b/lib/anchor/types.rb index 51801a9..1bba5cc 100644 --- a/lib/anchor/types.rb +++ b/lib/anchor/types.rb @@ -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 diff --git a/lib/jsonapi-resources-anchor.rb b/lib/jsonapi-resources-anchor.rb index a1c3d6f..6dfe313 100644 --- a/lib/jsonapi-resources-anchor.rb +++ b/lib/jsonapi-resources-anchor.rb @@ -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" diff --git a/spec/anchor/example_multifile_schema_snapshot_spec.rb b/spec/anchor/example_multifile_schema_snapshot_spec.rb new file mode 100644 index 0000000..c13af34 --- /dev/null +++ b/spec/anchor/example_multifile_schema_snapshot_spec.rb @@ -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 diff --git a/spec/example/lib/tasks/anchor.rake b/spec/example/lib/tasks/anchor.rake index ab80166..d8ae443 100644 --- a/spec/example/lib/tasks/anchor.rake +++ b/spec/example/lib/tasks/anchor.rake @@ -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 diff --git a/spec/example/test/files/multifile/Comment.ts b/spec/example/test/files/multifile/Comment.ts new file mode 100644 index 0000000..b8eb960 --- /dev/null +++ b/spec/example/test/files/multifile/Comment.ts @@ -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 }; diff --git a/spec/example/test/files/multifile/Exhaustive.ts b/spec/example/test/files/multifile/Exhaustive.ts new file mode 100644 index 0000000..c68b5ee --- /dev/null +++ b/spec/example/test/files/multifile/Exhaustive.ts @@ -0,0 +1,71 @@ +// START AUTOGEN + +import { Maybe } from "./shared"; + +type Model = { + id: number; + type: "exhaustives"; + /** My asserted string. */ + assertedString: string; + assertedNumber: number; + assertedBoolean: boolean; + assertedNull: null; + assertedUnknown: unknown; + assertedObject: { + a: "a"; + "b-dash": 1; + c: Maybe; + d_optional?: Maybe; + }; + assertedMaybeObject: Maybe<{ + a: "a"; + "b-dash": 1; + c: Maybe; + d_optional?: Maybe; + }>; + assertedArrayRecord: Array>; + assertedUnion: string | number; + /** This is a provided description. */ + withDescription: string; + inferredUnknown: unknown; + uuid: string; + string: string; + maybeString: string; + text: string; + integer: number; + float: number; + decimal: string; + datetime: string; + timestamp: string; + time: string; + date: string; + boolean: boolean; + arrayString: Array; + maybeArrayString: Maybe>; + json: Record; + jsonb: Record; + daterange: unknown; + enum: unknown; + virtualUpcasedString: Maybe; + loljk: "never"; + delegatedMaybeString: string; + modelOverridden: unknown; + resourceOverridden: unknown; + /** This is a comment. */ + withComment: Maybe; + relationships: {}; + meta: { + some_count: number; + extra_stuff: string; + }; + links: { + self: string; + some_url: string; + }; +}; + +// END AUTOGEN + +type Exhaustive = Model; + +export { type Exhaustive }; diff --git a/spec/example/test/files/multifile/Post.ts b/spec/example/test/files/multifile/Post.ts new file mode 100644 index 0000000..9d227ec --- /dev/null +++ b/spec/example/test/files/multifile/Post.ts @@ -0,0 +1,21 @@ +// START AUTOGEN + +import { Comment } from "./Comment"; +import { User } from "./User"; + +type Model = { + id: number; + type: "posts"; + description: string; + relationships: { + user: User; + comments: Array; + participants: Array; + }; +}; + +// END AUTOGEN + +type Post = Model; + +export { type Post }; diff --git a/spec/example/test/files/multifile/User.ts b/spec/example/test/files/multifile/User.ts new file mode 100644 index 0000000..db88097 --- /dev/null +++ b/spec/example/test/files/multifile/User.ts @@ -0,0 +1,22 @@ +// START AUTOGEN + +import { UserRole } from "./shared"; +import { Comment } from "./Comment"; +import { Post } from "./Post"; + +type Model = { + id: number; + type: "users"; + name: string; + role: UserRole; + relationships: { + comments: Array; + posts: Array; + }; +}; + +// END AUTOGEN + +type User = Model; + +export { type User }; diff --git a/spec/example/test/files/multifile/shared.ts b/spec/example/test/files/multifile/shared.ts new file mode 100644 index 0000000..6cf8410 --- /dev/null +++ b/spec/example/test/files/multifile/shared.ts @@ -0,0 +1,9 @@ +export type Maybe = T | null; + +export enum UserRole { + Admin = "admin", + ContentCreator = "content_creator", + External = "external", + Guest = "guest", + System = "system", +}