From 1c1fe3cef3d481a1684cd75e6313e94a2699c4ce Mon Sep 17 00:00:00 2001 From: mattkhan <86168986+mattkhan@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:31:36 -0700 Subject: [PATCH] refactor: more declarative API to construct types --- lib/anchor/concerns/typeable.rb | 26 +++ lib/anchor/config.rb | 4 +- lib/anchor/inference/active_record/infer.rb | 8 + .../inference/active_record/infer/base.rb | 12 + .../inference/active_record/infer/columns.rb | 42 ++++ .../inference/active_record/infer/enums.rb | 17 ++ .../inference/active_record/infer/model.rb | 64 ++++++ .../inference/active_record/infer/rbs.rb | 30 +++ lib/anchor/inference/active_record/types.rb | 8 + .../inference/active_record/types/base.rb | 9 + .../active_record/types/column_comments.rb | 29 +++ .../active_record/types/defaulted.rb | 17 ++ .../active_record/types/overridden.rb | 23 ++ .../active_record/types/presence_required.rb | 33 +++ .../active_record/types/serialized.rb | 13 ++ lib/anchor/inference/jsonapi/infer.rb | 8 + .../inference/jsonapi/infer/anchor_def.rb | 86 +++++++ lib/anchor/inference/jsonapi/infer/base.rb | 12 + lib/anchor/inference/jsonapi/infer/rbs.rb | 30 +++ .../jsonapi/infer/relationship_references.rb | 38 ++++ .../inference/jsonapi/infer/resource.rb | 72 ++++++ lib/anchor/inference/jsonapi/infer/shell.rb | 24 ++ lib/anchor/inference/jsonapi/read_type.rb | 47 ++++ lib/anchor/inference/jsonapi/types.rb | 8 + .../active_record_relationships_wrapper.rb | 44 ++++ .../jsonapi/types/anchor_comments.rb | 23 ++ lib/anchor/inference/jsonapi/types/base.rb | 9 + lib/anchor/inference/jsonapi/types/empty.rb | 9 + .../inference/jsonapi/types/overridden.rb | 14 ++ .../inference/jsonapi/types/readable.rb | 22 ++ .../jsonapi/types/relationships_wrapper.rb | 29 +++ lib/anchor/json_schema/resource.rb | 16 +- lib/anchor/json_schema/schema_generator.rb | 4 +- lib/anchor/json_schema/serializer.rb | 1 + lib/anchor/resource.rb | 213 ------------------ lib/anchor/schema.rb | 2 +- lib/anchor/schema_generator.rb | 3 +- lib/anchor/type_script/file_structure.rb | 7 +- .../type_script/multifile_schema_generator.rb | 5 +- lib/anchor/type_script/resource.rb | 32 +-- lib/anchor/type_script/schema_generator.rb | 4 +- lib/anchor/type_script/serializer.rb | 1 + lib/anchor/types.rb | 134 ++++++++++- lib/anchor/types/inference/active_record.rb | 46 +--- lib/anchor/types/inference/jsonapi.rb | 14 -- lib/anchor/types/inference/rbs.rb | 58 +++-- lib/jsonapi-resources-anchor.rb | 37 ++- .../example_multifile_schema_snapshot_spec.rb | 1 - spec/anchor/example_schema_snapshot_spec.rb | 3 - spec/example/config/initializers/anchor.rb | 1 + ...1000738_add_comment_to_exhaustives_enum.rb | 5 + spec/example/lib/tasks/anchor.rake | 5 - spec/example/sig/models/exhaustive.rbs | 2 + .../test/files/excluded_fields_schema.ts | 118 ---------- 54 files changed, 1057 insertions(+), 465 deletions(-) create mode 100644 lib/anchor/concerns/typeable.rb create mode 100644 lib/anchor/inference/active_record/infer.rb create mode 100644 lib/anchor/inference/active_record/infer/base.rb create mode 100644 lib/anchor/inference/active_record/infer/columns.rb create mode 100644 lib/anchor/inference/active_record/infer/enums.rb create mode 100644 lib/anchor/inference/active_record/infer/model.rb create mode 100644 lib/anchor/inference/active_record/infer/rbs.rb create mode 100644 lib/anchor/inference/active_record/types.rb create mode 100644 lib/anchor/inference/active_record/types/base.rb create mode 100644 lib/anchor/inference/active_record/types/column_comments.rb create mode 100644 lib/anchor/inference/active_record/types/defaulted.rb create mode 100644 lib/anchor/inference/active_record/types/overridden.rb create mode 100644 lib/anchor/inference/active_record/types/presence_required.rb create mode 100644 lib/anchor/inference/active_record/types/serialized.rb create mode 100644 lib/anchor/inference/jsonapi/infer.rb create mode 100644 lib/anchor/inference/jsonapi/infer/anchor_def.rb create mode 100644 lib/anchor/inference/jsonapi/infer/base.rb create mode 100644 lib/anchor/inference/jsonapi/infer/rbs.rb create mode 100644 lib/anchor/inference/jsonapi/infer/relationship_references.rb create mode 100644 lib/anchor/inference/jsonapi/infer/resource.rb create mode 100644 lib/anchor/inference/jsonapi/infer/shell.rb create mode 100644 lib/anchor/inference/jsonapi/read_type.rb create mode 100644 lib/anchor/inference/jsonapi/types.rb create mode 100644 lib/anchor/inference/jsonapi/types/active_record_relationships_wrapper.rb create mode 100644 lib/anchor/inference/jsonapi/types/anchor_comments.rb create mode 100644 lib/anchor/inference/jsonapi/types/base.rb create mode 100644 lib/anchor/inference/jsonapi/types/empty.rb create mode 100644 lib/anchor/inference/jsonapi/types/overridden.rb create mode 100644 lib/anchor/inference/jsonapi/types/readable.rb create mode 100644 lib/anchor/inference/jsonapi/types/relationships_wrapper.rb delete mode 100644 lib/anchor/resource.rb delete mode 100644 lib/anchor/types/inference/jsonapi.rb create mode 100644 spec/example/db/migrate/20251011000738_add_comment_to_exhaustives_enum.rb delete mode 100644 spec/example/test/files/excluded_fields_schema.ts diff --git a/lib/anchor/concerns/typeable.rb b/lib/anchor/concerns/typeable.rb new file mode 100644 index 0000000..9f21a6f --- /dev/null +++ b/lib/anchor/concerns/typeable.rb @@ -0,0 +1,26 @@ +module Anchor + module Typeable + extend ActiveSupport::Concern + + included do + def object(...) = Anchor::Types::Object.new(...) + def property(...) = Anchor::Types::Property.new(...) + def maybe(...) = Anchor::Types::Maybe.new(...) + def array(...) = Anchor::Types::Array.new(...) + def union(...) = Anchor::Types::Union.new(...) + def literal(...) = Anchor::Types::Literal.new(...) + def literals(values) = union(values.map { |value| literal(value) }) + def reference(...) = Anchor::Types::Reference.new(...) + def references(names) = union(names.map { |name| reference(name) }) + def record(value_type = Anchor::Types::Unknown) = Anchor::Types::Record.new(value_type) + + def boolean = Anchor::Types::Boolean + def null = Anchor::Types::Null + def unknown = Anchor::Types::Unknown + def string = Anchor::Types::String + def float = Anchor::Types::Float + def integer = Anchor::Types::Integer + def big_decimal = Anchor::Types::BigDecimal + end + end +end diff --git a/lib/anchor/config.rb b/lib/anchor/config.rb index 236cb59..b203e3c 100644 --- a/lib/anchor/config.rb +++ b/lib/anchor/config.rb @@ -11,7 +11,8 @@ class Config :array_bracket_notation, :infer_default_as_non_null, :ar_comment_to_string, - :infer_ar_enums + :infer_ar_enums, + :rbs def initialize @ar_column_to_type = nil @@ -26,6 +27,7 @@ def initialize @infer_default_as_non_null = nil @ar_comment_to_string = nil @infer_ar_enums = nil + @rbs = "off" end end end diff --git a/lib/anchor/inference/active_record/infer.rb b/lib/anchor/inference/active_record/infer.rb new file mode 100644 index 0000000..0e99552 --- /dev/null +++ b/lib/anchor/inference/active_record/infer.rb @@ -0,0 +1,8 @@ +module Anchor + module Inference + module ActiveRecord + module Infer + end + end + end +end diff --git a/lib/anchor/inference/active_record/infer/base.rb b/lib/anchor/inference/active_record/infer/base.rb new file mode 100644 index 0000000..f2d0840 --- /dev/null +++ b/lib/anchor/inference/active_record/infer/base.rb @@ -0,0 +1,12 @@ +module Anchor::Inference::ActiveRecord::Infer + class Base + include Anchor::Typeable + + def initialize(klass) + @klass = klass + end + + def self.infer(...) = new(...).infer + def infer = raise NotImplementedError + end +end diff --git a/lib/anchor/inference/active_record/infer/columns.rb b/lib/anchor/inference/active_record/infer/columns.rb new file mode 100644 index 0000000..50524cc --- /dev/null +++ b/lib/anchor/inference/active_record/infer/columns.rb @@ -0,0 +1,42 @@ +module Anchor::Inference::ActiveRecord::Infer + class Columns < Base + def infer = object(properties) + + private + + def properties + @klass.columns_hash.map do |name, column| + next property(name, Anchor.config.ar_column_to_type.call(column)) if Anchor.config.ar_column_to_type + metadata_type = from_sql_type_metadata(column.sql_type_metadata) + column_type = from_column_type(column.type) + + type = [metadata_type, column_type, unknown].compact.first + type = column.null ? maybe(type) : type + property(name, type) + end + end + + def from_sql_type_metadata(sql_type_metadata) + case sql_type_metadata.sql_type + when "character varying[]", "text[]" then array(string) + end + end + + def from_column_type(type) + case type + when :boolean then boolean + when :date then string + when :datetime then string + when :decimal then big_decimal + when :float then float + when :integer then integer + when :json then record + when :jsonb then record + when :string then string + when :text then string + when :time then string + when :uuid then string + end + end + end +end diff --git a/lib/anchor/inference/active_record/infer/enums.rb b/lib/anchor/inference/active_record/infer/enums.rb new file mode 100644 index 0000000..4b9b3b6 --- /dev/null +++ b/lib/anchor/inference/active_record/infer/enums.rb @@ -0,0 +1,17 @@ +module Anchor::Inference::ActiveRecord::Infer + class Enums < Base + def infer = object(properties) + + private + + def properties + @klass.columns_hash.slice(*defined_enums.keys).merge(defined_enums) do |name, column, enum| + property(name, column.null ? maybe(enum) : enum) + end.values + end + + def defined_enums + @defined_enums ||= @klass.defined_enums.transform_values { |enum| literals(enum.values) } + end + end +end diff --git a/lib/anchor/inference/active_record/infer/model.rb b/lib/anchor/inference/active_record/infer/model.rb new file mode 100644 index 0000000..6ff60f9 --- /dev/null +++ b/lib/anchor/inference/active_record/infer/model.rb @@ -0,0 +1,64 @@ +# TODO: Is attribute_types.keys ⊅ columns_hash.keys possible? +# def superset?(klass) = klass.attribute_types.keys.to_set.superset?(klass.columns_hash.keys.to_set) +# !ActiveRecord::Base.descendants.reject(&:abstract_class?).all? { |k| superset?(k) } +module Anchor::Inference::ActiveRecord::Infer + class Model < Base + module T + include Anchor::Inference::ActiveRecord::Types + end + + def infer + res = [serialized, overridden, presence_required, defaulted, column_comments].compact.reduce(columns) do |acc, elem| + elem.wrap(acc) + end + + res.overwrite( + rbs.pick( + res.pick_by_value(unknown.singleton_class).keys, + ), + keep_description: :left, + ) + end + + private + + def columns + Columns.infer(@klass).overwrite(enums, keep_description: :left) + end + + def enums + return object([]) unless Anchor.config.infer_ar_enums + @enum_types ||= Enums.infer(@klass) + end + + def column_comments + return unless Anchor.config.use_active_record_comment + T::ColumnComments.new(@klass) + end + + def rbs + return @rbs if defined?(@rbs) + return object([]) unless Anchor::Types::Inference::RBS.enabled? + Anchor::Types::Inference::RBS.validate! + @rbs = RBS.infer(@klass) + end + + def serialized + T::Serialized.new(@klass) + end + + def overridden + T::Overridden.new(@klass) + end + + def presence_required + return unless Anchor.config.use_active_record_validations + T::PresenceRequired.new(@klass) + end + + def defaulted + return unless Anchor.config.infer_default_as_non_null + T::Defaulted.new(@klass) + end + end +end diff --git a/lib/anchor/inference/active_record/infer/rbs.rb b/lib/anchor/inference/active_record/infer/rbs.rb new file mode 100644 index 0000000..fef63b9 --- /dev/null +++ b/lib/anchor/inference/active_record/infer/rbs.rb @@ -0,0 +1,30 @@ +module Anchor::Inference::ActiveRecord::Infer + class RBS < Base + def infer = object(properties) + + private + + def properties + included_attributes.map do |method_name| + type = rbs.from_rbs_type(instance.methods[method_name].method_types.first.type.return_type) + Anchor::Types::Property.new(method_name.to_s, type) + end + end + + def included_attributes + instance.methods.filter_map do |method_name, method_def| + next if method_def&.method_types&.length != 1 + method_name + end + end + + def instance + return @instance if defined?(@instance) + @instance ||= rbs.build_instance(@klass) + end + + def rbs + @rbs ||= Anchor::Types::Inference::RBS + end + end +end diff --git a/lib/anchor/inference/active_record/types.rb b/lib/anchor/inference/active_record/types.rb new file mode 100644 index 0000000..a87e984 --- /dev/null +++ b/lib/anchor/inference/active_record/types.rb @@ -0,0 +1,8 @@ +module Anchor + module Inference + module ActiveRecord + module Types + end + end + end +end diff --git a/lib/anchor/inference/active_record/types/base.rb b/lib/anchor/inference/active_record/types/base.rb new file mode 100644 index 0000000..6b9f67b --- /dev/null +++ b/lib/anchor/inference/active_record/types/base.rb @@ -0,0 +1,9 @@ +module Anchor::Inference::ActiveRecord::Types + class Base + def initialize(klass) + @klass = klass + end + + def wrap(t) = raise NotImplementedError + end +end diff --git a/lib/anchor/inference/active_record/types/column_comments.rb b/lib/anchor/inference/active_record/types/column_comments.rb new file mode 100644 index 0000000..66152c8 --- /dev/null +++ b/lib/anchor/inference/active_record/types/column_comments.rb @@ -0,0 +1,29 @@ +module Anchor::Inference::ActiveRecord::Types + class ColumnComments < Base + include Anchor::Typeable + + def wrap(t) = object(add_comments(t)) + + private + + def add_comments(t) + t.properties.map do |prop| + prop.dup(description: comments[prop.name] || prop.description) + end + end + + def comments + @comments ||= @klass.columns_hash.filter_map do |name, column| + next unless column.comment + description = serialize_comment(column.comment) + [name, description] + end.to_h + end + + def serialize_comment(comment) + return comment unless Anchor.config.ar_comment_to_string + + Anchor.config.ar_comment_to_string.call(comment) + end + end +end diff --git a/lib/anchor/inference/active_record/types/defaulted.rb b/lib/anchor/inference/active_record/types/defaulted.rb new file mode 100644 index 0000000..7e0fcdb --- /dev/null +++ b/lib/anchor/inference/active_record/types/defaulted.rb @@ -0,0 +1,17 @@ +module Anchor::Inference::ActiveRecord::Types + class Defaulted < Base + def wrap(t) = t.pick(names).nonnullable + t.omit(names) + + private + + def names + @klass.columns_hash.filter_map do |name, column| + name if has_default?(column) + end + end + + def has_default?(column) + column.default.present? || column.default_function.present? && column.instance_variable_get(:@generated).blank? + end + end +end diff --git a/lib/anchor/inference/active_record/types/overridden.rb b/lib/anchor/inference/active_record/types/overridden.rb new file mode 100644 index 0000000..68f0df3 --- /dev/null +++ b/lib/anchor/inference/active_record/types/overridden.rb @@ -0,0 +1,23 @@ +module Anchor::Inference::ActiveRecord::Types + class Overridden < Base + def wrap(t) = t.untype(names) + + private + + def names + @klass.attribute_types.keys.filter do |name| + next unless @klass.method_defined?(name.to_sym) + expected_generators.none? do |generator| + @klass.instance_method(name.to_sym).owner.is_a?(generator) + end + end + end + + def expected_generators + @expected_generators ||= [ + ActiveRecord::AttributeMethods::PrimaryKey, + ActiveRecord::AttributeMethods::GeneratedAttributeMethods, + ] + end + end +end diff --git a/lib/anchor/inference/active_record/types/presence_required.rb b/lib/anchor/inference/active_record/types/presence_required.rb new file mode 100644 index 0000000..62df3cc --- /dev/null +++ b/lib/anchor/inference/active_record/types/presence_required.rb @@ -0,0 +1,33 @@ +module Anchor::Inference::ActiveRecord::Types + class PresenceRequired < Base + def wrap(t) = t.pick(names).nonnullable + t.omit(names) + + private + + def names + @klass.attribute_types.keys.filter do |name| + presence_required_for?(name) + end + end + + def presence_required_for?(attribute) + @klass.validators_on(attribute).any? do |validator| + case validator + when ActiveRecord::Validations::NumericalityValidator then numericality_presence_required?(validator) + when ActiveRecord::Validations::PresenceValidator then presence_required?(validator) + else false + end + end + end + + def numericality_presence_required?(validator) + opts = validator.options.with_indifferent_access + !(opts[:allow_nil] || opts[:if] || opts[:unless] || opts[:on]) + end + + def presence_required?(validator) + opts = validator.options.with_indifferent_access + !(opts[:if] || opts[:unless] || opts[:on]) + end + end +end diff --git a/lib/anchor/inference/active_record/types/serialized.rb b/lib/anchor/inference/active_record/types/serialized.rb new file mode 100644 index 0000000..b61127a --- /dev/null +++ b/lib/anchor/inference/active_record/types/serialized.rb @@ -0,0 +1,13 @@ +module Anchor::Inference::ActiveRecord::Types + class Serialized < Base + def wrap(t) = t.untype(names) + + private + + def names + @klass.attribute_types.filter_map do |name, type| + name if type.respond_to?(:coder) + end + end + end +end diff --git a/lib/anchor/inference/jsonapi/infer.rb b/lib/anchor/inference/jsonapi/infer.rb new file mode 100644 index 0000000..ecc7508 --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer.rb @@ -0,0 +1,8 @@ +module Anchor + module Inference + module JSONAPI + module Infer + end + end + end +end diff --git a/lib/anchor/inference/jsonapi/infer/anchor_def.rb b/lib/anchor/inference/jsonapi/infer/anchor_def.rb new file mode 100644 index 0000000..92e7a51 --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/anchor_def.rb @@ -0,0 +1,86 @@ +module Anchor::Inference::JSONAPI::Infer + class AnchorDef < Base + delegate :resource_key_type, :_type, :_attributes, :_relationships, to: :@klass + + def initialize(klass) + super(klass) + @anchor_attributes = klass.try(:anchor_attributes) || {} + @anchor_relationships = klass.try(:anchor_relationships) || {} + @anchor_attributes_descriptions = klass.try(:anchor_attributes_descriptions) || {} + @anchor_relationships_descriptions = klass.try(:anchor_relationships_descriptions) || {} + @anchor_links_schema = klass.try(:anchor_links_schema) || nil + @anchor_meta_schema = klass.try(:anchor_meta_schema) || nil + end + + def infer + object([ + id, + type, + *attributes, + *relationships, + meta, + links, + ].compact) + end + + private + + def attributes + _attributes.except(:id).filter_map do |attr, _| + next unless @anchor_attributes.key?(attr) + + property( + attr.to_s, + @anchor_attributes[attr], + false, + @anchor_attributes_descriptions[attr], + ) + end + end + + def relationships + _relationships.filter_map do |name, rel| + next if @anchor_relationships.exclude?(name) + + anchor_relationship = @anchor_relationships[name] + polymorphic = anchor_relationship.resources.present? + + base_type = if polymorphic + references(anchor_relationship.resources.map(&:anchor_schema_name)) + else + reference(anchor_relationship.resource.anchor_schema_name) + end + + if rel.is_a?(::JSONAPI::Relationship::ToMany) + null_elements = anchor_relationship.null_elements.present? + base_type |= null if null_elements + base_type = array(base_type) + end + + type = anchor_relationship.null.present? ? maybe(base_type) : base_type + property(name.to_s, type, false, @anchor_relationships_descriptions[name]) + end + end + + def id + # TODO: resource_key_type can also return a proc + res_key_type = case resource_key_type + when :integer then integer + else string + end + property("id", res_key_type) + end + + def type = property("type", literal(_type)) + + def links + return unless @anchor_links_schema + property("links", @anchor_links_schema) + end + + def meta + return unless @anchor_meta_schema + property("meta", @anchor_meta_schema) + end + end +end diff --git a/lib/anchor/inference/jsonapi/infer/base.rb b/lib/anchor/inference/jsonapi/infer/base.rb new file mode 100644 index 0000000..451828d --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/base.rb @@ -0,0 +1,12 @@ +module Anchor::Inference::JSONAPI::Infer + class Base + include Anchor::Typeable + + def initialize(klass) + @klass = klass + end + + def self.infer(...) = new(...).infer + def infer = raise NotImplementedError + end +end diff --git a/lib/anchor/inference/jsonapi/infer/rbs.rb b/lib/anchor/inference/jsonapi/infer/rbs.rb new file mode 100644 index 0000000..63b7916 --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/rbs.rb @@ -0,0 +1,30 @@ +module Anchor::Inference::JSONAPI::Infer + class RBS < Base + def infer = object(properties) + + private + + def properties + included_attributes.map do |method_name| + type = rbs.from_rbs_type(instance.methods[method_name].method_types.first.type.return_type) + Anchor::Types::Property.new(method_name.to_s, type) + end + end + + def included_attributes + instance.methods.filter_map do |method_name, method_def| + next if method_def&.method_types&.length != 1 + method_name + end + end + + def instance + return @instance if defined?(@instance) + @instance ||= rbs.build_instance(@klass) + end + + def rbs + @rbs ||= Anchor::Types::Inference::RBS + end + end +end diff --git a/lib/anchor/inference/jsonapi/infer/relationship_references.rb b/lib/anchor/inference/jsonapi/infer/relationship_references.rb new file mode 100644 index 0000000..6a8c3e6 --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/relationship_references.rb @@ -0,0 +1,38 @@ +module Anchor::Inference::JSONAPI::Infer + class RelationshipReferences < Base + def infer = object(properties) + + private + + def properties + relationships.map { |name, rel| property(name.to_s, type_for(rel)) } + end + + def type_for(rel) + begin + rel.resource_klass + rescue NameError => e + Rails.logger.warn(e.message) + return unknown + end + + return reference(rel.resource_klass.anchor_schema_name) unless rel.polymorphic? + + version = nil + version ||= rel.respond_to?(:polymorphic_types) && :new # 0.11.0.beta2 + version ||= rel.class.respond_to?(:polymorphic_types) && :old # TODO: < 0.11.0.beta2 + + polymorphic_types = case version + when :new then rel.polymorphic_types + when :old then rel.class.polymorphic_types + end + + return reference(rel.resource_klass.anchor_schema_name) unless polymorphic_types + + resource_klasses = polymorphic_types.map { |t| @klass.resource_klass_for(t) } + union(resource_klasses.map { |rk| reference(rk.anchor_schema_name) }) + end + + def relationships = @klass._relationships + end +end diff --git a/lib/anchor/inference/jsonapi/infer/resource.rb b/lib/anchor/inference/jsonapi/infer/resource.rb new file mode 100644 index 0000000..03a01ae --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/resource.rb @@ -0,0 +1,72 @@ +module Anchor::Inference::JSONAPI::Infer + module T + include Anchor::Inference::JSONAPI::Types + end + + class Resource < Base + def infer + shell = Shell.infer(@klass) + annotated = AnchorDef.infer(@klass) + + model = delegated_attrs(attributes) + attributes + relationships + inferred = (model + shell).pick(shell.keys) + + fallback = rbs.pick(inferred.pick_by_value(unknown.singleton_class).keys) + result = annotated + inferred.overwrite(fallback, keep_description: :left) + + anchor_comments.wrap(result) + end + + private + + def attributes + @attributes ||= overridden.wrap(active_record_model) + end + + def relationships + base_relationships = RelationshipReferences.infer(@klass) + + jsonapi_relationships = relationships_wrapper.wrap(base_relationships) + active_record_relationships = active_record_relationships_wrapper.wrap(base_relationships) + + active_record_relationships + jsonapi_relationships + end + + def rbs + return @rbs if defined?(@rbs) + return object([]) unless Anchor::Types::Inference::RBS.enabled? + Anchor::Types::Inference::RBS.validate! + @rbs = RBS.infer(@klass) + end + + def active_record_model + return object([]) unless @klass._model_class < ActiveRecord::Base + Anchor::Inference::ActiveRecord::Infer::Model.infer(@klass._model_class) + end + + def active_record_relationships_wrapper + return T::Empty.new unless @klass._model_class < ActiveRecord::Base + T::ActiveRecordRelationshipsWrapper.new(@klass) + end + + def relationships_wrapper + T::RelationshipsWrapper.new(@klass) + end + + def overridden + T::Overridden.new(@klass) + end + + def anchor_comments + T::AnchorComments.new(@klass) + end + + def delegated_attrs(attrs) + props = @klass._attributes.filter_map do |name, opts| + next unless (delegate = opts[:delegate]&.to_s) + attrs[delegate]&.dup(name: name.to_s) || property(name.to_s, unknown) + end + object(props) + end + end +end diff --git a/lib/anchor/inference/jsonapi/infer/shell.rb b/lib/anchor/inference/jsonapi/infer/shell.rb new file mode 100644 index 0000000..73bac6e --- /dev/null +++ b/lib/anchor/inference/jsonapi/infer/shell.rb @@ -0,0 +1,24 @@ +module Anchor::Inference::JSONAPI::Infer + class Shell < Base + def infer + object({ + id: unknown, + type: unknown, + **attributes.index_with { unknown }, + **relationships.index_with { unknown }, + meta: unknown, + links: unknown, + }) + end + + private + + def object(hash) + props = hash.map { |key, type| property(key.to_s, type) } + Anchor::Types::Object.new(props) + end + + def attributes = @klass._attributes.except(:id).keys + def relationships = @klass._relationships.keys + end +end diff --git a/lib/anchor/inference/jsonapi/read_type.rb b/lib/anchor/inference/jsonapi/read_type.rb new file mode 100644 index 0000000..e570916 --- /dev/null +++ b/lib/anchor/inference/jsonapi/read_type.rb @@ -0,0 +1,47 @@ +module Anchor + module Inference + module JSONAPI + class ReadType + include Anchor::Typeable + + attr_reader :t + + delegate :convert_case, to: Anchor::Types + + def initialize(klass, context: {}, include_all_fields: false) + @klass = klass + @t = Anchor::Inference::JSONAPI::Infer::Resource.infer(klass) + @context = context + @include_all_fields = include_all_fields + end + + def self.infer(...) = new(...).infer + + def infer + id + + type + + readable(attributes).convert_case + + object([property( + "relationships", + readable(relationships.nullable_to_optional).convert_case, + )]) + + meta + + links + end + + def readable(t) + Anchor::Inference::JSONAPI::Types::Readable.new( + @klass, context: @context, include_all_fields: @include_all_fields + ).wrap(t) + end + + def id = t.pick(["id"]) + def type = t.pick(["type"]) + def attributes = t.pick(@klass._attributes.except(:id).keys.map(&:to_s)) + def relationships = t.pick(@klass._relationships.keys.map(&:to_s)) + def meta = t["meta"].type.is_a?(unknown.singleton_class) ? t.pick([]) : t.pick(["meta"]) + def links = t["links"].type.is_a?(unknown.singleton_class) ? t.pick([]) : t.pick(["links"]) + end + end + end +end diff --git a/lib/anchor/inference/jsonapi/types.rb b/lib/anchor/inference/jsonapi/types.rb new file mode 100644 index 0000000..16835f2 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types.rb @@ -0,0 +1,8 @@ +module Anchor + module Inference + module JSONAPI + module Types + end + end + end +end diff --git a/lib/anchor/inference/jsonapi/types/active_record_relationships_wrapper.rb b/lib/anchor/inference/jsonapi/types/active_record_relationships_wrapper.rb new file mode 100644 index 0000000..4d42ba3 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/active_record_relationships_wrapper.rb @@ -0,0 +1,44 @@ +module Anchor::Inference::JSONAPI::Types + class ActiveRecordRelationshipsWrapper < Base + include Anchor::Typeable + + def initialize(klass) + super + @model_klass = klass._model_class + end + + def wrap(t) = t.apply_higher(wrapper_type).pick(wrapper_type.keys) + + private + + def wrapper_type + return @wrapper_type if defined?(@wrapper_type) + + props = @klass._relationships.filter_map do |name, rel| + relation_name = rel.options[:relation_name]&.to_s || name.to_s + + next unless (ref = @model_klass.reflections[relation_name]) + + # TODO: comments from DB? + property(name.to_s, wrapper(ref), false, nil) + end + + @wrapper_type = object(props) + end + + def wrapper(reflection) + case reflection + when ::ActiveRecord::Reflection::BelongsToReflection then belongs_to_type(reflection) + when ::ActiveRecord::Reflection::HasOneReflection then Anchor::Types::Maybe + when ::ActiveRecord::Reflection::HasManyReflection then Anchor::Types::Array + when ::ActiveRecord::Reflection::HasAndBelongsToManyReflection then Anchor::Types::Array + when ::ActiveRecord::Reflection::ThroughReflection then wrapper(reflection.send(:delegate_reflection)) + else raise "#{reflection.class.name} not supported" # TODO: make this unknown wrapper somehow ? + end + end + + def belongs_to_type(reflection) + reflection.options[:optional] ? Anchor::Types::Maybe : Anchor::Types::Identity + end + end +end diff --git a/lib/anchor/inference/jsonapi/types/anchor_comments.rb b/lib/anchor/inference/jsonapi/types/anchor_comments.rb new file mode 100644 index 0000000..f8a514d --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/anchor_comments.rb @@ -0,0 +1,23 @@ +module Anchor::Inference::JSONAPI::Types + class AnchorComments < Base + include Anchor::Typeable + + def wrap(t) = object(properties(t)) + + private + + def properties(t) + t.properties.map do |prop| + prop.dup(description: comments[prop.name] || prop.description) + end + end + + def comments + return @comments if defined?(@comments) + attr_descs = @klass.try(:anchor_attributes_descriptions) || {} + rel_descs = @klass.try(:anchor_relationships_descriptions) || {} + + @comments = attr_descs.merge(rel_descs).reject { |_, d| d.nil? }.transform_keys(&:to_s) + end + end +end diff --git a/lib/anchor/inference/jsonapi/types/base.rb b/lib/anchor/inference/jsonapi/types/base.rb new file mode 100644 index 0000000..9cc3f9d --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/base.rb @@ -0,0 +1,9 @@ +module Anchor::Inference::JSONAPI::Types + class Base + def initialize(klass) + @klass = klass + end + + def wrap(t) = raise NotImplementedError + end +end diff --git a/lib/anchor/inference/jsonapi/types/empty.rb b/lib/anchor/inference/jsonapi/types/empty.rb new file mode 100644 index 0000000..5fbce76 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/empty.rb @@ -0,0 +1,9 @@ +module Anchor::Inference::JSONAPI::Types + class Empty < Base + def initialize(klass = nil) + super(klass) + end + + def wrap(t) = Anchor::Types::Object.new([]) + end +end diff --git a/lib/anchor/inference/jsonapi/types/overridden.rb b/lib/anchor/inference/jsonapi/types/overridden.rb new file mode 100644 index 0000000..d66adf1 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/overridden.rb @@ -0,0 +1,14 @@ +module Anchor::Inference::JSONAPI::Types + class Overridden < Base + def wrap(t) = t.untype(names) + + private + + def names + count = @klass.anchor_method_added_count || Hash.new(0) + @klass._attributes.keys.filter_map do |name| + name.to_s if count[name.to_sym] > 1 + end + end + end +end diff --git a/lib/anchor/inference/jsonapi/types/readable.rb b/lib/anchor/inference/jsonapi/types/readable.rb new file mode 100644 index 0000000..4ab7f45 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/readable.rb @@ -0,0 +1,22 @@ +module Anchor::Inference::JSONAPI::Types + class Readable < Base + def initialize(klass, context: {}, include_all_fields: false) + super(klass) + @context = context + @include_all_fields = include_all_fields + end + + def wrap(t) = t.pick(names.map(&:to_s)) + + private + + def names + return @klass.fields unless statically_determinable_fetchable_fields? && !@include_all_fields + @klass.anchor_fetchable_fields(@context) + end + + def statically_determinable_fetchable_fields? + @klass.singleton_class.method_defined?(:anchor_fetchable_fields) + end + end +end diff --git a/lib/anchor/inference/jsonapi/types/relationships_wrapper.rb b/lib/anchor/inference/jsonapi/types/relationships_wrapper.rb new file mode 100644 index 0000000..b298643 --- /dev/null +++ b/lib/anchor/inference/jsonapi/types/relationships_wrapper.rb @@ -0,0 +1,29 @@ +module Anchor::Inference::JSONAPI::Types + class RelationshipsWrapper < Base + include Anchor::Typeable + + def wrap(t) = t.apply_higher(wrapper_type).pick(wrapper_type.keys) + + private + + def wrapper_type + return @wrapper_type if defined?(@wrapper_type) + props = @klass._relationships.map do |name, rel| + property(name.to_s, wrapper(rel), false, nil) + end + @wrapper_type = object(props) + end + + def wrapper(relationship) + case relationship + when ::JSONAPI::Relationship::ToOne then Anchor::Types::Identity + when ::JSONAPI::Relationship::ToMany then Anchor::Types::Array + else raise "#{relationship.class.name} not supported" + end + end + + def belongs_to_type(reflection) + reflection.options[:optional] ? Anchor::Types::Maybe : Anchor::Types::Identity + end + end +end diff --git a/lib/anchor/json_schema/resource.rb b/lib/anchor/json_schema/resource.rb index e42fb84..296ae0c 100644 --- a/lib/anchor/json_schema/resource.rb +++ b/lib/anchor/json_schema/resource.rb @@ -1,14 +1,14 @@ module Anchor::JSONSchema - class Resource < Anchor::Resource - def express(context: {}, include_all_fields:, exclude_fields:) - included_fields = schema_fetchable_fields(context:, include_all_fields:) - included_fields -= exclude_fields if exclude_fields + class Resource + delegate :anchor_schema_name, to: :@klass - properties = [id_property, type_property] + - Array.wrap(anchor_attributes_properties(included_fields:)) + - Array.wrap(anchor_relationships_property(included_fields:)) + def initialize(klass) + @klass = klass + end - Anchor::Types::Object.new(properties) + def express(context: {}, include_all_fields:) + t = Anchor::Inference::JSONAPI::ReadType.infer(@klass, context:, include_all_fields:).omit(["meta", "links"]) + t["relationships"].type.properties.count > 0 ? t : t.omit(["relationships"]) end end end diff --git a/lib/anchor/json_schema/schema_generator.rb b/lib/anchor/json_schema/schema_generator.rb index e91adda..455c498 100644 --- a/lib/anchor/json_schema/schema_generator.rb +++ b/lib/anchor/json_schema/schema_generator.rb @@ -2,11 +2,10 @@ module Anchor::JSONSchema class SchemaGenerator < Anchor::SchemaGenerator delegate :type_property, to: Anchor::JSONSchema::Serializer - def initialize(register:, context: {}, include_all_fields: false, exclude_fields: nil) # rubocop:disable Lint/MissingSuper + def initialize(register:, context: {}, include_all_fields: false) # rubocop:disable Lint/MissingSuper @register = register @context = context @include_all_fields = include_all_fields - @exclude_fields = exclude_fields end def call @@ -41,7 +40,6 @@ def definitions resource.anchor_schema_name => type_property(resource.express( context: @context, include_all_fields: @include_all_fields, - exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym], )), } end.reduce(&:merge) diff --git a/lib/anchor/json_schema/serializer.rb b/lib/anchor/json_schema/serializer.rb index 1ca8dff..705a288 100644 --- a/lib/anchor/json_schema/serializer.rb +++ b/lib/anchor/json_schema/serializer.rb @@ -20,6 +20,7 @@ def type_property(type) when Anchor::Types::Reference then { "$ref" => "#/$defs/#{type.name}" } when Anchor::Types::Object, Anchor::Types::Object.singleton_class then serialize_object(type) when Anchor::Types::Enum.singleton_class then { enum: type.values.map(&:second) } + when Anchor::Types::Identity then type_property(type.type) when Anchor::Types::Unknown.singleton_class then {} when Anchor::Types::Intersection then {} else raise RuntimeError diff --git a/lib/anchor/resource.rb b/lib/anchor/resource.rb deleted file mode 100644 index 7b11076..0000000 --- a/lib/anchor/resource.rb +++ /dev/null @@ -1,213 +0,0 @@ -module Anchor - class Resource - delegate_missing_to :@resource_klass - attr_reader :resource_klass - - # resource_klass#anchor_attributes, #anchor_relationships, #anchor_attributes_descriptions, - # #anchor_relationships_descriptions are optional methods from Anchor::Annotatable. - # @param [JSONAPI::Resource] Must include Anchor::TypeInferable - def initialize(resource_klass) - @resource_klass = resource_klass - @anchor_attributes = resource_klass.try(:anchor_attributes) || {} - @anchor_relationships = resource_klass.try(:anchor_relationships) || {} - @anchor_attributes_descriptions = resource_klass.try(:anchor_attributes_descriptions) || {} - @anchor_relationships_descriptions = resource_klass.try(:anchor_relationships_descriptions) || {} - @anchor_method_added_count = resource_klass.anchor_method_added_count || Hash.new(0) - @anchor_links_schema = resource_klass.try(:anchor_links_schema) || nil - @anchor_meta_schema = resource_klass.try(:anchor_meta_schema) || nil - end - - def express(...) - raise NotImplementedError - end - - private - - delegate :convert_case, to: Anchor::Types - - def schema_fetchable_fields(context:, include_all_fields:) - return fields unless statically_determinable_fetchable_fields? && !include_all_fields - @resource_klass.anchor_fetchable_fields(context) - end - - def statically_determinable_fetchable_fields? - @resource_klass.singleton_class.method_defined?(:anchor_fetchable_fields) - end - - # @return [Anchor::Types::Property] - def id_property - # TODO: resource_key_type can also return a proc - res_key_type = case resource_key_type - when :integer then Anchor::Types::Integer - else Anchor::Types::String - end - - Anchor::Types::Property.new(:id, res_key_type) - end - - # @return [Anchor::Types::Property] - def type_property - Anchor::Types::Property.new(:type, Anchor::Types::Literal.new(_type)) - end - - # @param included_fields [Array] - # @return [Array] - def anchor_attributes_properties(included_fields:) - _attributes.except(:id).filter_map do |attr, options| - next if included_fields.exclude?(attr.to_sym) - description = @anchor_attributes_descriptions[attr] - next Anchor::Types::Property.new( - convert_case(attr), - @anchor_attributes[attr], - false, - description, - ) if @anchor_attributes.key?(attr) - - type = begin - model_method = options[:delegate] || attr - resource_method = attr - - model_method_defined = _model_class.try( - :method_defined?, - model_method.to_sym, - ) && !_model_class.instance_method(model_method.to_sym) - .owner.is_a?(ActiveRecord::AttributeMethods::GeneratedAttributeMethods) - resource_method_defined = @anchor_method_added_count[resource_method.to_sym] > 1 - serializer_defined = (_model_class.try(:attribute_types) || {})[model_method.to_s].respond_to?(:coder) - method_defined = model_method_defined || resource_method_defined || serializer_defined - - enum = Anchor.config.infer_ar_enums && !method_defined && _model_class.try(:defined_enums).try(:[], model_method.to_s) - column = !method_defined && _model_class.try(:columns_hash).try(:[], model_method.to_s) - - rbs_defined = defined?(::RBS) && ::RBS::VERSION.first == "3" - - if resource_method_defined && rbs_defined - Anchor::Types::Inference::RBS.from(name.to_sym, resource_method) - elsif model_method_defined && rbs_defined - Anchor::Types::Inference::RBS.from(_model_class.name.to_sym, model_method.to_sym) - elsif column - type = Anchor::Types::Inference::ActiveRecord::SQL.from(column) - - if enum - enum_type = Anchor::Types::Union.new(enum.map { |_key, val| Anchor::Types::Literal.new(val) }) - type = type.is_a?(Anchor::Types::Maybe) ? Anchor::Types::Maybe.new(enum_type) : enum_type - end - - unless description - description = column.comment if Anchor.config.use_active_record_comment - if description && !Anchor.config.ar_comment_to_string.nil? - description = Anchor.config.ar_comment_to_string.call(description) - end - end - check_presence = type.is_a?(Anchor::Types::Maybe) && Anchor.config.use_active_record_validations - if check_presence && _model_class.validators_on(model_method).any? do |v| - if v.is_a?(ActiveRecord::Validations::NumericalityValidator) - opts = v.options.with_indifferent_access - !(opts[:allow_nil] || opts[:if] || opts[:unless] || opts[:on]) - elsif v.is_a?(ActiveRecord::Validations::PresenceValidator) - opts = v.options.with_indifferent_access - !(opts[:if] || opts[:unless] || opts[:on]) - end - end - type.type - elsif type.is_a?(Anchor::Types::Maybe) && Anchor.config.infer_default_as_non_null - column.default.present? || column.default_function.present? && column.instance_variable_get(:@generated).blank? ? type.type : type - else - type - end - elsif rbs_defined - # TODO: Methods may not be defined on the class at generation time? - [ - Anchor::Types::Inference::RBS.from(name.to_sym, resource_method), - Anchor::Types::Inference::RBS.from(_model_class.name.to_sym, model_method.to_sym), - ].find { |t| t != Anchor::Types::Unknown } || Anchor::Types::Unknown - else - Anchor::Types::Unknown - end - end - - Anchor::Types::Property.new(convert_case(attr), type, false, description) - end - end - - # @param included_fields [Array] - # @return [Anchor::Types::Property, NilClass] - def anchor_relationships_property(included_fields:) - anchor_relationships_properties(included_fields:).then do |properties| - break if properties.blank? - Anchor::Types::Property.new(:relationships, Anchor::Types::Object.new(properties)) - end - end - - def anchor_links_property - if @anchor_links_schema - Anchor::Types::Property.new("links", @anchor_links_schema, false) - end - end - - def anchor_meta_property - if @anchor_meta_schema - Anchor::Types::Property.new("meta", @anchor_meta_schema, false) - end - end - - # @param included_fields [Array] - # @return [Array] - def anchor_relationships_properties(included_fields:) - _relationships.filter_map do |name, rel| - next if included_fields.exclude?(name.to_sym) - description = @anchor_relationships_descriptions[name] - relationship_type = relationship_type_for(rel, rel.resource_klass, name) if @anchor_relationships.exclude?(name) - - relationship_type ||= begin - anchor_relationship = @anchor_relationships[name] - - type = if (resources = anchor_relationship.resources) - references = resources.map do |resource_klass| - Anchor::Types::Reference.new(resource_klass.anchor_schema_name) - end - null_type = Array.wrap(anchor_relationship.null_elements.presence && Anchor::Types::Null) - Anchor::Types::Union.new(references + null_type) - else - Anchor::Types::Reference.new(anchor_relationship.resource.anchor_schema_name) - end - - type = Anchor::Types::Inference::JSONAPI.wrapper_from_relationship(rel).call(type) - anchor_relationship.null.present? ? Anchor::Types::Maybe.new(type) : type - end - - use_optional = Anchor.config.infer_nullable_relationships_as_optional - if use_optional && relationship_type.is_a?(Anchor::Types::Maybe) - Anchor::Types::Property.new(convert_case(name), relationship_type.type, true, description) - else - Anchor::Types::Property.new(convert_case(name), relationship_type, false, description) - end - end - end - - # @param rel [Relationship] - # @param resource_klass [Anchor::Resource] - # @param name [String, Symbol] - # @return [Anchor::Types::Reference, Anchor::Types::Array, Anchor::Types::Maybe, Anchor::Types::Union] - def relationship_type_for(rel, resource_klass, name) - rel_type = if rel.polymorphic? && rel.respond_to?(:polymorphic_types) # 0.11.0.beta2 - resource_klasses = rel.polymorphic_types.map { |t| resource_klass_for(t) } - Anchor::Types::Union.new(resource_klasses.map { |rk| Anchor::Types::Reference.new(rk.anchor_schema_name) }) - elsif rel.polymorphic? && rel.class.respond_to?(:polymorphic_types) # TODO: < 0.11.0.beta2 - resource_klasses = rel.class.polymorphic_types.map { |t| resource_klass_for(t) } - Anchor::Types::Union.new(resource_klasses.map { |rk| Anchor::Types::Reference.new(rk.anchor_schema_name) }) - end - - rel_type ||= Anchor::Types::Reference.new(resource_klass.anchor_schema_name) - model_relationship_name = (rel.options[:relation_name] || name).to_s - reflection = _model_class.try(:reflections).try(:[], model_relationship_name) - wrapper = if reflection - Anchor::Types::Inference::ActiveRecord.wrapper_from_reflection(reflection) - else - Anchor::Types::Inference::JSONAPI.wrapper_from_relationship(rel) - end - - wrapper.call(rel_type) - end - end -end diff --git a/lib/anchor/schema.rb b/lib/anchor/schema.rb index c88f5a3..5d3a4f1 100644 --- a/lib/anchor/schema.rb +++ b/lib/anchor/schema.rb @@ -3,7 +3,7 @@ class Schema class DuplicateTypeError < StandardError; end class << self - Register = Struct.new(:resources, :enums, keyword_init: true) + Register = Data.define(:resources, :enums) def register Register.new(resources: @resources || [], enums: @enums || []) diff --git a/lib/anchor/schema_generator.rb b/lib/anchor/schema_generator.rb index acf78d9..51c43b5 100644 --- a/lib/anchor/schema_generator.rb +++ b/lib/anchor/schema_generator.rb @@ -1,10 +1,9 @@ module Anchor class SchemaGenerator - def initialize(register:, context:, include_all_fields:, exclude_fields:) + def initialize(register:, context:, include_all_fields:) @register = register @context = context @include_all_fields = include_all_fields - @exclude_fields = exclude_fields end def self.call(...) diff --git a/lib/anchor/type_script/file_structure.rb b/lib/anchor/type_script/file_structure.rb index 86ed268..b4ecff5 100644 --- a/lib/anchor/type_script/file_structure.rb +++ b/lib/anchor/type_script/file_structure.rb @@ -2,7 +2,7 @@ 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) + Import = Data.define(:file_name, :type) class FileUtils def self.imports_to_code(imports) imports.group_by(&:file_name).map do |file_name, file_imports| @@ -71,16 +71,17 @@ def relationship_imports end def relationships_to_import - relationships = @object.properties.find { |p| p.name == :relationships } + relationships = @object.properties.find { |p| p.name.to_sym == :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::Array, Anchor::Types::Maybe, Anchor::Types::Identity 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] + when Anchor::Types::Unknown.singleton_class then [] end.uniq.sort_by(&:anchor_schema_name) end diff --git a/lib/anchor/type_script/multifile_schema_generator.rb b/lib/anchor/type_script/multifile_schema_generator.rb index a0742c0..eae46ae 100644 --- a/lib/anchor/type_script/multifile_schema_generator.rb +++ b/lib/anchor/type_script/multifile_schema_generator.rb @@ -1,6 +1,6 @@ module Anchor::TypeScript class MultifileSchemaGenerator < Anchor::SchemaGenerator - Result = Struct.new(:name, :text, :type, keyword_init: true) + Result = Data.define(:name, :text, :type) module FileType RESOURCE = "resource" @@ -11,14 +11,12 @@ def initialize( # rubocop:disable Lint/MissingSuper register:, context: {}, include_all_fields: false, - exclude_fields: nil, manually_editable: true, resource_file_extension: ".ts" ) @register = register @context = context @include_all_fields = include_all_fields - @exclude_fields = exclude_fields @manually_editable = manually_editable @resource_file_extension = "." + resource_file_extension.sub(/^\./, "") end @@ -47,7 +45,6 @@ def resource_files 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, extension: @resource_file_extension) diff --git a/lib/anchor/type_script/resource.rb b/lib/anchor/type_script/resource.rb index 5dbbb18..e59c091 100644 --- a/lib/anchor/type_script/resource.rb +++ b/lib/anchor/type_script/resource.rb @@ -1,6 +1,12 @@ module Anchor::TypeScript - class Resource < Anchor::Resource - Definition = Struct.new(:name, :object, keyword_init: true) + class Resource + Definition = Data.define(:name, :object) + + delegate :anchor_schema_name, to: :@klass + + def initialize(klass) + @klass = klass + end def express(...) @object = object(...) @@ -13,21 +19,17 @@ def definition(...) 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 + def object(context: {}, include_all_fields:) + t = Anchor::Inference::JSONAPI::ReadType.infer(@klass, context:, include_all_fields:) + return t if t["relationships"].type.properties.count > 0 + return t.omit(["relationships"]) unless Anchor.config.empty_relationship_type - relationships_property = anchor_relationships_property(included_fields:) - if relationships_property.nil? && Anchor.config.empty_relationship_type - relationships_property = Anchor::Types::Property.new(:relationships, Anchor.config.empty_relationship_type.call) - end + t.overwrite(Anchor::Types::Object.new([empty_relationships_property])) + end - properties = [id_property, type_property] + - Array.wrap(anchor_attributes_properties(included_fields:)) + - Array.wrap(relationships_property) + - [anchor_meta_property].compact + [anchor_links_property].compact + private - Anchor::Types::Object.new(properties) - end + def empty_relationships_property = property("relationships", Anchor.config.empty_relationship_type.call) + def property(...) = Anchor::Types::Property.new(...) end end diff --git a/lib/anchor/type_script/schema_generator.rb b/lib/anchor/type_script/schema_generator.rb index 14a6829..94b4ad5 100644 --- a/lib/anchor/type_script/schema_generator.rb +++ b/lib/anchor/type_script/schema_generator.rb @@ -1,10 +1,9 @@ module Anchor::TypeScript class SchemaGenerator < Anchor::SchemaGenerator - def initialize(register:, context: {}, include_all_fields: false, exclude_fields: nil) # rubocop:disable Lint/MissingSuper + def initialize(register:, context: {}, include_all_fields: false) # rubocop:disable Lint/MissingSuper @register = register @context = context @include_all_fields = include_all_fields - @exclude_fields = exclude_fields end def call @@ -15,7 +14,6 @@ def call r.express( context: @context, include_all_fields: @include_all_fields, - exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym], ) end diff --git a/lib/anchor/type_script/serializer.rb b/lib/anchor/type_script/serializer.rb index 605f24d..d1be747 100644 --- a/lib/anchor/type_script/serializer.rb +++ b/lib/anchor/type_script/serializer.rb @@ -18,6 +18,7 @@ def type_string(type, depth = 1) when Anchor::Types::Reference then type.name when Anchor::Types::Object, Anchor::Types::Object.singleton_class then serialize_object(type, depth) when Anchor::Types::Enum.singleton_class then type.anchor_schema_name + when Anchor::Types::Identity then type_string(type.type) when Anchor::Types::Unknown.singleton_class then "unknown" else raise RuntimeError end diff --git a/lib/anchor/types.rb b/lib/anchor/types.rb index 8aac958..1422181 100644 --- a/lib/anchor/types.rb +++ b/lib/anchor/types.rb @@ -7,23 +7,131 @@ class BigDecimal; end class Boolean; end class Null; end class Unknown; end - Record = Struct.new(:value_type) - Maybe = Struct.new(:type) - Array = Struct.new(:type) - Literal = Struct.new(:value) - Union = Struct.new(:types) - Intersection = Struct.new(:types) - Reference = Struct.new(:name) do - def anchor_schema_name - name + Identity = Data.define(:type) + Record = Data.define(:value_type) + Maybe = Data.define(:type) + Array = Data.define(:type) + Literal = Data.define(:value) + Union = Data.define(:types) do + def |(other) + self.class.new(types + [other]) end end - Property = Struct.new(:name, :type, :optional, :description) + Intersection = Data.define(:types) + Reference = Data.define(:name) do + def anchor_schema_name = name + + def |(other) + Anchor::Types::Union.new([self, other]) + end + end + + Property = Data.define(:name, :type, :optional, :description) do + def initialize(name:, type:, optional: false, description: nil) + super + end + + def dup(name: nil, type: nil, optional: nil, description: nil) + self.class.new( + name: name || self.name, + type: type || self.type, + optional: optional.nil? ? self.optional : optional, + description: description || self.description, + ) + end + end + class Object - attr_reader :properties + attr_reader :properties, :properties_hash + + delegate :[], :keys, :key?, to: :properties_hash def initialize(properties) @properties = properties || [] + @properties_hash = properties.index_by(&:name) || [] + end + + def pick(keys) + picked = properties_hash.slice(*keys).values + self.class.new(picked) + end + + def omit(keys) + omitted = properties_hash.except(*keys).values + self.class.new(omitted) + end + + def pick_by_value(t) + props = properties.filter { |prop| prop.type.is_a?(t) } + self.class.new(props) + end + + def untype(names = nil) + names ||= keys + pick(names).overwrite_values(Anchor::Types::Unknown) + omit(names) + end + + def overwrite_values(type) + props = properties.map { |prop| prop.dup(type:) } + self.class.new(props) + end + + def apply_higher(other, keep_description: :right) + props = properties.filter_map do |prop| + if (other_prop = other[prop.name]) + desc = keep_description == :right ? other_prop.description : property.description + Property.new(prop.name, other_prop.type.new(prop.type), prop.optional, desc) + else + prop + end + end + self.class.new(props) + end + + def overwrite(other, keep_description: :right) + props = properties.map do |prop| + if (other_prop = other[prop.name]) + description = keep_description == :left ? prop.description : other_prop.description + other_prop.dup(description:) + else + prop.dup + end + end + self.class.new(props) + end + + # left-based union + def +(other) + self.class.new(properties + other.omit(keys).properties) + end + + def transform_keys + props = properties.map { |prop| prop.dup(name: yield(prop.name)) } + self.class.new(props) + end + + def camelize + transform_keys { |name| Anchor::Types.camelize_without_inflection(name) } + end + + def convert_case + transform_keys { |name| Anchor::Types.convert_case(name) } + end + + def nonnullable + props = properties.map do |prop| + next prop unless prop.type.is_a?(Anchor::Types::Maybe) + prop.dup(type: prop.type.type) + end + self.class.new(props) + end + + def nullable_to_optional + props = properties.map do |prop| + next prop unless prop.type.is_a?(Anchor::Types::Maybe) + prop.dup(type: prop.type.type, optional: true) + end + self.class.new(props) end class << self @@ -35,6 +143,10 @@ def property(name, type, optional: nil, description: nil) @properties ||= [] @properties.push(Property.new(name, type, optional, description)) end + + def camelize + new(properties.map { |prop| prop.dup(name: Anchor::Types.camelize_without_inflection(prop.name)) }) + end end end diff --git a/lib/anchor/types/inference/active_record.rb b/lib/anchor/types/inference/active_record.rb index 4c96a0d..8f3f2a9 100644 --- a/lib/anchor/types/inference/active_record.rb +++ b/lib/anchor/types/inference/active_record.rb @@ -1,49 +1,26 @@ module Anchor::Types::Inference module ActiveRecord - class << self - # @return [Proc{Type => Type, Anchor::Types::Maybe, Anchor::Types::Array}] - def wrapper_from_reflection(reflection) - case reflection - when ::ActiveRecord::Reflection::BelongsToReflection then ->(type) { belongs_to_type(reflection, type) } - when ::ActiveRecord::Reflection::HasOneReflection then ->(type) { Anchor::Types::Maybe.new(type) } - when ::ActiveRecord::Reflection::HasManyReflection then ->(type) { Anchor::Types::Array.new(type) } - when ::ActiveRecord::Reflection::HasAndBelongsToManyReflection then ->(type) { Anchor::Types::Array.new(type) } - when ::ActiveRecord::Reflection::ThroughReflection then wrapper_from_reflection(reflection.send(:delegate_reflection)) - else raise "#{reflection.class.name} not supported" - end - end - - private - - # @param reflection [::ActiveRecord::Reflection::BelongsToReflection] - # @param type [Anchor::Types] - # @return [Anchor::Types::Maybe, Type] - def belongs_to_type(reflection, type) - reflection.options[:optional] ? Anchor::Types::Maybe.new(type) : type - end - end - module SQL class << self - def from(column, check_config: true) - return Anchor.config.ar_column_to_type.call(column) if check_config && Anchor.config.ar_column_to_type - type = from_sql_type(column.type) + def default_ar_column_to_type(column) + metadata_type = from_sql_type_metadata(column.sql_type_metadata) + column_type = from_column_type(column.type) - if ["character varying[]", "text[]"].include?(column.sql_type_metadata.sql_type) - type = Anchor::Types::Array.new(Anchor::Types::String) - end + type = [metadata_type, column_type, Anchor::Types::Unknown].compact.first column.null ? Anchor::Types::Maybe.new(type) : type end - def default_ar_column_to_type(column) - from(column, check_config: false) - end - private + def from_sql_type_metadata(sql_type_metadata) + case sql_type_metadata.sql_type + when "character varying[]", "text[]" then Anchor::Types::Array.new(Anchor::Types::String) + end + end + # inspiration from https://github.com/ElMassimo/types_from_serializers/blob/146ba40bc1a0da37473cd3b705a8ca982c2d173f/types_from_serializers/lib/types_from_serializers/generator.rb#L382 - def from_sql_type(type) + def from_column_type(type) case type when :boolean then Anchor::Types::Boolean when :date then Anchor::Types::String @@ -57,7 +34,6 @@ def from_sql_type(type) when :text then Anchor::Types::String when :time then Anchor::Types::String when :uuid then Anchor::Types::String - else Anchor::Types::Unknown end end end diff --git a/lib/anchor/types/inference/jsonapi.rb b/lib/anchor/types/inference/jsonapi.rb deleted file mode 100644 index 807fd46..0000000 --- a/lib/anchor/types/inference/jsonapi.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Anchor::Types::Inference - module JSONAPI - class << self - # @return [Proc{Type => Type, Anchor::Types::Array}] - def wrapper_from_relationship(relationship) - case relationship - when ::JSONAPI::Relationship::ToOne then ->(type) { type } - when ::JSONAPI::Relationship::ToMany then ->(type) { Anchor::Types::Array.new(type) } - else raise "#{relationship.class.name} not supported" - end - end - end - end -end diff --git a/lib/anchor/types/inference/rbs.rb b/lib/anchor/types/inference/rbs.rb index 75f356e..cc9de19 100644 --- a/lib/anchor/types/inference/rbs.rb +++ b/lib/anchor/types/inference/rbs.rb @@ -1,33 +1,29 @@ module Anchor::Types::Inference module RBS class << self - # @param class_name [Symbol] e.g. :Exhaustive - # @param method_name [Symbol] e.g. :model_overridden - def from(class_name, method_name) - @loader ||= ::RBS::EnvironmentLoader.new.tap do |l| - l.add(path: Rails.root.join("sig")) - end - - @env ||= ::RBS::Environment.from_loader(@loader).resolve_type_names - - # TODO: Do we need both the absolute and non-absolute namespaces? - klass = @env.class_decls.keys.find { |kl| [class_name.to_s, "::#{class_name}"].include?(kl.to_s) } - return Types::Unknown unless klass - - @builder ||= ::RBS::DefinitionBuilder.new(env: @env) - instance = @builder.build_instance(klass) - - instance_method = instance.methods[method_name] - return Types::Unknown unless instance_method + def enabled? + Anchor.config.rbs.to_s == "fallback" + end - method_types = instance.methods[method_name].method_types - return Types::Unknown unless method_types.length == 1 + def validate! + return if defined?(::RBS) && ::RBS::VERSION.first == "3" + raise "RBS version conflict: rbs ~> 3 required." + end - return_type = method_types.first.type.return_type - from_rbs_type(return_type) + # @param klass [Class] + # @return [Class, nil] + def get_definition(klass) + env.class_decls.keys.find do |definition| + # TODO: Do we need both absolute and relative here? + [klass.name, "::#{klass.name}"].include?(definition.to_s) + end end - private + def build_instance(klass) + if (definition = get_definition(klass)) + builder.build_instance(definition) + end + end def from_rbs_type(type) case type @@ -46,6 +42,22 @@ def from_rbs_type(type) end end + private + + def builder + @builder ||= ::RBS::DefinitionBuilder.new(env:) + end + + def env + @env ||= ::RBS::Environment.from_loader(loader).resolve_type_names + end + + def loader + @loader ||= ::RBS::EnvironmentLoader.new.tap do |l| + l.add(path: Rails.root.join("sig")) + end + end + def from_record(type) properties = type.fields.map do |name, type| Types::Property.new(name, from_rbs_type(type)) diff --git a/lib/jsonapi-resources-anchor.rb b/lib/jsonapi-resources-anchor.rb index b938098..dd3ba23 100644 --- a/lib/jsonapi-resources-anchor.rb +++ b/lib/jsonapi-resources-anchor.rb @@ -1,16 +1,15 @@ require "anchor/types" require "anchor/anchor" require "anchor/config" -require "anchor/resource" require "anchor/schema_generator" require "anchor/concerns/annotatable" +require "anchor/concerns/typeable" require "anchor/concerns/custom_linkable" require "anchor/concerns/custom_meta" require "anchor/concerns/static_context" require "anchor/concerns/type_inferable" require "anchor/concerns/schema_serializable" require "anchor/schema" -require "anchor/types/inference/jsonapi" require "anchor/types/inference/active_record" require "anchor/types/inference/rbs" require "anchor/type_script/file_structure" @@ -23,3 +22,37 @@ require "anchor/json_schema/serializer" require "anchor/json_schema/schema_generator" require "anchor/json_schema/resource" + +require "anchor/inference/active_record/types" +require "anchor/inference/active_record/types/base" +require "anchor/inference/active_record/types/column_comments" +require "anchor/inference/active_record/types/presence_required" +require "anchor/inference/active_record/types/serialized" +require "anchor/inference/active_record/types/defaulted" +require "anchor/inference/active_record/types/overridden" + +require "anchor/inference/active_record/infer" +require "anchor/inference/active_record/infer/base" +require "anchor/inference/active_record/infer/columns" +require "anchor/inference/active_record/infer/model" +require "anchor/inference/active_record/infer/enums" +require "anchor/inference/active_record/infer/rbs" + +require "anchor/inference/jsonapi/types" +require "anchor/inference/jsonapi/types/base" +require "anchor/inference/jsonapi/types/active_record_relationships_wrapper" +require "anchor/inference/jsonapi/types/relationships_wrapper" +require "anchor/inference/jsonapi/types/empty" +require "anchor/inference/jsonapi/types/overridden" +require "anchor/inference/jsonapi/types/anchor_comments" +require "anchor/inference/jsonapi/types/readable" + +require "anchor/inference/jsonapi/infer" +require "anchor/inference/jsonapi/infer/base" +require "anchor/inference/jsonapi/infer/anchor_def" +require "anchor/inference/jsonapi/infer/shell" +require "anchor/inference/jsonapi/infer/rbs" +require "anchor/inference/jsonapi/infer/relationship_references" +require "anchor/inference/jsonapi/infer/resource" + +require "anchor/inference/jsonapi/read_type" diff --git a/spec/anchor/example_multifile_schema_snapshot_spec.rb b/spec/anchor/example_multifile_schema_snapshot_spec.rb index 8c2d5b9..9a9558b 100644 --- a/spec/anchor/example_multifile_schema_snapshot_spec.rb +++ b/spec/anchor/example_multifile_schema_snapshot_spec.rb @@ -26,7 +26,6 @@ def self.multifile_snapshot_test(filename, generator) register: Schema.register, context: {}, include_all_fields: true, - exclude_fields: nil, manually_editable: true, ) end diff --git a/spec/anchor/example_schema_snapshot_spec.rb b/spec/anchor/example_schema_snapshot_spec.rb index bf6c4a7..9930460 100644 --- a/spec/anchor/example_schema_snapshot_spec.rb +++ b/spec/anchor/example_schema_snapshot_spec.rb @@ -25,9 +25,6 @@ def self.snapshot_test(filename, generate) Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, context: { role: "test" }) } snapshot_test "all_fields_false_schema.ts", -> { Anchor::TypeScript::SchemaGenerator.call(register: Schema.register) } - snapshot_test "excluded_fields_schema.ts", -> { - Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, exclude_fields: { User: [:name, :posts] }) - } snapshot_test "json_schema.json", -> { Anchor::JSONSchema::SchemaGenerator.call(register: Schema.register, include_all_fields: true) } diff --git a/spec/example/config/initializers/anchor.rb b/spec/example/config/initializers/anchor.rb index 158878d..7e3eaee 100644 --- a/spec/example/config/initializers/anchor.rb +++ b/spec/example/config/initializers/anchor.rb @@ -6,6 +6,7 @@ module Anchor c.infer_default_as_non_null = true c.infer_nullable_relationships_as_optional = true c.infer_ar_enums = true + c.rbs = "fallback" c.ar_column_to_type = lambda { |column| return Types::Literal.new("never") if column.name == "loljk" diff --git a/spec/example/db/migrate/20251011000738_add_comment_to_exhaustives_enum.rb b/spec/example/db/migrate/20251011000738_add_comment_to_exhaustives_enum.rb new file mode 100644 index 0000000..3a34f14 --- /dev/null +++ b/spec/example/db/migrate/20251011000738_add_comment_to_exhaustives_enum.rb @@ -0,0 +1,5 @@ +class AddCommentToExhaustivesEnum < ActiveRecord::Migration[8.0] + def change + change_column_comment :exhaustives, :enum, from: nil, to: "This is an enum comment." + end +end diff --git a/spec/example/lib/tasks/anchor.rake b/spec/example/lib/tasks/anchor.rake index 5915fe9..2bb67da 100644 --- a/spec/example/lib/tasks/anchor.rake +++ b/spec/example/lib/tasks/anchor.rake @@ -22,16 +22,12 @@ namespace :anchor do Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, context: { role: "test" }) } write_to "all_fields_false_schema.ts", -> { Anchor::TypeScript::SchemaGenerator.call(register: Schema.register) } - write_to "excluded_fields_schema.ts", -> { - Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, exclude_fields: { User: [:name, :posts] }) - } write_to_multi "multifile", false, Anchor::TypeScript::MultifileSchemaGenerator.new( register: Schema.register, context: {}, include_all_fields: true, - exclude_fields: nil, manually_editable: true, ) @@ -46,7 +42,6 @@ namespace :anchor do register: Schema.register, context: {}, include_all_fields: true, - exclude_fields: nil, manually_editable: true, ) modified_files = Anchor::TypeScript::MultifileSaveService.call(generator:, folder_path:, force: true) diff --git a/spec/example/sig/models/exhaustive.rbs b/spec/example/sig/models/exhaustive.rbs index 75264ed..2ab9115 100644 --- a/spec/example/sig/models/exhaustive.rbs +++ b/spec/example/sig/models/exhaustive.rbs @@ -1,3 +1,5 @@ class Exhaustive < ApplicationRecord + # A multiline + # comment. def model_overridden: () -> 'model_overridden' end diff --git a/spec/example/test/files/excluded_fields_schema.ts b/spec/example/test/files/excluded_fields_schema.ts deleted file mode 100644 index 0901dc0..0000000 --- a/spec/example/test/files/excluded_fields_schema.ts +++ /dev/null @@ -1,118 +0,0 @@ -type Maybe = T | null; - -export enum UserRole { - Admin = "admin", - ContentCreator = "content_creator", - External = "external", - Guest = "guest", - System = "system", -} - -export type Comment = { - id: number; - type: "comments"; - createdAt: string; - relationships: {}; -}; - -export type User = { - id: number; - type: "users"; - role: UserRole; - relationships: { - comments: Array; - }; -}; - -export type Post = { - id: number; - type: "posts"; - description: string; - relationships: { - user: User; - comments: Array; - participants: Array; - }; -}; - -export type Exhaustive = { - 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: { - str: string; - union: Maybe | false | boolean | unknown | number | number | number | Array | ("a") & ("b" | "c") | 2 | "union woo"; - array: Array>; - intersection: ({ - a: 1; - }) & ({ - b: Maybe; - s: "string lit"; - }); - next: { - i: number; - f?: number; - }; - } | "union"; - assertedUnionArray: Array; - /** 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; - /** This is an enum comment. */ - enum: "sample" | "enum" | "value"; - virtualUpcasedString: Maybe; - loljk: "never"; - delegatedMaybeString: string; - modelOverridden: "model_overridden"; - resourceOverridden: "resource_overridden"; - /** This is a comment. */ - withComment: Maybe; - /** This is a parsed JSON comment. */ - withParsedComment: Maybe; - defaultedBoolean: boolean; - defaultedAt: string; - relationships: {}; - meta: { - some_count: number; - extra_stuff: string; - }; - links: { - self: string; - some_url: string; - }; -};