From c48b26141a32b82611d828f7519d8b85151938c8 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 12:57:15 -0700 Subject: [PATCH 1/8] Compact YARD docs; add RBS support; use autoload --- CHANGELOG.md | 8 ++ README.md | 24 ++++ VERSION | 2 +- lib/support_table_data.rb | 36 ++++- lib/support_table_data/documentation.rb | 8 +- .../documentation/rbs_doc.rb | 52 +++++++ .../documentation/rbs_file.rb | 104 ++++++++++++++ .../documentation/type_inference.rb | 59 ++++++++ .../documentation/yard_doc.rb | 95 ++++++++++-- .../documentation_connection_error.rb | 25 ++++ lib/support_table_data/tasks.rb | 7 + lib/{ => support_table_data}/tasks/utils.rb | 10 ++ lib/tasks/support_table_data.rake | 59 ++++++-- spec/spec_helper.rb | 1 - .../documentation/rbs_doc_spec.rb | 51 +++++++ .../documentation/rbs_file_spec.rb | 136 ++++++++++++++++++ .../documentation/type_inference_spec.rb | 95 ++++++++++++ .../documentation/yard_doc_spec.rb | 72 ++++++++++ spec/support_table_data_spec.rb | 2 +- spec/tasks_spec.rb | 96 ++++++++++--- support_table_data.gemspec | 1 + test_app/app/models/status.rb | 10 +- 22 files changed, 899 insertions(+), 54 deletions(-) create mode 100644 lib/support_table_data/documentation/rbs_doc.rb create mode 100644 lib/support_table_data/documentation/rbs_file.rb create mode 100644 lib/support_table_data/documentation/type_inference.rb create mode 100644 lib/support_table_data/documentation_connection_error.rb create mode 100644 lib/support_table_data/tasks.rb rename lib/{ => support_table_data}/tasks/utils.rb (84%) create mode 100644 spec/support_table_data/documentation/rbs_doc_spec.rb create mode 100644 spec/support_table_data/documentation/rbs_file_spec.rb create mode 100644 spec/support_table_data/documentation/type_inference_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7c7f..1835fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.6.0 + +### Added + +- Generated YARD documentation now switches to a compact `@!macro`-based form when a model has more than `SupportTableData.compact_yard_threshold` named instances (default 20). The macro definitions appear once at the top of the documentation block with the prose templates so the file stays readable for humans, and IDEs/`yard doc` resolve them into the same per-method documentation as the verbose form. This dramatically reduces the size of the generated comment block on models with many instances. +- The attribute helper YARD comment now uses the underlying ActiveRecord column type (`String`, `Integer`, `Boolean`, etc.) for `@return` instead of the generic `Object`. This requires a database connection at generation time; set `SupportTableData.infer_documentation_types = false` to opt out and fall back to generic types. When type inference is enabled but no connection is available, the tasks now raise a clear `SupportTableData::DocumentationConnectionError` rather than failing with a low-level ActiveRecord error. +- Added opt-in RBS signature generation. The new tasks `support_table_data:rbs`, `support_table_data:rbs:verify`, and `support_table_data:rbs:remove` write/check/delete `sig/.rbs` files so the named instance helpers are visible to Ruby LSP, Steep, RubyMine, and other RBS-aware tools without polluting the model source files. The output directory can be overridden with `SupportTableData.rbs_signatures_path`. + ## 1.5.2 ### Added diff --git a/README.md b/README.md index 70ceef3..da402d3 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,30 @@ The default behavior is to add the documentation comments at the end of the mode A good practice is to add a check to your CI pipeline to ensure the documentation is always up to date. You can run the rake task `support_table_data:yard_docs:verify` to do this. It will exit with an error if any models do not have up to date documentation. +For models with many named instances, the verbose comment form can become very long. When a model has more than `SupportTableData.compact_yard_threshold` named instances (default 20), the generator switches to a compact form that uses YARD `@!macro` directives. The macros are defined at the top of the documentation block with the prose templates, and each named instance gets a short `@!method`/`@!macro` pair beneath. IDEs and `yard doc` resolve the macros into the same per-method documentation you would get from the verbose form. + +To force the verbose form for all models, set the threshold to a high value (e.g. in an initializer): + +```ruby +SupportTableData.compact_yard_threshold = 1000 +``` + +To force the compact form everywhere, set it to `0`. + +The generator reads the underlying ActiveRecord column types so attribute helpers get specific return types (`String`, `Integer`, `Boolean`, etc.) in their documentation. This requires a working database connection at generation time. If the documentation tasks need to run in an environment without a database (for example a lint-only CI job), disable the lookup and the docs will fall back to generic `Object` / `untyped` return types: + +```ruby +SupportTableData.infer_documentation_types = false +``` + +When the setting is left at its default (`true`) and no connection is available, the tasks raise `SupportTableData::DocumentationConnectionError` with instructions for both resolution paths so behavior is consistent across local and CI runs. + +#### Generating RBS Signatures + +In addition to the inline YARD comments, you can generate [RBS](https://github.com/ruby/rbs) type signatures for the named instance helpers by running `bundle exec rake support_table_data:rbs`. This writes one `sig/.rbs` file per model — for example a Rails model at `app/models/feature.rb` produces `sig/app/models/feature.rbs`. The signatures are picked up by Ruby LSP, Steep, RubyMine, and any other RBS-aware tool, and they keep the model source files free of generated content. + +Just like the YARD task, you can target a single file with `bundle exec rake "support_table_data:rbs[app/models/feature.rb]"`, verify they are up to date in CI with `support_table_data:rbs:verify`, and remove the generated files with `support_table_data:rbs:remove`. RBS generation is opt-in — running the YARD task alone does not produce RBS output, and vice versa. + ### Caching You can use the companion [support_table_cache gem](https://github.com/bdurand/support_table_cache) to add caching support to your models. That way your application won't need to constantly query the database for records that will never change. diff --git a/VERSION b/VERSION index 4cda8f1..dc1e644 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/lib/support_table_data.rb b/lib/support_table_data.rb index 332abe4..333e2ca 100644 --- a/lib/support_table_data.rb +++ b/lib/support_table_data.rb @@ -8,7 +8,15 @@ module SupportTableData extend ActiveSupport::Concern + autoload :ValidationError, File.expand_path("support_table_data/validation_error", __dir__) + autoload :DocumentationConnectionError, File.expand_path("support_table_data/documentation_connection_error", __dir__) + autoload :Documentation, File.expand_path("support_table_data/documentation", __dir__) + autoload :Tasks, File.expand_path("support_table_data/tasks", __dir__) + @data_directory = nil + @compact_yard_threshold = 20 + @rbs_signatures_path = nil + @infer_documentation_types = true included do # Internal variables used for memoization. @@ -373,6 +381,32 @@ def data_directory=(value) @data_directory = value&.to_s end + # When a model has more than this many named instances, the YARD docs + # generator emits a compact form using @!macro directives instead of a + # verbose comment block per method. Defaults to 5. + # + # @return [Integer] + attr_accessor :compact_yard_threshold + + # Override the directory under which generated RBS signature files are + # written. When nil (the default) signatures go to + # `/sig/.rbs`, where the project root is the + # nearest ancestor directory containing a Gemfile or .git directory. + # + # @return [String, Pathname, nil] + attr_accessor :rbs_signatures_path + + # When true (the default) the documentation generators read ActiveRecord + # column types so that attribute helper return types are real types like + # `String` or `Integer`. This requires a working database connection at + # generation time and will raise SupportTableData::DocumentationConnectionError + # if one is not available. Set to false to skip the database lookup and emit + # generic Object/untyped return types instead — useful for CI jobs that + # cannot or do not want to provision a database. + # + # @return [Boolean] + attr_accessor :infer_documentation_types + # Sync all support table classes. Classes must already be loaded in order to be synced. # # You can pass in a list of classes that you want to ensure are synced. This feature @@ -483,8 +517,6 @@ def protected_instance? end end -require_relative "support_table_data/validation_error" - if defined?(Rails::Railtie) require_relative "support_table_data/railtie" end diff --git a/lib/support_table_data/documentation.rb b/lib/support_table_data/documentation.rb index 248baf5..2013c56 100644 --- a/lib/support_table_data/documentation.rb +++ b/lib/support_table_data/documentation.rb @@ -2,8 +2,10 @@ module SupportTableData module Documentation + autoload :TypeInference, File.expand_path("documentation/type_inference", __dir__) + autoload :SourceFile, File.expand_path("documentation/source_file", __dir__) + autoload :YardDoc, File.expand_path("documentation/yard_doc", __dir__) + autoload :RbsDoc, File.expand_path("documentation/rbs_doc", __dir__) + autoload :RbsFile, File.expand_path("documentation/rbs_file", __dir__) end end - -require_relative "documentation/source_file" -require_relative "documentation/yard_doc" diff --git a/lib/support_table_data/documentation/rbs_doc.rb b/lib/support_table_data/documentation/rbs_doc.rb new file mode 100644 index 0000000..433fc7f --- /dev/null +++ b/lib/support_table_data/documentation/rbs_doc.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + # Generates RBS type signatures for the dynamically-defined named instance + # helper methods on a support table model. + class RbsDoc + # @param klass [Class] The model class to generate signatures for + def initialize(klass) + @klass = klass + end + + # Render the full RBS file content for the model, including the class + # declaration. Returns nil when the model has no named instances. + # + # @return [String, nil] + def signatures + instance_names = klass.instance_names + return nil if instance_names.empty? + + body_lines = [] + instance_names.sort.each_with_index do |name, idx| + body_lines << "" unless idx.zero? + body_lines.concat(instance_signatures(name)) + end + + <<~RBS + # Generated by support_table_data - do not edit by hand. + # To update, run `bundle exec rake support_table_data:rbs`. + class #{klass.name} + #{body_lines.map { |line| line.empty? ? "" : " #{line}" }.join("\n")} + end + RBS + end + + private + + attr_reader :klass + + def instance_signatures(name) + lines = [] + lines << "def self.#{name}: () -> #{klass.name}" + lines << "def #{name}?: () -> bool" + klass.support_table_attribute_helpers.each do |attribute_name| + return_type = TypeInference.rbs_type(TypeInference.column_type(klass, attribute_name)) + lines << "def self.#{name}_#{attribute_name}: () -> #{return_type}" + end + lines + end + end + end +end diff --git a/lib/support_table_data/documentation/rbs_file.rb b/lib/support_table_data/documentation/rbs_file.rb new file mode 100644 index 0000000..bd271df --- /dev/null +++ b/lib/support_table_data/documentation/rbs_file.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "pathname" + +module SupportTableData + module Documentation + # Manages reading/writing the per-model RBS signature file. + class RbsFile + attr_reader :klass, :source_path, :path + + # @param klass [Class] The model class + # @param source_path [Pathname] The path to the model's source `.rb` file + def initialize(klass, source_path) + @klass = klass + @source_path = Pathname.new(source_path) + @path = self.class.signatures_path_for(@source_path) + end + + # Compute where the RBS signatures should live for a given source file. + # Defaults to `/sig/.rbs`. + # The project root is found by walking upward looking for a `Gemfile` or + # `.git` directory; if neither is found, the immediate parent of the + # source file is used. + # + # @param source_path [Pathname] + # @return [Pathname] + def self.signatures_path_for(source_path) + if SupportTableData.rbs_signatures_path + base = Pathname.new(SupportTableData.rbs_signatures_path) + relative = relative_to_project_root(source_path) + return base.join("#{relative.to_s.sub(/\.rb\z/, "")}.rbs") + end + + root = project_root_for(source_path) + relative = source_path.expand_path.relative_path_from(root) + root.join("sig", "#{relative.to_s.sub(/\.rb\z/, "")}.rbs") + end + + # Project root is the nearest ancestor directory containing a Gemfile or + # a .git directory. Falls back to the source file's directory. + def self.project_root_for(source_path) + current = Pathname.new(source_path).expand_path.parent + loop do + return current if current.join("Gemfile").file? + return current if current.join(".git").exist? + + parent = current.parent + return Pathname.new(source_path).expand_path.parent if parent == current + + current = parent + end + end + + def self.relative_to_project_root(source_path) + Pathname.new(source_path).expand_path.relative_path_from(project_root_for(source_path)) + end + + # Render the desired RBS content for this model, or nil if the model has + # no named instances (in which case no file should be written). + # + # @return [String, nil] + def signatures_content + RbsDoc.new(klass).signatures + end + + # Whether the existing on-disk file matches what would be generated. + # + # @return [Boolean] + def up_to_date? + desired = signatures_content + if desired.nil? + !path.file? + else + path.file? && path.read == desired + end + end + + # Write the generated RBS content to disk, creating parent directories as + # needed. Returns true if the file was written, false if there was nothing + # to write. + # + # @return [Boolean] + def write! + content = signatures_content + return false if content.nil? + + path.parent.mkpath + path.write(content) + true + end + + # Delete the generated RBS file if it exists. Returns true if a file was + # removed. + # + # @return [Boolean] + def remove! + return false unless path.file? + + path.delete + true + end + end + end +end diff --git a/lib/support_table_data/documentation/type_inference.rb b/lib/support_table_data/documentation/type_inference.rb new file mode 100644 index 0000000..25583c5 --- /dev/null +++ b/lib/support_table_data/documentation/type_inference.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + # Maps ActiveRecord column types to documentation type strings. + module TypeInference + module_function + + # Look up the ActiveRecord column type symbol for an attribute, or nil if + # type inference is disabled, the class does not expose columns_hash, or + # the column is not defined on the table (e.g. virtual attributes). + # + # Raises SupportTableData::DocumentationConnectionError when the lookup + # fails because no database connection is available and + # SupportTableData.infer_documentation_types is true. + def column_type(klass, attribute_name) + return nil unless SupportTableData.infer_documentation_types + return nil unless klass.respond_to?(:columns_hash) + + column = klass.columns_hash[attribute_name.to_s] + column&.type + rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid => e + raise SupportTableData::DocumentationConnectionError.new(klass, e) + end + + # Map a column type symbol to a YARD type string. + def yard_type(column_type) + case column_type + when :string, :text, :uuid, :inet, :cidr then "String" + when :integer, :bigint then "Integer" + when :decimal then "BigDecimal" + when :float then "Float" + when :boolean then "Boolean" + when :date then "Date" + when :datetime, :timestamp, :time then "Time" + when :json, :jsonb, :hstore then "Hash" + when :binary then "String" + else "Object" + end + end + + # Map a column type symbol to an RBS type string. + def rbs_type(column_type) + case column_type + when :string, :text, :uuid, :inet, :cidr then "String" + when :integer, :bigint then "Integer" + when :decimal then "BigDecimal" + when :float then "Float" + when :boolean then "bool" + when :date then "Date" + when :datetime, :timestamp, :time then "Time" + when :json, :jsonb, :hstore then "Hash[untyped, untyped]" + when :binary then "String" + else "untyped" + end + end + end + end +end diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index 2fa6baa..9e04622 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -3,17 +3,30 @@ module SupportTableData module Documentation class YardDoc + MACRO_FINDER = "support_table_data_finder" + MACRO_PREDICATE = "support_table_data_predicate" + MACRO_ATTRIBUTE = "support_table_data_attribute" + # @param klass [Class] The model class to generate documentation for def initialize(klass) @klass = klass end - # Generate YARD documentation class definition for the model's helper methods. + # Generate YARD documentation for the model's helper methods. When the + # number of named instances exceeds SupportTableData.compact_yard_threshold + # the output uses YARD @!macro directives to keep the file size manageable; + # otherwise it emits a verbose comment block per method. # - # @return [String, nil] The YARD documentation class definition, or nil if no named instances + # @return [String, nil] The YARD documentation, or nil if no named instances def named_instance_yard_docs instance_names = klass.instance_names - generate_yard_docs(instance_names) + return nil if instance_names.empty? + + if instance_names.size > SupportTableData.compact_yard_threshold + generate_compact_yard_docs(instance_names) + else + generate_verbose_yard_docs(instance_names) + end end # Generate YARD documentation comment for named instance singleton method. @@ -48,14 +61,16 @@ def predicate_helper_yard_doc(name) # Generate YARD documentation comment for the attribute method helper for the named instance. # # @param name [String] The name of the instance method. + # @param attribute_name [String] The attribute being read. # @return [String] The YARD comment text def attribute_helper_yard_doc(name, attribute_name) + return_type = TypeInference.yard_type(TypeInference.column_type(klass, attribute_name)) <<~YARD.chomp("\n") # Get the #{attribute_name} attribute from the data file # for the named instance +#{name}+. # # @!method self.#{name}_#{attribute_name} - # @return [Object] + # @return [#{return_type}] # @!visibility public YARD end @@ -64,12 +79,9 @@ def attribute_helper_yard_doc(name, attribute_name) attr_reader :klass - def generate_yard_docs(instance_names) - return nil if instance_names.empty? - + def generate_verbose_yard_docs(instance_names) yard_lines = ["# @!group Named Instances"] - # Generate docs for each named instance instance_names.sort.each do |name| yard_lines << "" yard_lines << instance_helper_yard_doc(name) @@ -86,6 +98,73 @@ def generate_yard_docs(instance_names) yard_lines.join("\n") end + + def generate_compact_yard_docs(instance_names) + yard_lines = ["# @!group Named Instances"] + yard_lines << "" + yard_lines << compact_preamble + yard_lines << "" + yard_lines << compact_macro_definitions + + instance_names.sort.each do |name| + yard_lines << "" + yard_lines << compact_instance_block(name) + end + + yard_lines << "" + yard_lines << "# @!endgroup" + + yard_lines.join("\n") + end + + def compact_preamble + <<~YARD.chomp("\n") + # The methods in this group are dynamically defined by support_table_data + # for each named instance in the data file. The macros below are the + # documentation templates; the per-instance @!method lines that follow + # invoke them with the instance name (and attribute name, where applicable). + YARD + end + + def compact_macro_definitions + attribute_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_ATTRIBUTE} + # Get the +$2+ attribute from the data file for the named instance +$1+. + # @return [$3] + # @!visibility public + YARD + + finder_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_FINDER} + # Find the named instance +$1+ from the database. + # @return [#{klass.name}] + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!visibility public + YARD + + predicate_macro = <<~YARD.chomp("\n") + # @!macro [new] #{MACRO_PREDICATE} + # Check if this record is the named instance +$1+. + # @return [Boolean] + # @!visibility public + YARD + + [finder_macro, "", predicate_macro, "", attribute_macro].join("\n") + end + + def compact_instance_block(name) + lines = [] + lines << "# @!method self.#{name}" + lines << "# @!macro #{MACRO_FINDER} #{name}" + lines << "# @!method #{name}?" + lines << "# @!macro #{MACRO_PREDICATE} #{name}" + klass.support_table_attribute_helpers.each do |attribute_name| + return_type = TypeInference.yard_type(TypeInference.column_type(klass, attribute_name)) + lines << "# @!method self.#{name}_#{attribute_name}" + lines << "# @!macro #{MACRO_ATTRIBUTE} #{name} #{attribute_name} #{return_type}" + end + lines.join("\n") + end end end end diff --git a/lib/support_table_data/documentation_connection_error.rb b/lib/support_table_data/documentation_connection_error.rb new file mode 100644 index 0000000..69a7b37 --- /dev/null +++ b/lib/support_table_data/documentation_connection_error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module SupportTableData + # Raised when documentation generation needs to read column types from the + # database to fill in `@return` tags / RBS signatures, but no database + # connection is available. Users who run the documentation tasks in + # environments without a database (e.g. a lint-only CI job) can opt out by + # setting `SupportTableData.infer_documentation_types = false`. + class DocumentationConnectionError < StandardError + def initialize(klass, original) + message = <<~MSG.strip + Could not load column types for #{klass.name} from the database while generating documentation (#{original.class}: #{original.message}). + + Documentation generation reads ActiveRecord column types so the produced docs use specific return types (String, Integer, Boolean, ...) instead of generic Object/untyped. + + To resolve, either: + 1. Ensure a database connection is available when running the documentation tasks (e.g. run `bin/rails db:prepare` or set DATABASE_URL). + 2. Disable type inference globally: + SupportTableData.infer_documentation_types = false + The generated docs will fall back to generic Object/untyped return types. + MSG + super(message) + end + end +end diff --git a/lib/support_table_data/tasks.rb b/lib/support_table_data/tasks.rb new file mode 100644 index 0000000..d5192ff --- /dev/null +++ b/lib/support_table_data/tasks.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module SupportTableData + module Tasks + autoload :Utils, File.expand_path("tasks/utils", __dir__) + end +end diff --git a/lib/tasks/utils.rb b/lib/support_table_data/tasks/utils.rb similarity index 84% rename from lib/tasks/utils.rb rename to lib/support_table_data/tasks/utils.rb index c7b6eec..40008e5 100644 --- a/lib/tasks/utils.rb +++ b/lib/support_table_data/tasks/utils.rb @@ -51,6 +51,16 @@ def support_table_sources(file_path = nil) [] end + # Return RBS file handlers for all support table models. + # + # @param file_path [String, Pathname, nil] Optional file path to filter by. + # @return [Array] + def support_table_rbs_files(file_path = nil) + support_table_sources(file_path).map do |source| + Documentation::RbsFile.new(source.klass, source.path) + end + end + def model_file_path(klass) file_path = "#{klass.name.underscore}.rb" model_path = nil diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index 8e21bfe..c9d56db 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -3,8 +3,6 @@ namespace :support_table_data do desc "Syncronize data for all models that include SupportTableData." task sync: :environment do - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! logger_callback = lambda do |name, started, finished, unique_id, payload| @@ -28,9 +26,6 @@ namespace :support_table_data do namespace :yard_docs do desc "Adds YARD documentation comments to models to document the named instance methods. Optional arg: file_path" task :add, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! SupportTableData::Tasks::Utils.support_table_sources(args[:file_path]).each do |source_file| next if source_file.yard_docs_up_to_date? @@ -42,9 +37,6 @@ namespace :support_table_data do desc "Removes YARD documentation comments added by support_table_data from models. Optional arg: file_path" task :remove, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! SupportTableData::Tasks::Utils.support_table_sources(args[:file_path]).each do |source_file| next unless source_file.has_yard_docs? @@ -56,9 +48,6 @@ namespace :support_table_data do desc "Verify that support table models have up to date YARD docs for named instance methods. Optional arg: file_path" task :verify, [:file_path] => :environment do |_task, args| - require_relative "../support_table_data/documentation" - require_relative "utils" - SupportTableData::Tasks::Utils.eager_load! all_up_to_date = true @@ -80,4 +69,52 @@ namespace :support_table_data do end end end + + task rbs: "rbs:add" + + namespace :rbs do + desc "Generates RBS signature files for named instance methods. Optional arg: file_path" + task :add, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + next if rbs_file.up_to_date? + + rbs_file.write! + puts "Wrote RBS signatures for #{rbs_file.klass.name} to #{rbs_file.path}." + end + end + + desc "Removes generated RBS signature files. Optional arg: file_path" + task :remove, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + next unless rbs_file.remove! + + puts "Removed RBS signatures for #{rbs_file.klass.name} (#{rbs_file.path})." + end + end + + desc "Verify that generated RBS signature files are up to date. Optional arg: file_path" + task :verify, [:file_path] => :environment do |_task, args| + SupportTableData::Tasks::Utils.eager_load! + + all_up_to_date = true + SupportTableData::Tasks::Utils.support_table_rbs_files(args[:file_path]).each do |rbs_file| + unless rbs_file.up_to_date? + puts "RBS signatures are not up to date for #{rbs_file.klass.name} (#{rbs_file.path})." + all_up_to_date = false + end + end + + if all_up_to_date + if args[:file_path] + puts "RBS signatures are up to date for #{args[:file_path]}." + else + puts "All support table models have up to date RBS signatures." + end + else + raise "Run bundle exec rake support_table_data:rbs to update the signatures." + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9ff17b7..fe9b1cd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,6 @@ ActiveRecord::Base.establish_connection("adapter" => "sqlite3", "database" => ":memory:") require_relative "../lib/support_table_data" -require_relative "../lib/support_table_data/documentation" SupportTableData.data_directory = File.join(__dir__, "data") diff --git a/spec/support_table_data/documentation/rbs_doc_spec.rb b/spec/support_table_data/documentation/rbs_doc_spec.rb new file mode 100644 index 0000000..27231d7 --- /dev/null +++ b/spec/support_table_data/documentation/rbs_doc_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::RbsDoc do + describe "#signatures" do + it "returns nil when the model has no named instances" do + allow(Color).to receive(:instance_names).and_return([]) + + expect(described_class.new(Color).signatures).to be_nil + end + + it "wraps signatures in a class declaration matching the model" do + result = described_class.new(Color).signatures + + expect(result).to start_with("# Generated by support_table_data - do not edit by hand.\n") + expect(result).to include("class Color\n") + expect(result).to end_with("end\n") + end + + it "emits a finder and predicate signature per named instance" do + result = described_class.new(Color).signatures + + expect(result).to include(" def self.red: () -> Color") + expect(result).to include(" def red?: () -> bool") + expect(result).to include(" def self.blue: () -> Color") + expect(result).to include(" def blue?: () -> bool") + end + + it "uses RBS-flavoured types for attribute helper signatures" do + result = described_class.new(Group).signatures + + # group_id is integer, name is string + expect(result).to include(" def self.primary_group_id: () -> Integer") + expect(result).to include(" def self.primary_name: () -> String") + end + + it "sorts named instances alphabetically" do + result = described_class.new(Color).signatures + + black_pos = result.index("def self.black:") + blue_pos = result.index("def self.blue:") + green_pos = result.index("def self.green:") + red_pos = result.index("def self.red:") + + expect(black_pos).to be < blue_pos + expect(blue_pos).to be < green_pos + expect(green_pos).to be < red_pos + end + end +end diff --git a/spec/support_table_data/documentation/rbs_file_spec.rb b/spec/support_table_data/documentation/rbs_file_spec.rb new file mode 100644 index 0000000..259f71b --- /dev/null +++ b/spec/support_table_data/documentation/rbs_file_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tmpdir" + +RSpec.describe SupportTableData::Documentation::RbsFile do + let(:color_path) { Pathname.new(File.expand_path("../../models/color.rb", __dir__)) } + + describe ".signatures_path_for" do + it "places the sig file at /sig/.rbs" do + Dir.mktmpdir do |tmp| + root = Pathname.new(tmp) + FileUtils.touch(root.join("Gemfile")) + model_dir = root.join("app", "models") + model_dir.mkpath + source = model_dir.join("widget.rb") + source.write("class Widget; end\n") + + sig_path = described_class.signatures_path_for(source) + + expect(sig_path).to eq(root.join("sig", "app", "models", "widget.rbs")) + end + end + + it "honours SupportTableData.rbs_signatures_path when set" do + Dir.mktmpdir do |tmp| + root = Pathname.new(tmp) + FileUtils.touch(root.join("Gemfile")) + source = root.join("app", "models", "widget.rb") + source.dirname.mkpath + source.write("class Widget; end\n") + + original = SupportTableData.rbs_signatures_path + begin + SupportTableData.rbs_signatures_path = root.join("custom_sigs").to_s + sig_path = described_class.signatures_path_for(source) + + expect(sig_path).to eq(Pathname.new(root.join("custom_sigs", "app", "models", "widget.rbs").to_s)) + ensure + SupportTableData.rbs_signatures_path = original + end + end + end + end + + describe "#up_to_date?" do + it "returns false when the file is missing" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.up_to_date?).to be false + end + end + + it "returns true after writing" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + rbs_file.write! + + expect(rbs_file.up_to_date?).to be true + end + end + end + + describe "#write!" do + it "creates the parent sig directory and writes the signatures" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.write!).to be true + + expect(rbs_file.path).to exist + expect(rbs_file.path.read).to include("class Color") + expect(rbs_file.path.read).to include("def self.red: () -> Color") + end + end + + it "returns false when there are no instances to document" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + allow(Color).to receive(:instance_names).and_return([]) + rbs_file = described_class.new(Color, source) + + expect(rbs_file.write!).to be false + expect(rbs_file.path).not_to exist + end + end + end + + describe "#remove!" do + it "deletes the file when it exists" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + rbs_file.write! + expect(rbs_file.path).to exist + + expect(rbs_file.remove!).to be true + expect(rbs_file.path).not_to exist + end + end + + it "returns false when there is nothing to remove" do + Dir.mktmpdir do |tmp| + FileUtils.touch(File.join(tmp, "Gemfile")) + source = Pathname.new(tmp).join("app", "models", "color.rb") + source.dirname.mkpath + FileUtils.cp(color_path, source) + + rbs_file = described_class.new(Color, source) + expect(rbs_file.remove!).to be false + end + end + end +end diff --git a/spec/support_table_data/documentation/type_inference_spec.rb b/spec/support_table_data/documentation/type_inference_spec.rb new file mode 100644 index 0000000..894de92 --- /dev/null +++ b/spec/support_table_data/documentation/type_inference_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::TypeInference do + describe ".column_type" do + it "returns the AR column type symbol for a known column" do + expect(described_class.column_type(Group, "name")).to eq(:string) + expect(described_class.column_type(Group, "group_id")).to eq(:integer) + end + + it "returns nil for an unknown column" do + expect(described_class.column_type(Group, "not_a_real_column")).to be_nil + end + + it "returns nil for a class that does not respond to columns_hash" do + klass = Class.new + expect(described_class.column_type(klass, "anything")).to be_nil + end + + context "when SupportTableData.infer_documentation_types is false" do + around do |example| + original = SupportTableData.infer_documentation_types + SupportTableData.infer_documentation_types = false + begin + example.run + ensure + SupportTableData.infer_documentation_types = original + end + end + + it "returns nil without consulting the database" do + expect(Group).not_to receive(:columns_hash) + expect(described_class.column_type(Group, "name")).to be_nil + end + end + + context "when no database connection is available" do + it "raises a DocumentationConnectionError with resolution guidance" do + allow(Group).to receive(:columns_hash) + .and_raise(ActiveRecord::ConnectionNotEstablished, "No connection pool") + + expect { + described_class.column_type(Group, "name") + }.to raise_error(SupportTableData::DocumentationConnectionError) do |error| + expect(error.message).to include("Group") + expect(error.message).to include("ConnectionNotEstablished") + expect(error.message).to include("infer_documentation_types = false") + end + end + + it "returns nil silently when type inference is disabled" do + original = SupportTableData.infer_documentation_types + SupportTableData.infer_documentation_types = false + begin + # columns_hash is not even called; nothing to raise. + expect(described_class.column_type(Group, "name")).to be_nil + ensure + SupportTableData.infer_documentation_types = original + end + end + end + end + + describe ".yard_type" do + it "maps common column types to documentation strings" do + expect(described_class.yard_type(:string)).to eq("String") + expect(described_class.yard_type(:integer)).to eq("Integer") + expect(described_class.yard_type(:boolean)).to eq("Boolean") + expect(described_class.yard_type(:date)).to eq("Date") + expect(described_class.yard_type(:datetime)).to eq("Time") + expect(described_class.yard_type(:json)).to eq("Hash") + end + + it "falls back to Object for nil or unknown types" do + expect(described_class.yard_type(nil)).to eq("Object") + expect(described_class.yard_type(:something_exotic)).to eq("Object") + end + end + + describe ".rbs_type" do + it "maps common column types to RBS type strings" do + expect(described_class.rbs_type(:string)).to eq("String") + expect(described_class.rbs_type(:integer)).to eq("Integer") + expect(described_class.rbs_type(:boolean)).to eq("bool") + expect(described_class.rbs_type(:date)).to eq("Date") + expect(described_class.rbs_type(:datetime)).to eq("Time") + end + + it "falls back to untyped for nil or unknown types" do + expect(described_class.rbs_type(nil)).to eq("untyped") + expect(described_class.rbs_type(:something_exotic)).to eq("untyped") + end + end +end diff --git a/spec/support_table_data/documentation/yard_doc_spec.rb b/spec/support_table_data/documentation/yard_doc_spec.rb index b9fb623..fa61076 100644 --- a/spec/support_table_data/documentation/yard_doc_spec.rb +++ b/spec/support_table_data/documentation/yard_doc_spec.rb @@ -33,6 +33,22 @@ end end + describe "#attribute_helper_yard_doc" do + it "uses the column type for the @return tag when the column is known" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.attribute_helper_yard_doc("primary", "name") + + expect(result).to include("# @return [String]") + end + + it "falls back to Object when the column is not on the table" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.attribute_helper_yard_doc("primary", "not_a_real_column") + + expect(result).to include("# @return [Object]") + end + end + describe "#named_instance_yard_docs" do it "returns nil when model has no named instances" do allow(Color).to receive(:instance_names).and_return([]) @@ -94,5 +110,61 @@ expect(blue_pos).to be < green_pos expect(green_pos).to be < red_pos end + + context "when there are more named instances than the compact threshold" do + around do |example| + original = SupportTableData.compact_yard_threshold + SupportTableData.compact_yard_threshold = 5 + begin + example.run + ensure + SupportTableData.compact_yard_threshold = original + end + end + + it "emits the compact macro form for Hue (9 instances)" do + doc = SupportTableData::Documentation::YardDoc.new(Hue) + result = doc.named_instance_yard_docs + + expect(result).not_to be_nil + expect(result).to include("# @!group Named Instances") + expect(result).to include("# @!endgroup") + + # Macro definitions are emitted once. + expect(result).to include("# @!macro [new] support_table_data_finder") + expect(result).to include("# @!macro [new] support_table_data_predicate") + # Hue has no attribute helpers, but the attribute macro is still defined. + expect(result).to include("# @!macro [new] support_table_data_attribute") + expect(result.scan("# @!macro [new] support_table_data_finder").size).to eq(1) + + # Per-instance entries reference the macros rather than repeating prose. + expect(result).to include("# @!method self.red") + expect(result).to include("# @!macro support_table_data_finder red") + expect(result).to include("# @!method red?") + expect(result).to include("# @!macro support_table_data_predicate red") + expect(result).not_to include("# Find the named instance +red+ from the database.") + end + + it "uses the verbose form when count is at or below the threshold" do + SupportTableData.compact_yard_threshold = 4 + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.named_instance_yard_docs + + # Color has 4 instances; with threshold 4 we are NOT above it -> verbose. + expect(result).to include("# Find the named instance +red+ from the database.") + expect(result).not_to include("# @!macro [new]") + end + + it "includes attribute macro invocations with column-derived types" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + # Group only has 3 instances normally; force compact form to test attribute output. + SupportTableData.compact_yard_threshold = 0 + result = doc.named_instance_yard_docs + + # Group has attribute helpers for group_id (integer) and name (string). + expect(result).to include("# @!macro support_table_data_attribute primary group_id Integer") + expect(result).to include("# @!macro support_table_data_attribute primary name String") + end + end end end diff --git a/spec/support_table_data_spec.rb b/spec/support_table_data_spec.rb index 8ff48a7..ce993ea 100644 --- a/spec/support_table_data_spec.rb +++ b/spec/support_table_data_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "spec_helper" +require "spec_helper" describe SupportTableData do let(:red) { Color.find_by(name: "Red") } diff --git a/spec/tasks_spec.rb b/spec/tasks_spec.rb index 59d2d37..6bcc401 100644 --- a/spec/tasks_spec.rb +++ b/spec/tasks_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "spec_helper" +require "spec_helper" require "rake" RSpec.describe "support_table_data rake tasks" do @@ -35,9 +35,6 @@ describe "yard_docs:add" do it "adds YARD documentation to models with named instances" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - # Mock stdout to capture puts allow($stdout).to receive(:puts) @@ -62,9 +59,6 @@ end it "applies only to the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) written_files = [] @@ -81,9 +75,6 @@ describe "yard_docs:remove" do it "removes YARD documentation from models" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - # Mock stdout to capture puts allow($stdout).to receive(:puts) @@ -111,9 +102,6 @@ end it "applies only to the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) written_files = [] @@ -131,11 +119,81 @@ end end + describe "rbs:add" do + it "writes RBS files for models with named instances" do + allow($stdout).to receive(:puts) + + written_files = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:write!) do |instance| + written_files << {path: instance.path.to_s, klass: instance.klass.name} + true + end + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + Rake.application.invoke_task "support_table_data:rbs:add" + + expect(written_files).not_to be_empty + expect(written_files.map { |f| f[:klass] }).to include("Color") + end + + it "applies only to the specified file path when provided" do + allow($stdout).to receive(:puts) + + written_files = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:write!) do |instance| + written_files << {path: instance.path.to_s, klass: instance.klass.name} + true + end + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + Rake::Task["support_table_data:rbs:add"].invoke(color_model_path) + + expect(written_files.map { |f| f[:klass] }).to eq(["Color"]) + end + end + + describe "rbs:remove" do + it "removes generated RBS files for support table models" do + allow($stdout).to receive(:puts) + + removed = [] + allow_any_instance_of(SupportTableData::Documentation::RbsFile).to receive(:remove!) do |instance| + removed << instance.klass.name + true + end + + Rake.application.invoke_task "support_table_data:rbs:remove" + + expect(removed).not_to be_empty + end + end + + describe "rbs:verify" do + it "passes when all signatures are up to date" do + allow($stdout).to receive(:puts) + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(true) + + Rake.application.invoke_task "support_table_data:rbs:verify" + + expect($stdout).to have_received(:puts).with("All support table models have up to date RBS signatures.") + end + + it "raises when signatures are out of date" do + allow($stdout).to receive(:puts) + allow_any_instance_of(SupportTableData::Documentation::RbsFile) + .to receive(:up_to_date?).and_return(false) + + expect { + Rake.application.invoke_task "support_table_data:rbs:verify" + }.to raise_error(RuntimeError) + end + end + describe "yard_docs:verify" do it "verifies YARD documentation is up to date" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) allow_any_instance_of(SupportTableData::Documentation::SourceFile) .to receive(:yard_docs_up_to_date?).and_return(true) @@ -148,9 +206,6 @@ end it "raises an error if any YARD documentation is out of date" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) allow_any_instance_of(SupportTableData::Documentation::SourceFile) .to receive(:yard_docs_up_to_date?).and_return(false) @@ -165,9 +220,6 @@ end it "verifies only the specified file path when provided" do - require_relative "../lib/support_table_data/documentation" - require_relative "../lib/tasks/utils" - allow($stdout).to receive(:puts) expect_any_instance_of(SupportTableData::Documentation::SourceFile) diff --git a/support_table_data.gemspec b/support_table_data.gemspec index e906b74..43a460e 100644 --- a/support_table_data.gemspec +++ b/support_table_data.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| bin/ gemfiles/ spec/ + test_app/ ] spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do `git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } } diff --git a/test_app/app/models/status.rb b/test_app/app/models/status.rb index 9d298bb..1d24b5c 100644 --- a/test_app/app/models/status.rb +++ b/test_app/app/models/status.rb @@ -32,7 +32,7 @@ class Status # for the named instance +active+. # # @!method self.active_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +canceled+ from the database. @@ -52,7 +52,7 @@ class Status # for the named instance +canceled+. # # @!method self.canceled_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +completed+ from the database. @@ -72,7 +72,7 @@ class Status # for the named instance +completed+. # # @!method self.completed_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +failed+ from the database. @@ -92,7 +92,7 @@ class Status # for the named instance +failed+. # # @!method self.failed_name - # @return [Object] + # @return [String] # @!visibility public # Find the named instance +pending+ from the database. @@ -112,7 +112,7 @@ class Status # for the named instance +pending+. # # @!method self.pending_name - # @return [Object] + # @return [String] # @!visibility public # @!endgroup From 6540baceffaf82efd0017557f8396fa0700c7dad Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:17:44 -0700 Subject: [PATCH 2/8] Add yard docs option per model --- CHANGELOG.md | 2 +- README.md | 16 ++-- lib/support_table_data.rb | 44 +++++++++-- .../documentation/source_file.rb | 4 +- .../documentation/yard_doc.rb | 20 +++-- .../documentation/source_file_spec.rb | 67 +++++++++++++++++ .../documentation/yard_doc_spec.rb | 74 ++++++++++++++----- 7 files changed, 186 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1835fb4..eca58c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Generated YARD documentation now switches to a compact `@!macro`-based form when a model has more than `SupportTableData.compact_yard_threshold` named instances (default 20). The macro definitions appear once at the top of the documentation block with the prose templates so the file stays readable for humans, and IDEs/`yard doc` resolve them into the same per-method documentation as the verbose form. This dramatically reduces the size of the generated comment block on models with many instances. +- Each model can now choose how its YARD docs are generated by setting `self.support_table_yard_docs` to `:full` (the default — verbose comment block per method), `:compact` (shared `@!macro` definitions plus a short `@!method`/`@!macro` pair per method, dramatically reducing comment-block size on tables with many named instances), or `:none` (skip generation entirely; previously generated docs are stripped on the next run). IDEs and `yard doc` resolve the compact form into the same per-method documentation as the verbose form. - The attribute helper YARD comment now uses the underlying ActiveRecord column type (`String`, `Integer`, `Boolean`, etc.) for `@return` instead of the generic `Object`. This requires a database connection at generation time; set `SupportTableData.infer_documentation_types = false` to opt out and fall back to generic types. When type inference is enabled but no connection is available, the tasks now raise a clear `SupportTableData::DocumentationConnectionError` rather than failing with a low-level ActiveRecord error. - Added opt-in RBS signature generation. The new tasks `support_table_data:rbs`, `support_table_data:rbs:verify`, and `support_table_data:rbs:remove` write/check/delete `sig/.rbs` files so the named instance helpers are visible to Ruby LSP, Steep, RubyMine, and other RBS-aware tools without polluting the model source files. The output directory can be overridden with `SupportTableData.rbs_signatures_path`. diff --git a/README.md b/README.md index da402d3..c3c1ede 100644 --- a/README.md +++ b/README.md @@ -200,15 +200,21 @@ The default behavior is to add the documentation comments at the end of the mode A good practice is to add a check to your CI pipeline to ensure the documentation is always up to date. You can run the rake task `support_table_data:yard_docs:verify` to do this. It will exit with an error if any models do not have up to date documentation. -For models with many named instances, the verbose comment form can become very long. When a model has more than `SupportTableData.compact_yard_threshold` named instances (default 20), the generator switches to a compact form that uses YARD `@!macro` directives. The macros are defined at the top of the documentation block with the prose templates, and each named instance gets a short `@!method`/`@!macro` pair beneath. IDEs and `yard doc` resolve the macros into the same per-method documentation you would get from the verbose form. +Each model can choose how its YARD docs are generated by setting `support_table_yard_docs` to one of three values: -To force the verbose form for all models, set the threshold to a high value (e.g. in an initializer): +- `:full` — verbose comment block per generated method (the default) +- `:compact` — shared `@!macro` definitions at the top of the documentation block plus a short `@!method` / `@!macro` pair per generated method. IDEs and `yard doc` resolve the macros into the same per-method documentation as `:full`. Useful when a model has many named instances and the verbose comment block is too long to be useful inline. +- `:none` — generate no YARD docs for this model. The rake task will also strip any previously generated YARD docs from the source file. ```ruby -SupportTableData.compact_yard_threshold = 1000 -``` +class Feature < ApplicationRecord + include SupportTableData + + self.support_table_yard_docs = :compact -To force the compact form everywhere, set it to `0`. + add_support_table_data "features.yml" +end +``` The generator reads the underlying ActiveRecord column types so attribute helpers get specific return types (`String`, `Integer`, `Boolean`, etc.) in their documentation. This requires a working database connection at generation time. If the documentation tasks need to run in an environment without a database (for example a lint-only CI job), disable the lookup and the docs will fall back to generic `Object` / `untyped` return types: diff --git a/lib/support_table_data.rb b/lib/support_table_data.rb index 333e2ca..55cd69e 100644 --- a/lib/support_table_data.rb +++ b/lib/support_table_data.rb @@ -13,8 +13,9 @@ module SupportTableData autoload :Documentation, File.expand_path("support_table_data/documentation", __dir__) autoload :Tasks, File.expand_path("support_table_data/tasks", __dir__) + YARD_DOC_OPTIONS = [:full, :compact, :none].freeze + @data_directory = nil - @compact_yard_threshold = 20 @rbs_signatures_path = nil @infer_documentation_types = true @@ -39,6 +40,15 @@ class << self # value set by SupportTableData.data_directory. This is only used if relative paths are passed # in to add_support_table_data. class_attribute :support_table_data_directory, instance_accessor: false + + # Private class attribute backing `support_table_yard_docs`. Use the public + # accessor to read/write. + # @private + class_attribute :_support_table_yard_docs, instance_accessor: false, default: :full + class << self + private :_support_table_yard_docs= + private :_support_table_yard_docs + end end class_methods do @@ -56,6 +66,31 @@ def support_table_key_attribute _support_table_key_attribute || "id" end + # Get the YARD documentation mode for this model. One of: + # + # * `:full` - emit a verbose comment block per generated method (default) + # * `:compact` - emit shared @!macro definitions plus a short + # @!method/@!macro pair per generated method + # * `:none` - generate no YARD docs for this model; the rake task will + # strip any existing generated YARD docs + # + # @return [Symbol] + def support_table_yard_docs + _support_table_yard_docs + end + + # Set the YARD documentation mode for this model. See `support_table_yard_docs` + # for the supported values. + # + # @param value [Symbol] + # @return [void] + def support_table_yard_docs=(value) + unless SupportTableData::YARD_DOC_OPTIONS.include?(value) + raise ArgumentError, "support_table_yard_docs must be one of #{SupportTableData::YARD_DOC_OPTIONS.inspect} (got #{value.inspect})" + end + self._support_table_yard_docs = value + end + # Synchronize the rows in the table with the values defined in the data files added with # `add_support_table_data`. Note that rows will not be deleted if they are no longer in # the data files. @@ -381,13 +416,6 @@ def data_directory=(value) @data_directory = value&.to_s end - # When a model has more than this many named instances, the YARD docs - # generator emits a compact form using @!macro directives instead of a - # verbose comment block per method. Defaults to 5. - # - # @return [Integer] - attr_accessor :compact_yard_threshold - # Override the directory under which generated RBS signature files are # written. When nil (the default) signatures go to # `/sig/.rbs`, where the project root is the diff --git a/lib/support_table_data/documentation/source_file.rb b/lib/support_table_data/documentation/source_file.rb index 73cdf27..40d2ce2 100644 --- a/lib/support_table_data/documentation/source_file.rb +++ b/lib/support_table_data/documentation/source_file.rb @@ -44,7 +44,9 @@ def source_without_yard_docs # @return [String] def source_with_yard_docs yard_docs = YardDoc.new(klass).named_instance_yard_docs - return source if yard_docs.nil? + if yard_docs.nil? + return has_yard_docs? ? source_without_yard_docs : source + end existing_yard_docs = source.match(YARD_COMMENT_REGEX) if existing_yard_docs diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index 9e04622..260caa5 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -12,17 +12,25 @@ def initialize(klass) @klass = klass end - # Generate YARD documentation for the model's helper methods. When the - # number of named instances exceeds SupportTableData.compact_yard_threshold - # the output uses YARD @!macro directives to keep the file size manageable; - # otherwise it emits a verbose comment block per method. + # Generate YARD documentation for the model's helper methods. The format + # is controlled by the model's `support_table_yard_docs` setting: # - # @return [String, nil] The YARD documentation, or nil if no named instances + # * `:full` - verbose comment block per method (default) + # * `:compact` - shared @!macro definitions plus a short @!method/@!macro + # pair per generated method + # * `:none` - generate no docs at all + # + # @return [String, nil] The YARD documentation, or nil if no docs should + # be emitted (either because the model has no named instances or because + # `support_table_yard_docs` is `:none`). def named_instance_yard_docs + return nil if klass.support_table_yard_docs == :none + instance_names = klass.instance_names return nil if instance_names.empty? - if instance_names.size > SupportTableData.compact_yard_threshold + case klass.support_table_yard_docs + when :compact generate_compact_yard_docs(instance_names) else generate_verbose_yard_docs(instance_names) diff --git a/spec/support_table_data/documentation/source_file_spec.rb b/spec/support_table_data/documentation/source_file_spec.rb index 4083f5a..69f12c5 100644 --- a/spec/support_table_data/documentation/source_file_spec.rb +++ b/spec/support_table_data/documentation/source_file_spec.rb @@ -187,6 +187,73 @@ class Color < ActiveRecord::Base end end + context "when the model declares support_table_yard_docs = :none" do + around do |example| + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :none + begin + example.run + ensure + Color.support_table_yard_docs = original + end + end + + it "strips existing YARD docs from the source" do + source_with_old_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Old YARD docs + end + # End YARD docs for support_table_data + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_old_docs) + + result = source_file.source_with_yard_docs + + expect(result).not_to include("# Begin YARD docs for support_table_data") + expect(result).not_to include("# Old YARD docs") + expect(result).to include("class Color < ActiveRecord::Base") + end + + it "leaves the source unchanged when no YARD docs are present" do + source_without_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_without_docs) + + expect(source_file.source_with_yard_docs).to eq(source_without_docs) + end + + it "reports yard_docs_up_to_date? false when stale docs remain" do + source_with_old_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Old YARD docs + end + # End YARD docs for support_table_data + end + RUBY + + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_old_docs) + + expect(source_file.yard_docs_up_to_date?).to be false + end + end + describe "#yard_docs_up_to_date?" do it "returns true when YARD docs match current generated docs" do source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) diff --git a/spec/support_table_data/documentation/yard_doc_spec.rb b/spec/support_table_data/documentation/yard_doc_spec.rb index fa61076..a815e8b 100644 --- a/spec/support_table_data/documentation/yard_doc_spec.rb +++ b/spec/support_table_data/documentation/yard_doc_spec.rb @@ -111,54 +111,51 @@ expect(green_pos).to be < red_pos end - context "when there are more named instances than the compact threshold" do + context "when the model declares support_table_yard_docs = :compact" do around do |example| - original = SupportTableData.compact_yard_threshold - SupportTableData.compact_yard_threshold = 5 + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :compact begin example.run ensure - SupportTableData.compact_yard_threshold = original + Color.support_table_yard_docs = original end end - it "emits the compact macro form for Hue (9 instances)" do - doc = SupportTableData::Documentation::YardDoc.new(Hue) + it "emits the compact macro form" do + doc = SupportTableData::Documentation::YardDoc.new(Color) result = doc.named_instance_yard_docs expect(result).not_to be_nil expect(result).to include("# @!group Named Instances") expect(result).to include("# @!endgroup") - # Macro definitions are emitted once. expect(result).to include("# @!macro [new] support_table_data_finder") expect(result).to include("# @!macro [new] support_table_data_predicate") - # Hue has no attribute helpers, but the attribute macro is still defined. expect(result).to include("# @!macro [new] support_table_data_attribute") expect(result.scan("# @!macro [new] support_table_data_finder").size).to eq(1) - # Per-instance entries reference the macros rather than repeating prose. expect(result).to include("# @!method self.red") expect(result).to include("# @!macro support_table_data_finder red") expect(result).to include("# @!method red?") expect(result).to include("# @!macro support_table_data_predicate red") expect(result).not_to include("# Find the named instance +red+ from the database.") end + end - it "uses the verbose form when count is at or below the threshold" do - SupportTableData.compact_yard_threshold = 4 - doc = SupportTableData::Documentation::YardDoc.new(Color) - result = doc.named_instance_yard_docs - - # Color has 4 instances; with threshold 4 we are NOT above it -> verbose. - expect(result).to include("# Find the named instance +red+ from the database.") - expect(result).not_to include("# @!macro [new]") + context "when the model declares support_table_yard_docs = :compact with attribute helpers" do + around do |example| + original = Group.support_table_yard_docs + Group.support_table_yard_docs = :compact + begin + example.run + ensure + Group.support_table_yard_docs = original + end end - it "includes attribute macro invocations with column-derived types" do + it "emits attribute macro invocations with column-derived types" do doc = SupportTableData::Documentation::YardDoc.new(Group) - # Group only has 3 instances normally; force compact form to test attribute output. - SupportTableData.compact_yard_threshold = 0 result = doc.named_instance_yard_docs # Group has attribute helpers for group_id (integer) and name (string). @@ -166,5 +163,42 @@ expect(result).to include("# @!macro support_table_data_attribute primary name String") end end + + context "when the model declares support_table_yard_docs = :none" do + around do |example| + original = Color.support_table_yard_docs + Color.support_table_yard_docs = :none + begin + example.run + ensure + Color.support_table_yard_docs = original + end + end + + it "returns nil even when the model has named instances" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + expect(doc.named_instance_yard_docs).to be_nil + end + end + end + + describe "Color.support_table_yard_docs=" do + it "rejects unknown values" do + expect { + Color.support_table_yard_docs = :verbose + }.to raise_error(ArgumentError, /support_table_yard_docs must be one of/) + end + + it "accepts :full, :compact, and :none" do + original = Color.support_table_yard_docs + begin + %i[full compact none].each do |value| + expect { Color.support_table_yard_docs = value }.not_to raise_error + expect(Color.support_table_yard_docs).to eq(value) + end + ensure + Color.support_table_yard_docs = original + end + end end end From 502fa0ebb4983d866dadd20278b8a3560b90f09c Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:30:03 -0700 Subject: [PATCH 3/8] update rake docs --- lib/tasks/support_table_data.rake | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index c9d56db..649f04f 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -21,6 +21,7 @@ namespace :support_table_data do end end + desc "Adds YARD documentation comments to all support table models to document the named instance methods." task yard_docs: "yard_docs:add" namespace :yard_docs do @@ -70,6 +71,7 @@ namespace :support_table_data do end end + desc "Generates RBS signature files for named instance methods in all support table models." task rbs: "rbs:add" namespace :rbs do From 47888964449bedbdf54cc1a32516fe5c04f39401 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:31:57 -0700 Subject: [PATCH 4/8] update rake doc in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c3c1ede..70f1245 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ completed: In a Rails application, you can add YARD documentation for the named instance helpers by running the rake task `support_table_data:yard_docs`. This will add YARD comments to your model classes for each of the named instance helper methods defined on the model. Adding this documentation will help IDEs provide better code completion and inline documentation for the helper methods and expose the methods to AI agents. -To update a single model file, pass an optional file path argument, for example: `bundle exec rake "support_table_data:yard_docs[app/models/status.rb]"`. +To update a single model file, use the `add` task and pass an optional file path argument, for example: `bundle exec rake "support_table_data:yard_docs:add[app/models/status.rb]"`. The default behavior is to add the documentation comments at the end of the model class by reopening the class definition. If you prefer to have the documentation comments appear elsewhere in the file, you can add the following markers to your model class and the YARD documentation will be inserted between these markers. @@ -228,7 +228,7 @@ When the setting is left at its default (`true`) and no connection is available, In addition to the inline YARD comments, you can generate [RBS](https://github.com/ruby/rbs) type signatures for the named instance helpers by running `bundle exec rake support_table_data:rbs`. This writes one `sig/.rbs` file per model — for example a Rails model at `app/models/feature.rb` produces `sig/app/models/feature.rbs`. The signatures are picked up by Ruby LSP, Steep, RubyMine, and any other RBS-aware tool, and they keep the model source files free of generated content. -Just like the YARD task, you can target a single file with `bundle exec rake "support_table_data:rbs[app/models/feature.rb]"`, verify they are up to date in CI with `support_table_data:rbs:verify`, and remove the generated files with `support_table_data:rbs:remove`. RBS generation is opt-in — running the YARD task alone does not produce RBS output, and vice versa. +Just like the YARD task, you can target a single file with `bundle exec rake "support_table_data:rbs:add[app/models/feature.rb]"`, verify they are up to date in CI with `support_table_data:rbs:verify`, and remove the generated files with `support_table_data:rbs:remove`. RBS generation is opt-in — running the YARD task alone does not produce RBS output, and vice versa. ### Caching From def9b717a9c22d612d37ee37988cf412ec6b539e Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:32:42 -0700 Subject: [PATCH 5/8] typo --- lib/tasks/support_table_data.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index 649f04f..7e4031e 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -1,7 +1,7 @@ # frozen_string_literal: true namespace :support_table_data do - desc "Syncronize data for all models that include SupportTableData." + desc "Synchronize data for all models that include SupportTableData." task sync: :environment do SupportTableData::Tasks::Utils.eager_load! From b5d11782bdeded9a517b97906ea779d11b1fbb00 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:44:46 -0700 Subject: [PATCH 6/8] require fileutils in spec --- spec/support_table_data/documentation/rbs_file_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/support_table_data/documentation/rbs_file_spec.rb b/spec/support_table_data/documentation/rbs_file_spec.rb index 259f71b..846e5b9 100644 --- a/spec/support_table_data/documentation/rbs_file_spec.rb +++ b/spec/support_table_data/documentation/rbs_file_spec.rb @@ -2,6 +2,7 @@ require "spec_helper" require "tmpdir" +require "fileutils" RSpec.describe SupportTableData::Documentation::RbsFile do let(:color_path) { Pathname.new(File.expand_path("../../models/color.rb", __dir__)) } From 2e0835b329afc12d141ad73bcb38e2cfbd4ceb23 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 13:53:55 -0700 Subject: [PATCH 7/8] make file paths for rake tasks relative to current directory --- lib/support_table_data/tasks/utils.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/support_table_data/tasks/utils.rb b/lib/support_table_data/tasks/utils.rb index 40008e5..c552bd6 100644 --- a/lib/support_table_data/tasks/utils.rb +++ b/lib/support_table_data/tasks/utils.rb @@ -23,7 +23,8 @@ def eager_load! # @param file_path [String, Pathname, nil] Optional file path to filter by. # @return [Array] def support_table_sources(file_path = nil) - require_relative file_path if file_path + file_path = Pathname.new(file_path) if file_path.is_a?(String) + require file_path.expand_path if file_path sources = [] From 5c58fa951333434f161717323776a0a2db0be6f2 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 8 May 2026 14:31:23 -0700 Subject: [PATCH 8/8] Get type for documentation from YAML values --- CHANGELOG.md | 2 +- README.md | 8 +- lib/support_table_data.rb | 13 --- .../documentation/rbs_doc.rb | 2 +- .../documentation/type_inference.rb | 78 +++++++------ .../documentation/yard_doc.rb | 8 +- .../documentation_connection_error.rb | 25 ----- .../documentation/type_inference_spec.rb | 106 ++++++------------ 8 files changed, 84 insertions(+), 158 deletions(-) delete mode 100644 lib/support_table_data/documentation_connection_error.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d8080..c59afb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `delete_missing` option to `sync_table_data!` and `sync_all!`. When set to `true`, any records in the database that are not defined in the data files will be deleted. This option defaults to `false` to preserve backward compatibility. - Each model can now choose how its YARD docs are generated by setting `self.support_table_yard_docs` to `:full` (the default — verbose comment block per method), `:compact` (shared `@!macro` definitions plus a short `@!method`/`@!macro` pair per method, dramatically reducing comment-block size on tables with many named instances), or `:none` (skip generation entirely; previously generated docs are stripped on the next run). IDEs and `yard doc` resolve the compact form into the same per-method documentation as the verbose form. -- The attribute helper YARD comment now uses the underlying ActiveRecord column type (`String`, `Integer`, `Boolean`, etc.) for `@return` instead of the generic `Object`. This requires a database connection at generation time; set `SupportTableData.infer_documentation_types = false` to opt out and fall back to generic types. When type inference is enabled but no connection is available, the tasks now raise a clear `SupportTableData::DocumentationConnectionError` rather than failing with a low-level ActiveRecord error. +- Attribute helper return types in the generated YARD and RBS docs are now inferred per method by inspecting the value the helper actually returns (`String`, `Integer`, `Boolean`, etc.) instead of the generic `Object`/`untyped`. Because the helpers return frozen literals from the parsed data file, the documentation tasks no longer require a database connection. - Added opt-in RBS signature generation. The new tasks `support_table_data:rbs`, `support_table_data:rbs:verify`, and `support_table_data:rbs:remove` write/check/delete `sig/.rbs` files so the named instance helpers are visible to Ruby LSP, Steep, RubyMine, and other RBS-aware tools without polluting the model source files. The output directory can be overridden with `SupportTableData.rbs_signatures_path`. ## 1.5.2 diff --git a/README.md b/README.md index 3d08ba0..535693f 100644 --- a/README.md +++ b/README.md @@ -216,13 +216,7 @@ class Feature < ApplicationRecord end ``` -The generator reads the underlying ActiveRecord column types so attribute helpers get specific return types (`String`, `Integer`, `Boolean`, etc.) in their documentation. This requires a working database connection at generation time. If the documentation tasks need to run in an environment without a database (for example a lint-only CI job), disable the lookup and the docs will fall back to generic `Object` / `untyped` return types: - -```ruby -SupportTableData.infer_documentation_types = false -``` - -When the setting is left at its default (`true`) and no connection is available, the tasks raise `SupportTableData::DocumentationConnectionError` with instructions for both resolution paths so behavior is consistent across local and CI runs. +Attribute helper return types (`String`, `Integer`, `Boolean`, etc.) are inferred per method by inspecting the value the helper actually returns. The values come straight from the parsed data file, so the documentation tasks do not need a database connection to run. #### Generating RBS Signatures diff --git a/lib/support_table_data.rb b/lib/support_table_data.rb index 6890bea..8e4dfd4 100644 --- a/lib/support_table_data.rb +++ b/lib/support_table_data.rb @@ -9,7 +9,6 @@ module SupportTableData extend ActiveSupport::Concern autoload :ValidationError, File.expand_path("support_table_data/validation_error", __dir__) - autoload :DocumentationConnectionError, File.expand_path("support_table_data/documentation_connection_error", __dir__) autoload :Documentation, File.expand_path("support_table_data/documentation", __dir__) autoload :Tasks, File.expand_path("support_table_data/tasks", __dir__) @@ -17,7 +16,6 @@ module SupportTableData @data_directory = nil @rbs_signatures_path = nil - @infer_documentation_types = true included do # Internal variables used for memoization. @@ -435,17 +433,6 @@ def data_directory=(value) # @return [String, Pathname, nil] attr_accessor :rbs_signatures_path - # When true (the default) the documentation generators read ActiveRecord - # column types so that attribute helper return types are real types like - # `String` or `Integer`. This requires a working database connection at - # generation time and will raise SupportTableData::DocumentationConnectionError - # if one is not available. Set to false to skip the database lookup and emit - # generic Object/untyped return types instead — useful for CI jobs that - # cannot or do not want to provision a database. - # - # @return [Boolean] - attr_accessor :infer_documentation_types - # Sync all support table classes. Classes must already be loaded in order to be synced. # # You can pass in a list of classes that you want to ensure are synced. This feature diff --git a/lib/support_table_data/documentation/rbs_doc.rb b/lib/support_table_data/documentation/rbs_doc.rb index 433fc7f..70db651 100644 --- a/lib/support_table_data/documentation/rbs_doc.rb +++ b/lib/support_table_data/documentation/rbs_doc.rb @@ -42,7 +42,7 @@ def instance_signatures(name) lines << "def self.#{name}: () -> #{klass.name}" lines << "def #{name}?: () -> bool" klass.support_table_attribute_helpers.each do |attribute_name| - return_type = TypeInference.rbs_type(TypeInference.column_type(klass, attribute_name)) + return_type = TypeInference.rbs_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}")) lines << "def self.#{name}_#{attribute_name}: () -> #{return_type}" end lines diff --git a/lib/support_table_data/documentation/type_inference.rb b/lib/support_table_data/documentation/type_inference.rb index 25583c5..e30bc9e 100644 --- a/lib/support_table_data/documentation/type_inference.rb +++ b/lib/support_table_data/documentation/type_inference.rb @@ -2,56 +2,54 @@ module SupportTableData module Documentation - # Maps ActiveRecord column types to documentation type strings. + # Infers documentation types for the dynamically-defined attribute helpers + # by calling the generated method and inspecting the class of the value + # it returns. The values returned by these helpers are frozen literals from + # the parsed data file, so this does not require a database connection. + # + # This module must not be used on finder helpers (e.g. `Color.red`), which + # call `find_by!` and would hit the database. module TypeInference module_function - # Look up the ActiveRecord column type symbol for an attribute, or nil if - # type inference is disabled, the class does not expose columns_hash, or - # the column is not defined on the table (e.g. virtual attributes). + # Determine the documentation type for an attribute helper by calling + # the method and looking at the class of the returned value. Returns + # nil when the method is not defined. # - # Raises SupportTableData::DocumentationConnectionError when the lookup - # fails because no database connection is available and - # SupportTableData.infer_documentation_types is true. - def column_type(klass, attribute_name) - return nil unless SupportTableData.infer_documentation_types - return nil unless klass.respond_to?(:columns_hash) + # @param klass [Class] The model class + # @param method_name [String, Symbol] The class method name to call + # @return [Class, nil] + def value_type(klass, method_name) + return nil unless klass.respond_to?(method_name) - column = klass.columns_hash[attribute_name.to_s] - column&.type - rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid => e - raise SupportTableData::DocumentationConnectionError.new(klass, e) + klass.public_send(method_name).class end - # Map a column type symbol to a YARD type string. - def yard_type(column_type) - case column_type - when :string, :text, :uuid, :inet, :cidr then "String" - when :integer, :bigint then "Integer" - when :decimal then "BigDecimal" - when :float then "Float" - when :boolean then "Boolean" - when :date then "Date" - when :datetime, :timestamp, :time then "Time" - when :json, :jsonb, :hstore then "Hash" - when :binary then "String" - else "Object" + # Map a Ruby value class to a YARD type string. + def yard_type(value_class) + case value_class&.name + when "String" then "String" + when "Integer" then "Integer" + when "Float" then "Float" + when "TrueClass", "FalseClass" then "Boolean" + when "Array" then "Array" + when "Hash" then "Hash" + when "NilClass", nil then "Object" + else value_class.name end end - # Map a column type symbol to an RBS type string. - def rbs_type(column_type) - case column_type - when :string, :text, :uuid, :inet, :cidr then "String" - when :integer, :bigint then "Integer" - when :decimal then "BigDecimal" - when :float then "Float" - when :boolean then "bool" - when :date then "Date" - when :datetime, :timestamp, :time then "Time" - when :json, :jsonb, :hstore then "Hash[untyped, untyped]" - when :binary then "String" - else "untyped" + # Map a Ruby value class to an RBS type string. + def rbs_type(value_class) + case value_class&.name + when "String" then "String" + when "Integer" then "Integer" + when "Float" then "Float" + when "TrueClass", "FalseClass" then "bool" + when "Array" then "Array[untyped]" + when "Hash" then "Hash[untyped, untyped]" + when "NilClass", nil then "untyped" + else value_class.name end end end diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index 260caa5..cce9edd 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -72,7 +72,7 @@ def predicate_helper_yard_doc(name) # @param attribute_name [String] The attribute being read. # @return [String] The YARD comment text def attribute_helper_yard_doc(name, attribute_name) - return_type = TypeInference.yard_type(TypeInference.column_type(klass, attribute_name)) + return_type = attribute_yard_return_type(name, attribute_name) <<~YARD.chomp("\n") # Get the #{attribute_name} attribute from the data file # for the named instance +#{name}+. @@ -167,12 +167,16 @@ def compact_instance_block(name) lines << "# @!method #{name}?" lines << "# @!macro #{MACRO_PREDICATE} #{name}" klass.support_table_attribute_helpers.each do |attribute_name| - return_type = TypeInference.yard_type(TypeInference.column_type(klass, attribute_name)) + return_type = attribute_yard_return_type(name, attribute_name) lines << "# @!method self.#{name}_#{attribute_name}" lines << "# @!macro #{MACRO_ATTRIBUTE} #{name} #{attribute_name} #{return_type}" end lines.join("\n") end + + def attribute_yard_return_type(name, attribute_name) + TypeInference.yard_type(TypeInference.value_type(klass, "#{name}_#{attribute_name}")) + end end end end diff --git a/lib/support_table_data/documentation_connection_error.rb b/lib/support_table_data/documentation_connection_error.rb deleted file mode 100644 index 69a7b37..0000000 --- a/lib/support_table_data/documentation_connection_error.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module SupportTableData - # Raised when documentation generation needs to read column types from the - # database to fill in `@return` tags / RBS signatures, but no database - # connection is available. Users who run the documentation tasks in - # environments without a database (e.g. a lint-only CI job) can opt out by - # setting `SupportTableData.infer_documentation_types = false`. - class DocumentationConnectionError < StandardError - def initialize(klass, original) - message = <<~MSG.strip - Could not load column types for #{klass.name} from the database while generating documentation (#{original.class}: #{original.message}). - - Documentation generation reads ActiveRecord column types so the produced docs use specific return types (String, Integer, Boolean, ...) instead of generic Object/untyped. - - To resolve, either: - 1. Ensure a database connection is available when running the documentation tasks (e.g. run `bin/rails db:prepare` or set DATABASE_URL). - 2. Disable type inference globally: - SupportTableData.infer_documentation_types = false - The generated docs will fall back to generic Object/untyped return types. - MSG - super(message) - end - end -end diff --git a/spec/support_table_data/documentation/type_inference_spec.rb b/spec/support_table_data/documentation/type_inference_spec.rb index 894de92..4821494 100644 --- a/spec/support_table_data/documentation/type_inference_spec.rb +++ b/spec/support_table_data/documentation/type_inference_spec.rb @@ -3,93 +3,61 @@ require "spec_helper" RSpec.describe SupportTableData::Documentation::TypeInference do - describe ".column_type" do - it "returns the AR column type symbol for a known column" do - expect(described_class.column_type(Group, "name")).to eq(:string) - expect(described_class.column_type(Group, "group_id")).to eq(:integer) + describe ".value_type" do + it "returns the class of the value returned by the helper method" do + # Group has named_instance_attribute_helpers :group_id, :name + expect(described_class.value_type(Group, "primary_name")).to eq(String) + expect(described_class.value_type(Group, "primary_group_id")).to eq(Integer) end - it "returns nil for an unknown column" do - expect(described_class.column_type(Group, "not_a_real_column")).to be_nil + it "returns nil when the method is not defined" do + expect(described_class.value_type(Group, "not_a_real_method")).to be_nil end - it "returns nil for a class that does not respond to columns_hash" do - klass = Class.new - expect(described_class.column_type(klass, "anything")).to be_nil - end - - context "when SupportTableData.infer_documentation_types is false" do - around do |example| - original = SupportTableData.infer_documentation_types - SupportTableData.infer_documentation_types = false - begin - example.run - ensure - SupportTableData.infer_documentation_types = original - end - end - - it "returns nil without consulting the database" do - expect(Group).not_to receive(:columns_hash) - expect(described_class.column_type(Group, "name")).to be_nil - end - end - - context "when no database connection is available" do - it "raises a DocumentationConnectionError with resolution guidance" do - allow(Group).to receive(:columns_hash) - .and_raise(ActiveRecord::ConnectionNotEstablished, "No connection pool") - - expect { - described_class.column_type(Group, "name") - }.to raise_error(SupportTableData::DocumentationConnectionError) do |error| - expect(error.message).to include("Group") - expect(error.message).to include("ConnectionNotEstablished") - expect(error.message).to include("infer_documentation_types = false") - end - end - - it "returns nil silently when type inference is disabled" do - original = SupportTableData.infer_documentation_types - SupportTableData.infer_documentation_types = false - begin - # columns_hash is not even called; nothing to raise. - expect(described_class.column_type(Group, "name")).to be_nil - ensure - SupportTableData.infer_documentation_types = original - end - end + it "does not call the database for finder methods" do + # Sanity check: this confirms we are calling literal-returning helpers, + # not finder methods. Calling Group.primary would hit the DB; we should + # never invoke value_type on it. + expect(Group).not_to receive(:primary) + described_class.value_type(Group, "primary_name") end end describe ".yard_type" do - it "maps common column types to documentation strings" do - expect(described_class.yard_type(:string)).to eq("String") - expect(described_class.yard_type(:integer)).to eq("Integer") - expect(described_class.yard_type(:boolean)).to eq("Boolean") - expect(described_class.yard_type(:date)).to eq("Date") - expect(described_class.yard_type(:datetime)).to eq("Time") - expect(described_class.yard_type(:json)).to eq("Hash") + it "maps common value classes to YARD type strings" do + expect(described_class.yard_type(String)).to eq("String") + expect(described_class.yard_type(Integer)).to eq("Integer") + expect(described_class.yard_type(Float)).to eq("Float") + expect(described_class.yard_type(TrueClass)).to eq("Boolean") + expect(described_class.yard_type(FalseClass)).to eq("Boolean") + expect(described_class.yard_type(Array)).to eq("Array") + expect(described_class.yard_type(Hash)).to eq("Hash") end - it "falls back to Object for nil or unknown types" do + it "falls back to Object for nil or NilClass" do expect(described_class.yard_type(nil)).to eq("Object") - expect(described_class.yard_type(:something_exotic)).to eq("Object") + expect(described_class.yard_type(NilClass)).to eq("Object") + end + + it "uses the class name for other classes" do + expect(described_class.yard_type(Date)).to eq("Date") end end describe ".rbs_type" do - it "maps common column types to RBS type strings" do - expect(described_class.rbs_type(:string)).to eq("String") - expect(described_class.rbs_type(:integer)).to eq("Integer") - expect(described_class.rbs_type(:boolean)).to eq("bool") - expect(described_class.rbs_type(:date)).to eq("Date") - expect(described_class.rbs_type(:datetime)).to eq("Time") + it "maps common value classes to RBS type strings" do + expect(described_class.rbs_type(String)).to eq("String") + expect(described_class.rbs_type(Integer)).to eq("Integer") + expect(described_class.rbs_type(Float)).to eq("Float") + expect(described_class.rbs_type(TrueClass)).to eq("bool") + expect(described_class.rbs_type(FalseClass)).to eq("bool") + expect(described_class.rbs_type(Array)).to eq("Array[untyped]") + expect(described_class.rbs_type(Hash)).to eq("Hash[untyped, untyped]") end - it "falls back to untyped for nil or unknown types" do + it "falls back to untyped for nil or NilClass" do expect(described_class.rbs_type(nil)).to eq("untyped") - expect(described_class.rbs_type(:something_exotic)).to eq("untyped") + expect(described_class.rbs_type(NilClass)).to eq("untyped") end end end