From a22dc469b9b582a04ea1b70599b1d36028de0970 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Fri, 2 Jan 2026 23:18:43 -0800 Subject: [PATCH 01/11] Add rake task to generate YARD docs --- .github/workflows/continuous_integration.yml | 2 + AGENTS.md | 140 +++++++++++++++++ Appraisals | 5 + CHANGELOG.md | 7 + VERSION | 2 +- gemfiles/activerecord_8.1.gemfile | 15 ++ lib/support_table_data/documentation.rb | 88 +++++++++++ lib/support_table_data/railtie.rb | 6 + lib/tasks/support_table_data.rake | 46 ++++-- lib/tasks/utils.rb | 38 +++++ spec/models.rb | 147 ++---------------- spec/models/alias.rb | 7 + spec/models/color.rb | 41 +++++ spec/models/group.rb | 15 ++ spec/models/hue.rb | 26 ++++ spec/models/invalid.rb | 11 ++ spec/models/polygon.rb | 11 ++ spec/models/rectangle.rb | 5 + spec/models/shade.rb | 14 ++ spec/models/shade_hue.rb | 6 + spec/models/thing.rb | 6 + spec/models/triangle.rb | 5 + spec/support_table_data/documentation_spec.rb | 107 +++++++++++++ test_app/.gitignore | 4 + test_app/Gemfile | 7 + test_app/Rakefile | 6 + test_app/app/models/status.rb | 93 +++++++++++ test_app/bin/rails | 4 + test_app/config.ru | 6 + test_app/config/application.rb | 42 +++++ test_app/config/boot.rb | 3 + test_app/config/database.yml | 3 + test_app/config/environment.rb | 5 + test_app/config/environments/development.rb | 11 ++ .../migrate/20260103060951_create_status.rb | 8 + test_app/db/schema.rb | 20 +++ test_app/db/support_tables/statuses.yml | 19 +++ test_app/log/.keep | 0 38 files changed, 833 insertions(+), 148 deletions(-) create mode 100644 AGENTS.md create mode 100644 gemfiles/activerecord_8.1.gemfile create mode 100644 lib/support_table_data/documentation.rb create mode 100644 lib/tasks/utils.rb create mode 100644 spec/models/alias.rb create mode 100644 spec/models/color.rb create mode 100644 spec/models/group.rb create mode 100644 spec/models/hue.rb create mode 100644 spec/models/invalid.rb create mode 100644 spec/models/polygon.rb create mode 100644 spec/models/rectangle.rb create mode 100644 spec/models/shade.rb create mode 100644 spec/models/shade_hue.rb create mode 100644 spec/models/thing.rb create mode 100644 spec/models/triangle.rb create mode 100644 spec/support_table_data/documentation_spec.rb create mode 100644 test_app/.gitignore create mode 100644 test_app/Gemfile create mode 100644 test_app/Rakefile create mode 100644 test_app/app/models/status.rb create mode 100755 test_app/bin/rails create mode 100644 test_app/config.ru create mode 100644 test_app/config/application.rb create mode 100644 test_app/config/boot.rb create mode 100644 test_app/config/database.yml create mode 100644 test_app/config/environment.rb create mode 100644 test_app/config/environments/development.rb create mode 100644 test_app/db/migrate/20260103060951_create_status.rb create mode 100644 test_app/db/schema.rb create mode 100644 test_app/db/support_tables/statuses.yml create mode 100644 test_app/log/.keep diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 55b9eab..9cc10e3 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -28,6 +28,8 @@ jobs: - ruby: "ruby" standardrb: true yard: true + - ruby: "4.0" + appraisal: "activerecord_8.1" - ruby: "3.4" appraisal: "activerecord_8.0" - ruby: "3.2" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..028db65 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,140 @@ +# Copilot Instructions for support_table_data + +## Project Overview + +A Ruby gem providing an ActiveRecord mixin for managing support/lookup tables with canonical data defined in YAML/JSON/CSV files. The gem dynamically generates helper methods to reference specific records naturally in code (e.g., `Status.pending` instead of `Status.find_by(name: 'Pending')`). + +**Core concept**: Support tables blur the line between data and code—they contain small canonical datasets that must exist for the application to work. + +## Architecture + +### Key Components + +- **`SupportTableData` module** ([lib/support_table_data.rb](lib/support_table_data.rb)): Main concern mixed into ActiveRecord models +- **Named instance system**: Dynamically generates class methods (`.pending`), predicate methods (`.pending?`), and attribute helpers (`.pending_id`) from hash-based data files +- **Data sync engine**: Compares canonical data files with database records, creating/updating as needed in atomic transactions +- **File parsers**: Supports YAML, JSON, and CSV formats with unified interface + +### Data Flow + +1. Data files (YAML/JSON/CSV) define canonical records with unique key attributes +2. `add_support_table_data` registers file paths and triggers method generation for hash-based files +3. `sync_table_data!` parses files, loads matching DB records, and updates/creates within transactions +4. Named instance methods are dynamically defined via `class_eval` with memoization + +## Development Workflows + +### Running Tests + +```bash +bundle exec rspec # Run all specs +bundle exec rspec spec/support_table_data_spec.rb # Single file +bundle exec rake appraisals # Test against all ActiveRecord versions +``` + +Uses RSpec with in-memory SQLite database. Test models defined in [spec/models.rb](spec/models.rb), data files in `spec/data/`. + +### Testing Against Multiple ActiveRecord Versions + +The gem supports ActiveRecord 6.0-8.0. Uses Appraisal for multi-version testing: + +```bash +bundle exec appraisal install # Install all gemfiles +bundle exec appraisal rspec # Run specs against all versions +``` + +See `Appraisals` file and `gemfiles/` directory. + +### Code Style + +Uses Standard Ruby formatter: + +```bash +bundle exec rake standard:fix # Auto-fix style issues +``` + +## Critical Patterns + +### Named Instance Method Generation + +**Hash-based data files** trigger dynamic method generation. Example from [spec/data/colors/named_colors.yml](spec/data/colors/named_colors.yml): + +```yaml +red: + id: 1 + name: Red + value: 16711680 +``` + +Generates: +- `Color.red` → finds record by id +- `color_instance.red?` → tests if `color_instance.id == 1` +- `Color.red_id` → returns `1` (if `named_instance_attribute_helpers :id` defined) + +**Implementation**: See `define_support_table_named_instance_methods` in [lib/support_table_data.rb](lib/support_table_data.rb#L230-L265). Methods are generated using `class_eval` with string interpolation. + +### Custom Setters for Associations + +Support tables often reference other support tables via named instances. Pattern from [spec/models.rb](spec/models.rb#L72-L74): + +```ruby +def group_name=(value) + self.group = Group.named_instance(value) +end +``` + +Allows data files to reference related records by instance name instead of foreign keys. + +### Key Attribute Configuration + +By default, uses model's `primary_key`. Override for non-id keys: + +```ruby +self.support_table_key_attribute = :name # Use 'name' instead of 'id' +``` + +Key attributes cannot be updated—changing them creates new records. + +### Dependency Resolution + +`sync_all!` automatically resolves dependencies via `belongs_to` associations and loads tables in correct order. For complex cases (join tables, indirect dependencies), explicitly declare: + +```ruby +support_table_dependency "OtherModel" +``` + +See [lib/support_table_data.rb](lib/support_table_data.rb#L219-L222) and dependency resolution logic. + +## Testing Conventions + +- **Test data isolation**: Each test deletes all records in `before` block ([spec/spec_helper.rb](spec/spec_helper.rb)) +- **Sync before assertions**: Tests call `sync_table_data!` or `sync_all!` before verifying records exist +- **Multi-file merging**: Tests verify that multiple data files for same model merge correctly (see `Color` model with 5 data files) +- **STI handling**: See `Polygon`/`Triangle`/`Rectangle` tests for Single Table Inheritance patterns + +## Common Pitfalls + +1. **Method name conflicts**: Named instance methods raise `ArgumentError` if method already exists. Instance names must match `/\A[a-z][a-z0-9_]+\z/` +2. **Array vs hash data**: Only hash-keyed data generates named instance methods. Use arrays or underscore-prefixed keys (`_others`) for records without helpers +3. **Protected instances**: Records in data files cannot be deleted via `destroy` (though this gem doesn't enforce it—see companion caching gem) +4. **Transaction safety**: All sync operations wrapped in transactions; changes rollback on failure + +## Rails Integration + +In Rails apps, the gem automatically: +- Sets `SupportTableData.data_directory` to `Rails.root/db/support_tables` +- Provides `rake support_table_data:sync` task ([lib/tasks/support_table_data.rake](lib/tasks/support_table_data.rake)) +- Handles eager loading in both classic and Zeitwerk autoloaders + +## File References + +- Main module: [lib/support_table_data.rb](lib/support_table_data.rb) +- Test models: [spec/models.rb](spec/models.rb) - comprehensive examples of patterns +- Sync task: [lib/tasks/support_table_data.rake](lib/tasks/support_table_data.rake) +- Architecture docs: [ARCHITECTURE.md](ARCHITECTURE.md) - detailed diagrams and design decisions + +## Version Compatibility + +- Ruby ≥ 2.5 +- ActiveRecord ≥ 6.0 +- Ruby 3.4+: Requires `csv` gem in Gemfile (removed from stdlib) diff --git a/Appraisals b/Appraisals index 52818d3..379eaaf 100644 --- a/Appraisals +++ b/Appraisals @@ -1,5 +1,10 @@ # frozen_string_literal: true +appraise "activerecord_8.1" do + gem "activerecord", "~> 8.1.0" + gem "sqlite3", "~> 2.9.0" +end + appraise "activerecord_8.0" do gem "activerecord", "~> 8.0.0" gem "sqlite3", "~> 2.5.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index cd2e099..15c6e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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.4.1 + +### Added + +- The default data directory for support table data in Rails applications will be set to `db/support_tables`. This can also be overridden by setting the `config.support_table_data_directory` configuration option in the Rails application. +- Added rake task `support_table_data:add_yard_docs` for Rails applications that will add YARD documentation to support table models for the named instance helpers. + ## 1.4.0 ### Fixed diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/gemfiles/activerecord_8.1.gemfile b/gemfiles/activerecord_8.1.gemfile new file mode 100644 index 0000000..0e7ee30 --- /dev/null +++ b/gemfiles/activerecord_8.1.gemfile @@ -0,0 +1,15 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rspec", "~> 3.0" +gem "rake" +gem "sqlite3", "~> 2.9.0" +gem "appraisal" +gem "standard", "~>1.0" +gem "pry-byebug" +gem "yard" +gem "csv" +gem "activerecord", "~> 8.1.0" + +gemspec path: "../" diff --git a/lib/support_table_data/documentation.rb b/lib/support_table_data/documentation.rb new file mode 100644 index 0000000..31fbcc7 --- /dev/null +++ b/lib/support_table_data/documentation.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SupportTableData + class Documentation + # Create a new documentation generator for a configuration class. + # + # @param config_class [Class] The configuration class to generate documentation for + def initialize(klass) + @klass = klass + end + + # Generate YARD documentation class definition for the model's helper methods. + # + # @return [String, nil] The YARD documentation class definition, or nil if no named instances + def class_def_with_yard_docs + instance_names = klass.instance_names + return nil if instance_names.empty? + + generate_yard_class(instance_names) + end + + # Generate YARD documentation comment for named instance singleton method. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def instance_helper_yard_doc(name) + <<~YARD + # Find the #{name} record from the database. + # + # @return [#{klass.name}] the #{name} record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.#{name} + YARD + end + + # Generate YARD documentation comment for the predicate method for the named instance. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def predicate_helper_yard_doc(name) + <<~YARD + # Check if this record is the #{name} record. + # + # @return [Boolean] true if this is the #{name} record, false otherwise + # @!method #{name}? + YARD + end + + # Generate YARD documentation comment for the attribute method helper for the named instance. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def attribute_helper_yard_doc(name, attribute_name) + <<~YARD + # Get the #{name} record's #{attribute_name}. + # + # @return [Object] the #{name} record's #{attribute_name} + # @!method #{name}_#{attribute_name} + YARD + end + + private + + attr_reader :klass + + def generate_yard_class(instance_names) + return nil if instance_names.empty? + + yard_lines = ["class #{klass.name}"] + + # Generate docs for each named instance + instance_names.sort.each_with_index do |name, index| + yard_lines << "" unless index.zero? + instance_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" } + yard_lines << "" + predicate_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" } + klass.support_table_attribute_helpers.each do |attribute_name| + yard_lines << "" + attribute_helper_yard_doc(name, attribute_name).each_line(chomp: true) { |line| yard_lines << " #{line}" } + end + end + + yard_lines << "end" + + yard_lines.join("\n") + end + end +end diff --git a/lib/support_table_data/railtie.rb b/lib/support_table_data/railtie.rb index fd31e1e..afe911e 100644 --- a/lib/support_table_data/railtie.rb +++ b/lib/support_table_data/railtie.rb @@ -2,6 +2,12 @@ module SupportTableData class Railtie < Rails::Railtie + config.support_table_data_directory = "db/support_tables" + + initializer "support_table_data" do |app| + SupportTableData.data_directory ||= app.config.support_table_data_directory + end + rake_tasks do load File.expand_path("../tasks/support_table_data.rake", __dir__) end diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index f21ed44..c80280d 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -3,18 +3,9 @@ namespace :support_table_data do desc "Syncronize data for all models that include SupportTableData." task sync: :environment do - # Eager load models if we are in a Rails enviroment with eager loading turned off. - if defined?(Rails.application) - unless Rails.application.config.eager_load - if defined?(Rails.application.eager_load!) - Rails.application.eager_load! - elsif defined?(Rails.autoloaders.zeitwerk_enabled?) && Rails.autoloaders.zeitwerk_enabled? - Rails.autoloaders.each(&:eager_load) - else - warn "Could not eager load models; some support table data may not load" - end - end - end + require_relative "utils" + + SupportTableData::Tasks::Utils.eager_load! logger_callback = lambda do |name, started, finished, unique_id, payload| klass = payload[:class] @@ -31,4 +22,35 @@ namespace :support_table_data do SupportTableData.sync_all! end end + + desc "Adds YARD documentation comments to models to document the named instance methods." + task add_yard_docs: :environment do + require_relative "../support_table_data/documentation" + require_relative "utils" + + SupportTableData::Tasks::Utils.eager_load! + + ActiveRecord::Base.descendants.each do |klass| + next unless klass.included_modules.include?(SupportTableData) + next if klass.instance_names.empty? + + doc = SupportTableData::Documentation.new(klass) + class_def_with_docs = doc.class_def_with_yard_docs + next unless class_def_with_docs + + file_path = SupportTableData::Tasks::Utils.model_file_path(klass) + next unless file_path&.file? && file_path.readable? + + begin_comment = "# Begin autogenerated YARD docs" + end_comment = "# End autogenerated YARD docs" + + file_contents = File.read(file_path) + updated_contents = file_contents.sub(/#{begin_comment}.*#{end_comment}/m, "").strip + updated_contents = "#{updated_contents}\n\n#{begin_comment}\n#{class_def_with_docs}\n#{end_comment}\n" + next if file_contents == updated_contents + + File.write(file_path, updated_contents) + puts "Added YARD documentation to #{klass.name}." + end + end end diff --git a/lib/tasks/utils.rb b/lib/tasks/utils.rb new file mode 100644 index 0000000..942bae0 --- /dev/null +++ b/lib/tasks/utils.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SupportTableData + module Tasks + module Utils + class << self + # Helper for eager loading a Rails application. + def eager_load! + return unless defined?(Rails.application.config.eager_load) + return if Rails.application.config.eager_load + + if defined?(Rails.application.eager_load!) + Rails.application.eager_load! + elsif defined?(Rails.autoloaders.zeitwerk_enabled?) && Rails.autoloaders.zeitwerk_enabled? + Rails.autoloaders.each(&:eager_load) + else + raise "Failed to eager load application." + end + end + + def model_file_path(klass) + file_path = "#{klass.name.underscore}.rb" + model_path = nil + + Rails.application.config.paths["app/models"].each do |path_prefix| + path = Pathname.new(path_prefix.to_s).join(file_path) + if path&.file? && path.readable? + model_path = path + break + end + end + + model_path + end + end + end + end +end diff --git a/spec/models.rb b/spec/models.rb index 6fed774..706eb2b 100644 --- a/spec/models.rb +++ b/spec/models.rb @@ -48,138 +48,15 @@ end end -class Color < ActiveRecord::Base - include SupportTableData - - self.support_table_data_directory = File.join(__dir__, "data", "colors") - add_support_table_data "named_colors.yml" - add_support_table_data "named_colors.json" - add_support_table_data "colors.yml" - add_support_table_data File.join(__dir__, "data", "colors", "colors.json") - add_support_table_data "colors.csv" - - belongs_to :group - belongs_to :hue - has_many :things - has_many :shades, through: :things - has_many :aliases, autosave: true - - # Intentionally invalid association - belongs_to :non_existent, class_name: "NonExistent" - - validates_uniqueness_of :name - - def group_name=(value) - self.group = Group.named_instance(value) - end - - def hue_name=(value) - self.hue = Hue.find_by!(name: value) - end - - def alias_names=(names) - self.aliases = names.map { |name| Alias.find_or_initialize_by(name: name) } - end - - private - - def hex=(value) - self.value = value.to_i(16) - end -end - -class Alias < ActiveRecord::Base - belongs_to :color - - validates_uniqueness_of :name -end - -class Group < ActiveRecord::Base - include SupportTableData - - self.primary_key = :group_id - - named_instance_attribute_helpers :group_id - - add_support_table_data "groups.yml" - - named_instance_attribute_helpers :name - - validates_uniqueness_of :name -end - -class Hue < ActiveRecord::Base - include SupportTableData - - self.support_table_key_attribute = :name - - add_support_table_data "hues.yml" - - belongs_to :parent, class_name: "Hue", optional: true - - validates_uniqueness_of :name - - def parent_name=(value) - self.parent = Hue.find_by!(name: value) - end - - has_many :shade_hues - has_many :shades, through: :shade_hues, autosave: true - - support_table_dependency "Shade" - - def shade_names=(names) - self.shades = Shade.where(name: names) - end -end - -class Shade < ActiveRecord::Base - include SupportTableData - - self.support_table_key_attribute = :name - - add_support_table_data "shades.yml" - - validates_uniqueness_of :name - - has_many :shade_hues - has_many :hues, through: :shade_hues -end - -class ShadeHue < ActiveRecord::Base - belongs_to :shade - belongs_to :hue -end - -class Thing < ActiveRecord::Base - belongs_to :color - belongs_to :shade -end - -class Invalid < ActiveRecord::Base - include SupportTableData - - self.support_table_key_attribute = :name - - def already_defined? - true - end -end - -class Polygon < ActiveRecord::Base - include SupportTableData - - self.support_table_key_attribute = :name - - add_support_table_data "polygons.yml" - - validates :name, uniqueness: true -end - -class Triangle < Polygon - validates :side_count, numericality: {equal_to: 3} -end - -class Rectangle < Polygon - validates :side_count, numericality: {equal_to: 4} -end +# Lazy load model classes +autoload :Alias, File.expand_path("models/alias.rb", __dir__) +autoload :Color, File.expand_path("models/color.rb", __dir__) +autoload :Group, File.expand_path("models/group.rb", __dir__) +autoload :Hue, File.expand_path("models/hue.rb", __dir__) +autoload :Invalid, File.expand_path("models/invalid.rb", __dir__) +autoload :Polygon, File.expand_path("models/polygon.rb", __dir__) +autoload :Rectangle, File.expand_path("models/rectangle.rb", __dir__) +autoload :Shade, File.expand_path("models/shade.rb", __dir__) +autoload :ShadeHue, File.expand_path("models/shade_hue.rb", __dir__) +autoload :Thing, File.expand_path("models/thing.rb", __dir__) +autoload :Triangle, File.expand_path("models/triangle.rb", __dir__) diff --git a/spec/models/alias.rb b/spec/models/alias.rb new file mode 100644 index 0000000..31789ac --- /dev/null +++ b/spec/models/alias.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Alias < ActiveRecord::Base + belongs_to :color + + validates_uniqueness_of :name +end diff --git a/spec/models/color.rb b/spec/models/color.rb new file mode 100644 index 0000000..d429ca7 --- /dev/null +++ b/spec/models/color.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Color < ActiveRecord::Base + include SupportTableData + + self.support_table_data_directory = File.join(__dir__, "..", "data", "colors") + add_support_table_data "named_colors.yml" + add_support_table_data "named_colors.json" + add_support_table_data "colors.yml" + add_support_table_data File.join(__dir__, "..", "data", "colors", "colors.json") + add_support_table_data "colors.csv" + + belongs_to :group + belongs_to :hue + has_many :things + has_many :shades, through: :things + has_many :aliases, autosave: true + + # Intentionally invalid association + belongs_to :non_existent, class_name: "NonExistent" + + validates_uniqueness_of :name + + def group_name=(value) + self.group = Group.named_instance(value) + end + + def hue_name=(value) + self.hue = Hue.find_by!(name: value) + end + + def alias_names=(names) + self.aliases = names.map { |name| Alias.find_or_initialize_by(name: name) } + end + + private + + def hex=(value) + self.value = value.to_i(16) + end +end diff --git a/spec/models/group.rb b/spec/models/group.rb new file mode 100644 index 0000000..4f0dd0c --- /dev/null +++ b/spec/models/group.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Group < ActiveRecord::Base + include SupportTableData + + self.primary_key = :group_id + + named_instance_attribute_helpers :group_id + + add_support_table_data "groups.yml" + + named_instance_attribute_helpers :name + + validates_uniqueness_of :name +end diff --git a/spec/models/hue.rb b/spec/models/hue.rb new file mode 100644 index 0000000..120d8f1 --- /dev/null +++ b/spec/models/hue.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Hue < ActiveRecord::Base + include SupportTableData + + self.support_table_key_attribute = :name + + add_support_table_data "hues.yml" + + belongs_to :parent, class_name: "Hue", optional: true + + validates_uniqueness_of :name + + def parent_name=(value) + self.parent = Hue.find_by!(name: value) + end + + has_many :shade_hues + has_many :shades, through: :shade_hues, autosave: true + + support_table_dependency "Shade" + + def shade_names=(names) + self.shades = Shade.where(name: names) + end +end diff --git a/spec/models/invalid.rb b/spec/models/invalid.rb new file mode 100644 index 0000000..733e5ca --- /dev/null +++ b/spec/models/invalid.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Invalid < ActiveRecord::Base + include SupportTableData + + self.support_table_key_attribute = :name + + def already_defined? + true + end +end diff --git a/spec/models/polygon.rb b/spec/models/polygon.rb new file mode 100644 index 0000000..10359cf --- /dev/null +++ b/spec/models/polygon.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Polygon < ActiveRecord::Base + include SupportTableData + + self.support_table_key_attribute = :name + + add_support_table_data "polygons.yml" + + validates :name, uniqueness: true +end diff --git a/spec/models/rectangle.rb b/spec/models/rectangle.rb new file mode 100644 index 0000000..f0aaab9 --- /dev/null +++ b/spec/models/rectangle.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Rectangle < Polygon + validates :side_count, numericality: {equal_to: 4} +end diff --git a/spec/models/shade.rb b/spec/models/shade.rb new file mode 100644 index 0000000..d1d3011 --- /dev/null +++ b/spec/models/shade.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Shade < ActiveRecord::Base + include SupportTableData + + self.support_table_key_attribute = :name + + add_support_table_data "shades.yml" + + validates_uniqueness_of :name + + has_many :shade_hues + has_many :hues, through: :shade_hues +end diff --git a/spec/models/shade_hue.rb b/spec/models/shade_hue.rb new file mode 100644 index 0000000..a2dd88c --- /dev/null +++ b/spec/models/shade_hue.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ShadeHue < ActiveRecord::Base + belongs_to :shade + belongs_to :hue +end diff --git a/spec/models/thing.rb b/spec/models/thing.rb new file mode 100644 index 0000000..2326365 --- /dev/null +++ b/spec/models/thing.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Thing < ActiveRecord::Base + belongs_to :color + belongs_to :shade +end diff --git a/spec/models/triangle.rb b/spec/models/triangle.rb new file mode 100644 index 0000000..d75fc0a --- /dev/null +++ b/spec/models/triangle.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Triangle < Polygon + validates :side_count, numericality: {equal_to: 3} +end diff --git a/spec/support_table_data/documentation_spec.rb b/spec/support_table_data/documentation_spec.rb new file mode 100644 index 0000000..904e02e --- /dev/null +++ b/spec/support_table_data/documentation_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "spec_helper" + +require_relative "../../lib/support_table_data/documentation" + +RSpec.describe SupportTableData::Documentation do + before do + Color.delete_all + Group.delete_all + end + + describe "#instance_helper_yard_doc" do + it "generates YARD documentation for a named instance class method" do + doc = SupportTableData::Documentation.new(Color) + result = doc.instance_helper_yard_doc("red") + + expect(result).to include("# Find the red record from the database.") + expect(result).to include("# @return [Color] the red record") + expect(result).to include("# @raise [ActiveRecord::RecordNotFound] if the record does not exist") + expect(result).to include("# @!method self.red") + end + + it "uses the correct class name in return type" do + doc = SupportTableData::Documentation.new(Group) + result = doc.instance_helper_yard_doc("primary") + + expect(result).to include("# @return [Group] the primary record") + end + end + + describe "#predicate_helper_yard_doc" do + it "generates YARD documentation for a named instance predicate method" do + doc = SupportTableData::Documentation.new(Color) + result = doc.predicate_helper_yard_doc("red") + + expect(result).to include("# Check if this record is the red record.") + expect(result).to include("# @return [Boolean] true if this is the red record, false otherwise") + expect(result).to include("# @!method red?") + end + end + + describe "#class_def_with_yard_docs" do + it "returns nil when model has no named instances" do + allow(Color).to receive(:instance_names).and_return([]) + + doc = SupportTableData::Documentation.new(Color) + result = doc.class_def_with_yard_docs + + expect(result).to be_nil + end + + it "generates YARD docs for all named instances" do + doc = SupportTableData::Documentation.new(Color) + result = doc.class_def_with_yard_docs + puts result + + expect(result).not_to be_nil + + # Check for autogenerated markers + expect(result).to include("# Begin autogenerated YARD docs") + expect(result).to include("# End autogenerated YARD docs") + + # Check for class definition + expect(result).to include("class Color") + + # Check for named instances (Color has black, blue, green, red) + expect(result).to include("# Find the black record from the database.") + expect(result).to include("# @!method self.black") + expect(result).to include("# Check if this record is the black record.") + expect(result).to include("# @!method black?") + + expect(result).to include("# Find the blue record from the database.") + expect(result).to include("# @!method self.blue") + end + + it "includes attribute helper methods when defined" do + doc = SupportTableData::Documentation.new(Group) + result = doc.class_def_with_yard_docs + + expect(result).not_to be_nil + + # Group has attribute helpers for group_id and name + # Check for one of Group's instances (e.g., gray, primary, secondary) + expect(result).to include("# Get the group_id value for the gray record.") + expect(result).to include("def self.gray_group_id; end") + expect(result).to include("# Get the name value for the gray record.") + expect(result).to include("def self.gray_name; end") + end + + it "sorts instance names alphabetically" do + doc = SupportTableData::Documentation.new(Color) + result = doc.class_def_with_yard_docs + + # Color instances should appear in alphabetical order + black_pos = result.index("self.black") + blue_pos = result.index("self.blue") + green_pos = result.index("self.green") + red_pos = result.index("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/test_app/.gitignore b/test_app/.gitignore new file mode 100644 index 0000000..1b55771 --- /dev/null +++ b/test_app/.gitignore @@ -0,0 +1,4 @@ +log/*.log +tmp/ +doc/ +db/*.sqlite3 diff --git a/test_app/Gemfile b/test_app/Gemfile new file mode 100644 index 0000000..7164e9c --- /dev/null +++ b/test_app/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "rails", "~> 8.1.1" + +gem "sqlite3", "~> 2.9.0" + +gem "support_table_data", path: ".." diff --git a/test_app/Rakefile b/test_app/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/test_app/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/test_app/app/models/status.rb b/test_app/app/models/status.rb new file mode 100644 index 0000000..4d32df9 --- /dev/null +++ b/test_app/app/models/status.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Status < ActiveRecord::Base + include SupportTableData + + self.support_table_key_attribute = :code + add_support_table_data "statuses.yml" + named_instance_attribute_helpers :name +end + +# Begin autogenerated YARD docs +class Status + # Find the active record from the database. + # + # @return [Status] the active record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.active + + # Check if this record is the active record. + # + # @return [Boolean] true if this is the active record, false otherwise + # @!method active? + + # Get the active record's name. + # + # @return [Object] the active record's name + # @!method active_name + + # Find the canceled record from the database. + # + # @return [Status] the canceled record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.canceled + + # Check if this record is the canceled record. + # + # @return [Boolean] true if this is the canceled record, false otherwise + # @!method canceled? + + # Get the canceled record's name. + # + # @return [Object] the canceled record's name + # @!method canceled_name + + # Find the completed record from the database. + # + # @return [Status] the completed record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.completed + + # Check if this record is the completed record. + # + # @return [Boolean] true if this is the completed record, false otherwise + # @!method completed? + + # Get the completed record's name. + # + # @return [Object] the completed record's name + # @!method completed_name + + # Find the failed record from the database. + # + # @return [Status] the failed record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.failed + + # Check if this record is the failed record. + # + # @return [Boolean] true if this is the failed record, false otherwise + # @!method failed? + + # Get the failed record's name. + # + # @return [Object] the failed record's name + # @!method failed_name + + # Find the pending record from the database. + # + # @return [Status] the pending record + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!method self.pending + + # Check if this record is the pending record. + # + # @return [Boolean] true if this is the pending record, false otherwise + # @!method pending? + + # Get the pending record's name. + # + # @return [Object] the pending record's name + # @!method pending_name +end +# End autogenerated YARD docs diff --git a/test_app/bin/rails b/test_app/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/test_app/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/test_app/config.ru b/test_app/config.ru new file mode 100644 index 0000000..c7766f3 --- /dev/null +++ b/test_app/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +# run Rails.application +# Rails.application.load_server diff --git a/test_app/config/application.rb b/test_app/config/application.rb new file mode 100644 index 0000000..680a1b4 --- /dev/null +++ b/test_app/config/application.rb @@ -0,0 +1,42 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +# require "active_model/railtie" +# require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +# require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +# require "action_view/railtie" +# require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module TestApp + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + config.eager_load_paths << Rails.root.join("app", "configurations") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/test_app/config/boot.rb b/test_app/config/boot.rb new file mode 100644 index 0000000..2820116 --- /dev/null +++ b/test_app/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/test_app/config/database.yml b/test_app/config/database.yml new file mode 100644 index 0000000..576bcf4 --- /dev/null +++ b/test_app/config/database.yml @@ -0,0 +1,3 @@ +development: + adapter: sqlite3 + database: db/development.sqlite3 diff --git a/test_app/config/environment.rb b/test_app/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/test_app/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/test_app/config/environments/development.rb b/test_app/config/environments/development.rb new file mode 100644 index 0000000..cdc965b --- /dev/null +++ b/test_app/config/environments/development.rb @@ -0,0 +1,11 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false +end diff --git a/test_app/db/migrate/20260103060951_create_status.rb b/test_app/db/migrate/20260103060951_create_status.rb new file mode 100644 index 0000000..eee26f5 --- /dev/null +++ b/test_app/db/migrate/20260103060951_create_status.rb @@ -0,0 +1,8 @@ +class CreateStatus < ActiveRecord::Migration[8.1] + def change + create_table :statuses do |t| + t.string :code, null: false, index: {unique: true} + t.string :name, null: false, index: {unique: true} + end + end +end diff --git a/test_app/db/schema.rb b/test_app/db/schema.rb new file mode 100644 index 0000000..18095ef --- /dev/null +++ b/test_app/db/schema.rb @@ -0,0 +1,20 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_01_03_060951) do + create_table "statuses", force: :cascade do |t| + t.string "code", null: false + t.string "name", null: false + t.index ["code"], name: "index_statuses_on_code", unique: true + t.index ["name"], name: "index_statuses_on_name", unique: true + end +end diff --git a/test_app/db/support_tables/statuses.yml b/test_app/db/support_tables/statuses.yml new file mode 100644 index 0000000..4377678 --- /dev/null +++ b/test_app/db/support_tables/statuses.yml @@ -0,0 +1,19 @@ +pending: + code: pending + name: Pending + +active: + code: active + name: Active + +completed: + code: completed + name: Completed + +canceled: + code: canceled + name: Canceled + +failed: + code: failed + name: Failed diff --git a/test_app/log/.keep b/test_app/log/.keep new file mode 100644 index 0000000..e69de29 From df10689f1f859370499b9b9e13bd6bd9f76a0cb7 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 09:26:26 -0800 Subject: [PATCH 02/11] Improve generated docs --- lib/support_table_data/documentation.rb | 14 ++-- spec/support_table_data/documentation_spec.rb | 29 ++++---- test_app/app/models/status.rb | 70 +++++++++---------- 3 files changed, 54 insertions(+), 59 deletions(-) diff --git a/lib/support_table_data/documentation.rb b/lib/support_table_data/documentation.rb index 31fbcc7..ea5c660 100644 --- a/lib/support_table_data/documentation.rb +++ b/lib/support_table_data/documentation.rb @@ -25,9 +25,9 @@ def class_def_with_yard_docs # @return [String] The YARD comment text def instance_helper_yard_doc(name) <<~YARD - # Find the #{name} record from the database. + # Find the named instance record +#{name}+ from the database. # - # @return [#{klass.name}] the #{name} record + # @return [#{klass.name}] the +#{name}+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.#{name} YARD @@ -39,9 +39,9 @@ def instance_helper_yard_doc(name) # @return [String] The YARD comment text def predicate_helper_yard_doc(name) <<~YARD - # Check if this record is the #{name} record. + # Check if this record is the +#{name}+ record. # - # @return [Boolean] true if this is the #{name} record, false otherwise + # @return [Boolean] true if this is the +#{name}+ record, false otherwise # @!method #{name}? YARD end @@ -52,10 +52,10 @@ def predicate_helper_yard_doc(name) # @return [String] The YARD comment text def attribute_helper_yard_doc(name, attribute_name) <<~YARD - # Get the #{name} record's #{attribute_name}. + # Get the #{attribute_name} attribute of the +#{name}+ record. # - # @return [Object] the #{name} record's #{attribute_name} - # @!method #{name}_#{attribute_name} + # @return [Object] the +#{name}+ record's #{attribute_name} + # @!method self.#{name}_#{attribute_name} YARD end diff --git a/spec/support_table_data/documentation_spec.rb b/spec/support_table_data/documentation_spec.rb index 904e02e..788450a 100644 --- a/spec/support_table_data/documentation_spec.rb +++ b/spec/support_table_data/documentation_spec.rb @@ -15,8 +15,8 @@ doc = SupportTableData::Documentation.new(Color) result = doc.instance_helper_yard_doc("red") - expect(result).to include("# Find the red record from the database.") - expect(result).to include("# @return [Color] the red record") + expect(result).to include("# Find the named instance record +red+ from the database.") + expect(result).to include("# @return [Color] the +red+ record") expect(result).to include("# @raise [ActiveRecord::RecordNotFound] if the record does not exist") expect(result).to include("# @!method self.red") end @@ -25,7 +25,7 @@ doc = SupportTableData::Documentation.new(Group) result = doc.instance_helper_yard_doc("primary") - expect(result).to include("# @return [Group] the primary record") + expect(result).to include("# @return [Group] the +primary+ record") end end @@ -34,8 +34,8 @@ doc = SupportTableData::Documentation.new(Color) result = doc.predicate_helper_yard_doc("red") - expect(result).to include("# Check if this record is the red record.") - expect(result).to include("# @return [Boolean] true if this is the red record, false otherwise") + expect(result).to include("# Check if this record is the +red+ record.") + expect(result).to include("# @return [Boolean] true if this is the +red+ record, false otherwise") expect(result).to include("# @!method red?") end end @@ -57,20 +57,16 @@ expect(result).not_to be_nil - # Check for autogenerated markers - expect(result).to include("# Begin autogenerated YARD docs") - expect(result).to include("# End autogenerated YARD docs") - # Check for class definition expect(result).to include("class Color") # Check for named instances (Color has black, blue, green, red) - expect(result).to include("# Find the black record from the database.") + expect(result).to include("# Find the named instance record +black+ from the database.") expect(result).to include("# @!method self.black") - expect(result).to include("# Check if this record is the black record.") + expect(result).to include("# Check if this record is the +black+ record.") expect(result).to include("# @!method black?") - expect(result).to include("# Find the blue record from the database.") + expect(result).to include("# Find the named instance record +blue+ from the database.") expect(result).to include("# @!method self.blue") end @@ -82,10 +78,10 @@ # Group has attribute helpers for group_id and name # Check for one of Group's instances (e.g., gray, primary, secondary) - expect(result).to include("# Get the group_id value for the gray record.") - expect(result).to include("def self.gray_group_id; end") - expect(result).to include("# Get the name value for the gray record.") - expect(result).to include("def self.gray_name; end") + expect(result).to include("# Get the group_id attribute of the +gray+ record.") + expect(result).to include("# @!method self.gray_group_id") + expect(result).to include("# Get the name attribute of the +gray+ record.") + expect(result).to include("# @!method self.gray_name") end it "sorts instance names alphabetically" do @@ -104,4 +100,3 @@ end end end - diff --git a/test_app/app/models/status.rb b/test_app/app/models/status.rb index 4d32df9..152de04 100644 --- a/test_app/app/models/status.rb +++ b/test_app/app/models/status.rb @@ -10,84 +10,84 @@ class Status < ActiveRecord::Base # Begin autogenerated YARD docs class Status - # Find the active record from the database. + # Find the named instance record +active+ from the database. # - # @return [Status] the active record + # @return [Status] the +active+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.active - # Check if this record is the active record. + # Check if this record is the +active+ record. # - # @return [Boolean] true if this is the active record, false otherwise + # @return [Boolean] true if this is the +active+ record, false otherwise # @!method active? - # Get the active record's name. + # Get the name attribute of the +active+ record. # - # @return [Object] the active record's name - # @!method active_name + # @return [Object] the +active+ record's name + # @!method self.active_name - # Find the canceled record from the database. + # Find the named instance record +canceled+ from the database. # - # @return [Status] the canceled record + # @return [Status] the +canceled+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.canceled - # Check if this record is the canceled record. + # Check if this record is the +canceled+ record. # - # @return [Boolean] true if this is the canceled record, false otherwise + # @return [Boolean] true if this is the +canceled+ record, false otherwise # @!method canceled? - # Get the canceled record's name. + # Get the name attribute of the +canceled+ record. # - # @return [Object] the canceled record's name - # @!method canceled_name + # @return [Object] the +canceled+ record's name + # @!method self.canceled_name - # Find the completed record from the database. + # Find the named instance record +completed+ from the database. # - # @return [Status] the completed record + # @return [Status] the +completed+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.completed - # Check if this record is the completed record. + # Check if this record is the +completed+ record. # - # @return [Boolean] true if this is the completed record, false otherwise + # @return [Boolean] true if this is the +completed+ record, false otherwise # @!method completed? - # Get the completed record's name. + # Get the name attribute of the +completed+ record. # - # @return [Object] the completed record's name - # @!method completed_name + # @return [Object] the +completed+ record's name + # @!method self.completed_name - # Find the failed record from the database. + # Find the named instance record +failed+ from the database. # - # @return [Status] the failed record + # @return [Status] the +failed+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.failed - # Check if this record is the failed record. + # Check if this record is the +failed+ record. # - # @return [Boolean] true if this is the failed record, false otherwise + # @return [Boolean] true if this is the +failed+ record, false otherwise # @!method failed? - # Get the failed record's name. + # Get the name attribute of the +failed+ record. # - # @return [Object] the failed record's name - # @!method failed_name + # @return [Object] the +failed+ record's name + # @!method self.failed_name - # Find the pending record from the database. + # Find the named instance record +pending+ from the database. # - # @return [Status] the pending record + # @return [Status] the +pending+ record # @raise [ActiveRecord::RecordNotFound] if the record does not exist # @!method self.pending - # Check if this record is the pending record. + # Check if this record is the +pending+ record. # - # @return [Boolean] true if this is the pending record, false otherwise + # @return [Boolean] true if this is the +pending+ record, false otherwise # @!method pending? - # Get the pending record's name. + # Get the name attribute of the +pending+ record. # - # @return [Object] the pending record's name - # @!method pending_name + # @return [Object] the +pending+ record's name + # @!method self.pending_name end # End autogenerated YARD docs From 4e2b9d8ffa613bce3b3008cacd1c1dd6fef3dc1b Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 15:30:10 -0800 Subject: [PATCH 03/11] Update documentation task --- CHANGELOG.md | 6 +- README.md | 18 +- lib/support_table_data/documentation.rb | 87 +------ .../documentation/source_file.rb | 88 +++++++ .../documentation/yard_doc.rb | 91 +++++++ lib/tasks/support_table_data.rake | 25 +- lib/tasks/utils.rb | 19 ++ spec/spec_helper.rb | 1 + .../documentation/source_file_spec.rb | 232 ++++++++++++++++++ .../documentation/yard_doc_spec.rb | 98 ++++++++ spec/support_table_data/documentation_spec.rb | 102 -------- test_app/app/models/status.rb | 84 ------- 12 files changed, 558 insertions(+), 293 deletions(-) create mode 100644 lib/support_table_data/documentation/source_file.rb create mode 100644 lib/support_table_data/documentation/yard_doc.rb create mode 100644 spec/support_table_data/documentation/source_file_spec.rb create mode 100644 spec/support_table_data/documentation/yard_doc_spec.rb delete mode 100644 spec/support_table_data/documentation_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c6e10..d3d6a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The default data directory for support table data in Rails applications will be set to `db/support_tables`. This can also be overridden by setting the `config.support_table_data_directory` configuration option in the Rails application. +- The default data directory in a Rails application can be set with the `config.support_table_data_directory` option in the Rails application configuration. - Added rake task `support_table_data:add_yard_docs` for Rails applications that will add YARD documentation to support table models for the named instance helpers. +### Fixed + +- The default data directory for support table data in Rails applications now defaults to `db/support_tables` to match the documentation. + ## 1.4.0 ### Fixed diff --git a/README.md b/README.md index 8db62e3..e42b43d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ class Status < ApplicationRecord You cannot update the value of the key attribute in a record in the data file. If you do, a new record will be created and the existing record will be left unchanged. -You can specify data files as relative paths. This can be done by setting the `SupportTableData.data_directory` value. You can override this value for a model by setting the `support_table_data_directory` attribute on its class. In a Rails application, `SupportTableData.data_directory` will be automatically set to `db/support_tables/`. Otherwise, relative file paths will be resolved from the current working directory. You must define the directory to load relative files from before loading your model classes. +You can specify data files as relative paths. This can be done by setting the `SupportTableData.data_directory` value. You can override this value for a model by setting the `support_table_data_directory` attribute on its class. Otherwise, relative file paths will be resolved from the current working directory. You must define the directory to load relative files from before loading your model classes. + +In a Rails application, `SupportTableData.data_directory` will be automatically set to `db/support_tables/`. This can be overridden by setting the `config.support_table_data_directory` option in the Rails application configuration. **Note**: If you're using CSV files and Ruby 3.4 or higher, you'll need to include the `csv` gem in your Gemfile since it was removed from the standard library in Ruby 3.4. @@ -109,7 +111,6 @@ Helper methods will not override already defined methods on a model class. If a You can also define helper methods for named instance attributes. These helper methods will return the hard coded values from the data file. Calling these methods does not require a database connection. - ```ruby class Status < ApplicationRecord include SupportTableData @@ -171,6 +172,19 @@ completed: group_name: done ``` +#### Documenting Named Instance Helpers + +In a Rails application, you can add YARD documentation for the named instance helpers by running the rake task `support_table_data:add_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. + +The default behavior is to add the documentation commnts 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. + +```ruby +# Begin YARD docs for support_table_data +# End YARD docs for support_table_data +``` + +A good practice is to add a check to your CI pipeline to ensure the documentation is always up to date. + ### 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/lib/support_table_data/documentation.rb b/lib/support_table_data/documentation.rb index ea5c660..248baf5 100644 --- a/lib/support_table_data/documentation.rb +++ b/lib/support_table_data/documentation.rb @@ -1,88 +1,9 @@ # frozen_string_literal: true module SupportTableData - class Documentation - # Create a new documentation generator for a configuration class. - # - # @param config_class [Class] The configuration class to generate documentation for - def initialize(klass) - @klass = klass - end - - # Generate YARD documentation class definition for the model's helper methods. - # - # @return [String, nil] The YARD documentation class definition, or nil if no named instances - def class_def_with_yard_docs - instance_names = klass.instance_names - return nil if instance_names.empty? - - generate_yard_class(instance_names) - end - - # Generate YARD documentation comment for named instance singleton method. - # - # @param name [String] The name of the instance method. - # @return [String] The YARD comment text - def instance_helper_yard_doc(name) - <<~YARD - # Find the named instance record +#{name}+ from the database. - # - # @return [#{klass.name}] the +#{name}+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.#{name} - YARD - end - - # Generate YARD documentation comment for the predicate method for the named instance. - # - # @param name [String] The name of the instance method. - # @return [String] The YARD comment text - def predicate_helper_yard_doc(name) - <<~YARD - # Check if this record is the +#{name}+ record. - # - # @return [Boolean] true if this is the +#{name}+ record, false otherwise - # @!method #{name}? - YARD - end - - # Generate YARD documentation comment for the attribute method helper for the named instance. - # - # @param name [String] The name of the instance method. - # @return [String] The YARD comment text - def attribute_helper_yard_doc(name, attribute_name) - <<~YARD - # Get the #{attribute_name} attribute of the +#{name}+ record. - # - # @return [Object] the +#{name}+ record's #{attribute_name} - # @!method self.#{name}_#{attribute_name} - YARD - end - - private - - attr_reader :klass - - def generate_yard_class(instance_names) - return nil if instance_names.empty? - - yard_lines = ["class #{klass.name}"] - - # Generate docs for each named instance - instance_names.sort.each_with_index do |name, index| - yard_lines << "" unless index.zero? - instance_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" } - yard_lines << "" - predicate_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" } - klass.support_table_attribute_helpers.each do |attribute_name| - yard_lines << "" - attribute_helper_yard_doc(name, attribute_name).each_line(chomp: true) { |line| yard_lines << " #{line}" } - end - end - - yard_lines << "end" - - yard_lines.join("\n") - end + module Documentation end end + +require_relative "documentation/source_file" +require_relative "documentation/yard_doc" diff --git a/lib/support_table_data/documentation/source_file.rb b/lib/support_table_data/documentation/source_file.rb new file mode 100644 index 0000000..186b376 --- /dev/null +++ b/lib/support_table_data/documentation/source_file.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + class SourceFile + attr_reader :klass, :path + + BEGIN_YARD_COMMENT = "# Begin YARD docs for support_table_data" + END_YARD_COMMENT = "# End YARD docs for support_table_data" + YARD_COMMENT_REGEX = /^(?[ \t]*)#{BEGIN_YARD_COMMENT}.*^[ \t]*#{END_YARD_COMMENT}$/m + CLASS_DEF_REGEX = /^[ \t]*class [a-zA-Z_0-9:]+.*?$/ + + # Initialize a new source file representation. + # + # @param klass [Class] The model class + # @param path [Pathname] The path to the source file + def initialize(klass, path) + @klass = klass + @path = path + @source = nil + end + + # Return the source code of the file. + # + # @return [String] + def source + @source ||= @path.read + end + + # Return the source code without any generated YARD documentation. + # + # @return [String] + def source_without_yard_docs + "#{source.sub(YARD_COMMENT_REGEX, "").rstrip}#{trailing_newline}" + end + + # Return the source code with the generated YARD documentation added. + # The YARD docs are identified by a begin and end comment block. By default + # the generated docs are added to the end of the file by reopening the class + # definition. You can move the comment block inside the original class + # if desired. + # + # @return [String] + def source_with_yard_docs + yard_docs = YardDoc.new(klass).named_instance_yard_docs + return source if yard_docs.nil? + + existing_yard_docs = source.match(YARD_COMMENT_REGEX) + if existing_yard_docs + indent = existing_yard_docs[:indent] + has_class_def = existing_yard_docs.to_s.match?(CLASS_DEF_REGEX) + yard_docs = yard_docs.lines.map { |line| line.blank? ? "\n" : "#{indent}#{" " if has_class_def}#{line}" }.join + + updated_source = source[0, existing_yard_docs.begin(0)] + updated_source << "#{indent}#{BEGIN_YARD_COMMENT}\n" + updated_source << "#{indent}class #{klass.name}\n" if has_class_def + updated_source << yard_docs + updated_source << "\n#{indent}end" if has_class_def + updated_source << "\n#{indent}#{END_YARD_COMMENT}" + updated_source << source[existing_yard_docs.end(0)..-1] + updated_source + else + yard_comments = <<~SOURCE.chomp("\n") + #{BEGIN_YARD_COMMENT} + class #{klass.name} + #{yard_docs.lines.map { |line| line.blank? ? "\n" : " #{line}" }.join} + end + #{END_YARD_COMMENT} + SOURCE + "#{source.rstrip}\n\n#{yard_comments}#{trailing_newline}" + end + end + + # Check if the YARD documentation in the source file is up to date. + # + # @return [Boolean] + def yard_docs_up_to_date? + source == source_with_yard_docs + end + + private + + def trailing_newline + source.end_with?("\n") ? "\n" : "" + end + end + end +end diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb new file mode 100644 index 0000000..b9dcc6b --- /dev/null +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module SupportTableData + module Documentation + class YardDoc + # @param config_class [Class] The configuration class to generate documentation for + def initialize(klass) + @klass = klass + end + + # Generate YARD documentation class definition for the model's helper methods. + # + # @return [String, nil] The YARD documentation class definition, or nil if no named instances + def named_instance_yard_docs + instance_names = klass.instance_names + generate_yard_docs(instance_names) + end + + # Generate YARD documentation comment for named instance singleton method. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def instance_helper_yard_doc(name) + <<~YARD.chomp("\n") + # Find the named instance +#{name}+ from the database. + # + # @!method self.#{name} + # @return [#{klass.name}] + # @raise [ActiveRecord::RecordNotFound] if the record does not exist + # @!visibility public + YARD + end + + # Generate YARD documentation comment for the predicate method for the named instance. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def predicate_helper_yard_doc(name) + <<~YARD.chomp("\n") + # Check if this record is the named instance +#{name}+. + # + # @!method #{name}? + # @return [Boolean] + # @!visibility public + YARD + end + + # Generate YARD documentation comment for the attribute method helper for the named instance. + # + # @param name [String] The name of the instance method. + # @return [String] The YARD comment text + def attribute_helper_yard_doc(name, 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] + # @!visibility public + YARD + end + + private + + attr_reader :klass + + def generate_yard_docs(instance_names) + return nil if instance_names.empty? + + yard_lines = ["# @!group Named Instances"] + + # Generate docs for each named instance + instance_names.sort.each do |name, index| + yard_lines << "" + yard_lines << instance_helper_yard_doc(name) + yard_lines << "" + yard_lines << predicate_helper_yard_doc(name) + klass.support_table_attribute_helpers.each do |attribute_name| + yard_lines << "" + yard_lines << attribute_helper_yard_doc(name, attribute_name) + end + end + + yard_lines << "" + yard_lines << "# @!endgroup" + + yard_lines.join("\n") + end + end + end +end diff --git a/lib/tasks/support_table_data.rake b/lib/tasks/support_table_data.rake index c80280d..8b53cc6 100644 --- a/lib/tasks/support_table_data.rake +++ b/lib/tasks/support_table_data.rake @@ -29,28 +29,11 @@ namespace :support_table_data do require_relative "utils" SupportTableData::Tasks::Utils.eager_load! + SupportTableData::Tasks::Utils.support_table_sources.each do |source_file| + next if source_file.yard_docs_up_to_date? - ActiveRecord::Base.descendants.each do |klass| - next unless klass.included_modules.include?(SupportTableData) - next if klass.instance_names.empty? - - doc = SupportTableData::Documentation.new(klass) - class_def_with_docs = doc.class_def_with_yard_docs - next unless class_def_with_docs - - file_path = SupportTableData::Tasks::Utils.model_file_path(klass) - next unless file_path&.file? && file_path.readable? - - begin_comment = "# Begin autogenerated YARD docs" - end_comment = "# End autogenerated YARD docs" - - file_contents = File.read(file_path) - updated_contents = file_contents.sub(/#{begin_comment}.*#{end_comment}/m, "").strip - updated_contents = "#{updated_contents}\n\n#{begin_comment}\n#{class_def_with_docs}\n#{end_comment}\n" - next if file_contents == updated_contents - - File.write(file_path, updated_contents) - puts "Added YARD documentation to #{klass.name}." + source_file.path.write(source_file.source_with_yard_docs) + puts "Added YARD documentation to #{source_file.klass.name}." end end end diff --git a/lib/tasks/utils.rb b/lib/tasks/utils.rb index 942bae0..1067b7e 100644 --- a/lib/tasks/utils.rb +++ b/lib/tasks/utils.rb @@ -18,6 +18,25 @@ def eager_load! end end + # Return a hash mapping all models that include SupportTableData to their source file paths. + # + # @return [Array] + def support_table_sources + sources = [] + + ActiveRecord::Base.descendants.each do |klass| + next unless klass.included_modules.include?(SupportTableData) + next if klass.instance_names.empty? + + file_path = SupportTableData::Tasks::Utils.model_file_path(klass) + next unless file_path&.file? && file_path.readable? + + sources << Documentation::SourceFile.new(klass, file_path) + end + + sources + end + def model_file_path(klass) file_path = "#{klass.name.underscore}.rb" model_path = nil diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index fe9b1cd..9ff17b7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ 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/source_file_spec.rb b/spec/support_table_data/documentation/source_file_spec.rb new file mode 100644 index 0000000..7f17776 --- /dev/null +++ b/spec/support_table_data/documentation/source_file_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::SourceFile do + let(:color_path) { Pathname.new(File.expand_path("../../models/color.rb", __dir__)) } + let(:group_path) { Pathname.new(File.expand_path("../../models/group.rb", __dir__)) } + + describe "#source" do + it "reads and caches the file content" do + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + source = source_file.source + + expect(source).to be_a(String) + expect(source).to include("class Color < ActiveRecord::Base") + + # Verify caching - should return same object + expect(source_file.source).to equal(source) + end + end + + describe "#source_without_yard_docs" do + it "removes existing YARD documentation between markers" do + source_with_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Some YARD docs + end + # End YARD docs for support_table_data + end + RUBY + + allow(File).to receive(:read).and_return(source_with_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_without_yard_docs + + expect(result).to include("class Color < ActiveRecord::Base") + expect(result).to include("include SupportTableData") + expect(result).not_to include("Begin YARD docs") + expect(result).not_to include("Some YARD docs") + expect(result).not_to include("End YARD docs") + end + + it "preserves trailing newline if present in original" do + source_with_newline = "class Color\nend\n" + allow(File).to receive(:read).and_return(source_with_newline) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_without_yard_docs + + expect(result).to end_with("\n") + end + + it "preserves no trailing newline if absent in original" do + source_without_newline = "class Color\nend" + allow(File).to receive(:read).and_return(source_without_newline) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_without_yard_docs + + expect(result).not_to end_with("\n") + end + + it "returns original source if no YARD docs present" do + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + original = source_file.source + result = source_file.source_without_yard_docs + + # Both should have same content (though not necessarily same object) + expect(result.strip).to eq(original.strip) + end + end + + describe "#source_with_yard_docs" do + it "adds YARD documentation with proper markers" do + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + result = source_file.source_with_yard_docs + + expect(result).to include("# Begin YARD docs for support_table_data") + expect(result).to include("# End YARD docs for support_table_data") + + # Color has named instances: black, blue, green, red + expect(result).to include("# @!method self.black") + expect(result).to include("# @!method black?") + end + + it "preserves trailing newline from original file" do + source_with_newline = "class Color\nend\n" + allow(File).to receive(:read).and_return(source_with_newline) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_with_yard_docs + + expect(result).to end_with("\n") + end + + it "preserves no trailing newline if absent in original" do + source_without_newline = "class Color\nend" + allow(File).to receive(:read).and_return(source_without_newline) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_with_yard_docs + + # When appending new docs, they don't add trailing newline if original doesn't have one + expect(result).not_to end_with("\n") + end + + it "replaces existing YARD docs with fresh ones when markers include class definition" 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 + + allow(File).to receive(:read).and_return(source_with_old_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_with_yard_docs + + expect(result).to include("# Begin YARD docs for support_table_data") + expect(result).to include("# End YARD docs for support_table_data") + expect(result).not_to include("# Old YARD docs") + expect(result).to include("# @!method self.black") + # Should include class definition since original had it + expect(result).to match(/# Begin YARD docs.*class Color.*# @!method self\.black.*end.*# End YARD docs/m) + end + + it "replaces existing YARD docs inline when markers do not include class definition" do + source_with_inline_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + # Old inline YARD docs + # End YARD docs for support_table_data + + def some_method + end + end + RUBY + + allow(File).to receive(:read).and_return(source_with_inline_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_with_yard_docs + + expect(result).to include("# Begin YARD docs for support_table_data") + expect(result).to include("# End YARD docs for support_table_data") + expect(result).not_to include("# Old inline YARD docs") + expect(result).to include("# @!method self.black") + # Should NOT reopen class definition since original didn't have it + expect(result).not_to match(/# Begin YARD docs.*class Color.*# End YARD docs/m) + # Verify the class definition remains at the top and inline docs are between markers + expect(result).to match(/class Color < ActiveRecord::Base.*# Begin YARD docs.*# @!method self\.black.*# End YARD docs.*def some_method/m) + end + + it "preserves indentation when replacing inline YARD docs" do + source_with_indented_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + # Old docs + # End YARD docs for support_table_data + end + RUBY + + allow(File).to receive(:read).and_return(source_with_indented_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + result = source_file.source_with_yard_docs + + # Check that the generated docs maintain the 2-space indentation + expect(result).to include(" # Begin YARD docs for support_table_data") + expect(result).to include(" # @!method self.black") + expect(result).to include(" # End YARD docs for support_table_data") + 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) + up_to_date_source = source_file.source_with_yard_docs + + allow(source_file).to receive(:source).and_return(up_to_date_source) + + expect(source_file.yard_docs_up_to_date?).to be true + end + + it "returns false when YARD docs are missing" do + source_without_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + end + RUBY + + allow(File).to receive(:read).and_return(source_without_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + expect(source_file.yard_docs_up_to_date?).to be false + end + + it "returns false when YARD docs are outdated" do + source_with_old_docs = <<~RUBY + class Color < ActiveRecord::Base + include SupportTableData + + # Begin YARD docs for support_table_data + class Color + # Old outdated docs + end + # End YARD docs for support_table_data + end + RUBY + + allow(File).to receive(:read).and_return(source_with_old_docs) + source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + + expect(source_file.yard_docs_up_to_date?).to be false + 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 new file mode 100644 index 0000000..b9fb623 --- /dev/null +++ b/spec/support_table_data/documentation/yard_doc_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableData::Documentation::YardDoc do + describe "#instance_helper_yard_doc" do + it "generates YARD documentation for a named instance class method" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.instance_helper_yard_doc("red") + + expect(result).to include("# Find the named instance +red+ from the database.") + expect(result).to include("# @return [Color]") + expect(result).to include("# @raise [ActiveRecord::RecordNotFound] if the record does not exist") + expect(result).to include("# @!method self.red") + end + + it "uses the correct class name in return type" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.instance_helper_yard_doc("primary") + + expect(result).to include("# @return [Group]") + end + end + + describe "#predicate_helper_yard_doc" do + it "generates YARD documentation for a named instance predicate method" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.predicate_helper_yard_doc("red") + + expect(result).to include("# Check if this record is the named instance +red+.") + expect(result).to include("# @return [Boolean]") + expect(result).to include("# @!method red?") + 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([]) + + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.named_instance_yard_docs + + expect(result).to be_nil + end + + it "generates YARD docs for all named instances" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.named_instance_yard_docs + + expect(result).not_to be_nil + + # Check for group markers + expect(result).to include("# @!group Named Instances") + expect(result).to include("# @!endgroup") + + # Check for named instances (Color has black, blue, green, red) + expect(result).to include("# Find the named instance +black+ from the database.") + expect(result).to include("# @!method self.black") + expect(result).to include("# Check if this record is the named instance +black+.") + expect(result).to include("# @!method black?") + + expect(result).to include("# Find the named instance +blue+ from the database.") + expect(result).to include("# @!method self.blue") + end + + it "includes attribute helper methods when defined" do + doc = SupportTableData::Documentation::YardDoc.new(Group) + result = doc.named_instance_yard_docs + + expect(result).not_to be_nil + + # Group has attribute helpers for group_id and name + # Check for one of Group's instances (e.g., gray, primary, secondary) + expect(result).to include("# Get the group_id attribute from the data file") + expect(result).to include("# for the named instance +gray+.") + expect(result).to include("# @!method self.gray_group_id") + expect(result).to include("# Get the name attribute from the data file") + expect(result).to include("# for the named instance +gray+.") + expect(result).to include("# @!method self.gray_name") + end + + it "sorts instance names alphabetically" do + doc = SupportTableData::Documentation::YardDoc.new(Color) + result = doc.named_instance_yard_docs + + # Color instances should appear in alphabetical order + # Use more specific patterns to find the first occurrence of each method + black_pos = result.index("# @!method self.black") + blue_pos = result.index("# @!method self.blue") + green_pos = result.index("# @!method self.green") + red_pos = result.index("# @!method 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_spec.rb b/spec/support_table_data/documentation_spec.rb deleted file mode 100644 index 788450a..0000000 --- a/spec/support_table_data/documentation_spec.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -require_relative "../../lib/support_table_data/documentation" - -RSpec.describe SupportTableData::Documentation do - before do - Color.delete_all - Group.delete_all - end - - describe "#instance_helper_yard_doc" do - it "generates YARD documentation for a named instance class method" do - doc = SupportTableData::Documentation.new(Color) - result = doc.instance_helper_yard_doc("red") - - expect(result).to include("# Find the named instance record +red+ from the database.") - expect(result).to include("# @return [Color] the +red+ record") - expect(result).to include("# @raise [ActiveRecord::RecordNotFound] if the record does not exist") - expect(result).to include("# @!method self.red") - end - - it "uses the correct class name in return type" do - doc = SupportTableData::Documentation.new(Group) - result = doc.instance_helper_yard_doc("primary") - - expect(result).to include("# @return [Group] the +primary+ record") - end - end - - describe "#predicate_helper_yard_doc" do - it "generates YARD documentation for a named instance predicate method" do - doc = SupportTableData::Documentation.new(Color) - result = doc.predicate_helper_yard_doc("red") - - expect(result).to include("# Check if this record is the +red+ record.") - expect(result).to include("# @return [Boolean] true if this is the +red+ record, false otherwise") - expect(result).to include("# @!method red?") - end - end - - describe "#class_def_with_yard_docs" do - it "returns nil when model has no named instances" do - allow(Color).to receive(:instance_names).and_return([]) - - doc = SupportTableData::Documentation.new(Color) - result = doc.class_def_with_yard_docs - - expect(result).to be_nil - end - - it "generates YARD docs for all named instances" do - doc = SupportTableData::Documentation.new(Color) - result = doc.class_def_with_yard_docs - puts result - - expect(result).not_to be_nil - - # Check for class definition - expect(result).to include("class Color") - - # Check for named instances (Color has black, blue, green, red) - expect(result).to include("# Find the named instance record +black+ from the database.") - expect(result).to include("# @!method self.black") - expect(result).to include("# Check if this record is the +black+ record.") - expect(result).to include("# @!method black?") - - expect(result).to include("# Find the named instance record +blue+ from the database.") - expect(result).to include("# @!method self.blue") - end - - it "includes attribute helper methods when defined" do - doc = SupportTableData::Documentation.new(Group) - result = doc.class_def_with_yard_docs - - expect(result).not_to be_nil - - # Group has attribute helpers for group_id and name - # Check for one of Group's instances (e.g., gray, primary, secondary) - expect(result).to include("# Get the group_id attribute of the +gray+ record.") - expect(result).to include("# @!method self.gray_group_id") - expect(result).to include("# Get the name attribute of the +gray+ record.") - expect(result).to include("# @!method self.gray_name") - end - - it "sorts instance names alphabetically" do - doc = SupportTableData::Documentation.new(Color) - result = doc.class_def_with_yard_docs - - # Color instances should appear in alphabetical order - black_pos = result.index("self.black") - blue_pos = result.index("self.blue") - green_pos = result.index("self.green") - red_pos = result.index("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/test_app/app/models/status.rb b/test_app/app/models/status.rb index 152de04..3d3ebae 100644 --- a/test_app/app/models/status.rb +++ b/test_app/app/models/status.rb @@ -7,87 +7,3 @@ class Status < ActiveRecord::Base add_support_table_data "statuses.yml" named_instance_attribute_helpers :name end - -# Begin autogenerated YARD docs -class Status - # Find the named instance record +active+ from the database. - # - # @return [Status] the +active+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.active - - # Check if this record is the +active+ record. - # - # @return [Boolean] true if this is the +active+ record, false otherwise - # @!method active? - - # Get the name attribute of the +active+ record. - # - # @return [Object] the +active+ record's name - # @!method self.active_name - - # Find the named instance record +canceled+ from the database. - # - # @return [Status] the +canceled+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.canceled - - # Check if this record is the +canceled+ record. - # - # @return [Boolean] true if this is the +canceled+ record, false otherwise - # @!method canceled? - - # Get the name attribute of the +canceled+ record. - # - # @return [Object] the +canceled+ record's name - # @!method self.canceled_name - - # Find the named instance record +completed+ from the database. - # - # @return [Status] the +completed+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.completed - - # Check if this record is the +completed+ record. - # - # @return [Boolean] true if this is the +completed+ record, false otherwise - # @!method completed? - - # Get the name attribute of the +completed+ record. - # - # @return [Object] the +completed+ record's name - # @!method self.completed_name - - # Find the named instance record +failed+ from the database. - # - # @return [Status] the +failed+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.failed - - # Check if this record is the +failed+ record. - # - # @return [Boolean] true if this is the +failed+ record, false otherwise - # @!method failed? - - # Get the name attribute of the +failed+ record. - # - # @return [Object] the +failed+ record's name - # @!method self.failed_name - - # Find the named instance record +pending+ from the database. - # - # @return [Status] the +pending+ record - # @raise [ActiveRecord::RecordNotFound] if the record does not exist - # @!method self.pending - - # Check if this record is the +pending+ record. - # - # @return [Boolean] true if this is the +pending+ record, false otherwise - # @!method pending? - - # Get the name attribute of the +pending+ record. - # - # @return [Object] the +pending+ record's name - # @!method self.pending_name -end -# End autogenerated YARD docs From 11ef116cbd48058701044fe3fe653217c6420ff3 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 15:35:02 -0800 Subject: [PATCH 04/11] fix test mocking --- .../documentation/source_file_spec.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/support_table_data/documentation/source_file_spec.rb b/spec/support_table_data/documentation/source_file_spec.rb index 7f17776..075c165 100644 --- a/spec/support_table_data/documentation/source_file_spec.rb +++ b/spec/support_table_data/documentation/source_file_spec.rb @@ -33,8 +33,8 @@ class Color end RUBY - allow(File).to receive(:read).and_return(source_with_docs) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_docs) result = source_file.source_without_yard_docs @@ -47,8 +47,8 @@ class Color it "preserves trailing newline if present in original" do source_with_newline = "class Color\nend\n" - allow(File).to receive(:read).and_return(source_with_newline) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_newline) result = source_file.source_without_yard_docs @@ -57,8 +57,8 @@ class Color it "preserves no trailing newline if absent in original" do source_without_newline = "class Color\nend" - allow(File).to receive(:read).and_return(source_without_newline) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_without_newline) result = source_file.source_without_yard_docs @@ -90,8 +90,8 @@ class Color it "preserves trailing newline from original file" do source_with_newline = "class Color\nend\n" - allow(File).to receive(:read).and_return(source_with_newline) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_newline) result = source_file.source_with_yard_docs @@ -100,8 +100,8 @@ class Color it "preserves no trailing newline if absent in original" do source_without_newline = "class Color\nend" - allow(File).to receive(:read).and_return(source_without_newline) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_without_newline) result = source_file.source_with_yard_docs @@ -122,8 +122,8 @@ class Color end RUBY - allow(File).to receive(:read).and_return(source_with_old_docs) 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 @@ -149,8 +149,8 @@ def some_method end RUBY - allow(File).to receive(:read).and_return(source_with_inline_docs) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_inline_docs) result = source_file.source_with_yard_docs @@ -175,8 +175,8 @@ class Color < ActiveRecord::Base end RUBY - allow(File).to receive(:read).and_return(source_with_indented_docs) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_with_indented_docs) result = source_file.source_with_yard_docs @@ -204,8 +204,8 @@ class Color < ActiveRecord::Base end RUBY - allow(File).to receive(:read).and_return(source_without_docs) source_file = SupportTableData::Documentation::SourceFile.new(Color, color_path) + allow(source_file).to receive(:source).and_return(source_without_docs) expect(source_file.yard_docs_up_to_date?).to be false end @@ -223,8 +223,8 @@ class Color end RUBY - allow(File).to receive(:read).and_return(source_with_old_docs) 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 From c0de8097ffddf6aaa6bd457b4e7cf90b97d0bec9 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 17:30:13 -0800 Subject: [PATCH 05/11] update rake file --- .github/workflows/continuous_integration.yml | 4 ++-- Rakefile | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 9cc10e3..008301d 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -67,7 +67,7 @@ jobs: run: bundle exec rake - name: standardrb if: matrix.standardrb == true - run: bundle exec rake standard + run: bundle exec standardrb - name: yard if: matrix.yard == true - run: bundle exec yard --fail-on-warning + run: bundle exec yard doc --fail-on-warning diff --git a/Rakefile b/Rakefile index 584050e..3deea70 100644 --- a/Rakefile +++ b/Rakefile @@ -4,9 +4,6 @@ rescue LoadError puts "You must `gem install bundler` and `bundle install` to run rake tasks" end -require "yard" -YARD::Rake::YardocTask.new(:yard) - require "bundler/gem_tasks" task :verify_release_branch do @@ -23,17 +20,3 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) task default: :spec - -desc "run the specs using appraisal" -task :appraisals do - exec "bundle exec appraisal rake spec" -end - -namespace :appraisals do - desc "install all the appraisal gemspecs" - task :install do - exec "bundle exec appraisal install" - end -end - -require "standard/rake" From 561263da59f46225df3446f9a4ad795fda9ff556 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 17:39:33 -0800 Subject: [PATCH 06/11] Update lib/support_table_data/documentation/yard_doc.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/support_table_data/documentation/yard_doc.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index b9dcc6b..37a725b 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -3,7 +3,7 @@ module SupportTableData module Documentation class YardDoc - # @param config_class [Class] The configuration class to generate documentation for + # @param klass [Class] The model class to generate documentation for def initialize(klass) @klass = klass end From 2e0e516da9d50f046da9c228fbd94cf617ba1f78 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 17:39:40 -0800 Subject: [PATCH 07/11] Update lib/support_table_data/documentation/yard_doc.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/support_table_data/documentation/yard_doc.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/support_table_data/documentation/yard_doc.rb b/lib/support_table_data/documentation/yard_doc.rb index 37a725b..2fa6baa 100644 --- a/lib/support_table_data/documentation/yard_doc.rb +++ b/lib/support_table_data/documentation/yard_doc.rb @@ -70,7 +70,7 @@ def generate_yard_docs(instance_names) yard_lines = ["# @!group Named Instances"] # Generate docs for each named instance - instance_names.sort.each do |name, index| + instance_names.sort.each do |name| yard_lines << "" yard_lines << instance_helper_yard_doc(name) yard_lines << "" From 8f432376f5ad8bc16590eaefa740d3bb20d12b0c Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 17:39:50 -0800 Subject: [PATCH 08/11] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e42b43d..e2cb683 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ completed: In a Rails application, you can add YARD documentation for the named instance helpers by running the rake task `support_table_data:add_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. -The default behavior is to add the documentation commnts 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. +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. ```ruby # Begin YARD docs for support_table_data From c26d03c8d794af645313ae6a86df77325df68aec Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 17:48:30 -0800 Subject: [PATCH 09/11] Remove old code for setting Rails data directory --- CHANGELOG.md | 4 ++-- lib/support_table_data.rb | 22 +++++++++++----------- lib/support_table_data/railtie.rb | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d6a11..5efdde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default data directory in a Rails application can be set with the `config.support_table_data_directory` option in the Rails application configuration. - Added rake task `support_table_data:add_yard_docs` for Rails applications that will add YARD documentation to support table models for the named instance helpers. -### Fixed +### Changed -- The default data directory for support table data in Rails applications now defaults to `db/support_tables` to match the documentation. +- The default data directory is now set in a Railtie and can be overridden with the `config.support_table_data_directory` option in the Rails application configuration. ## 1.4.0 diff --git a/lib/support_table_data.rb b/lib/support_table_data.rb index f002120..12bb920 100644 --- a/lib/support_table_data.rb +++ b/lib/support_table_data.rb @@ -8,6 +8,8 @@ module SupportTableData extend ActiveSupport::Concern + @data_directory = nil + included do # Internal variables used for memoization. @mutex = Mutex.new @@ -342,19 +344,17 @@ def support_table_record_changed?(record, seen = Set.new) end class << self - # Specify the default directory for data files. - attr_writer :data_directory + # @attribute [r] + # The the default directory where data files live. + # @return [String, nil] + attr_reader :data_directory - # The directory where data files live by default. If you are running in a Rails environment, - # then this will be `db/support_tables`. Otherwise, the current working directory will be used. + # Set the default directory where data files live. # - # @return [String] - def data_directory - if defined?(@data_directory) - @data_directory - elsif defined?(Rails.root) - Rails.root.join("db", "support_tables").to_s - end + # @param value [String, Pathname, nil] The path to the directory. + # @return [void] + def data_directory=(value) + @data_directory = value&.to_s end # Sync all support table classes. Classes must already be loaded in order to be synced. diff --git a/lib/support_table_data/railtie.rb b/lib/support_table_data/railtie.rb index afe911e..ea84142 100644 --- a/lib/support_table_data/railtie.rb +++ b/lib/support_table_data/railtie.rb @@ -5,7 +5,7 @@ class Railtie < Rails::Railtie config.support_table_data_directory = "db/support_tables" initializer "support_table_data" do |app| - SupportTableData.data_directory ||= app.config.support_table_data_directory + SupportTableData.data_directory ||= app.root.join(app.config.support_table_data_directory).to_s end rake_tasks do From cdba6db24923eeb237ed2d49b4a8749f50687311 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sat, 3 Jan 2026 20:15:57 -0800 Subject: [PATCH 10/11] Add test for rake task --- lib/tasks/utils.rb | 8 +++++++- spec/tasks_spec.rb | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/tasks/utils.rb b/lib/tasks/utils.rb index 1067b7e..16f1962 100644 --- a/lib/tasks/utils.rb +++ b/lib/tasks/utils.rb @@ -26,7 +26,13 @@ def support_table_sources ActiveRecord::Base.descendants.each do |klass| next unless klass.included_modules.include?(SupportTableData) - next if klass.instance_names.empty? + + begin + next if klass.instance_names.empty? + rescue NoMethodError + # Skip models where instance_names is not properly initialized + next + end file_path = SupportTableData::Tasks::Utils.model_file_path(klass) next unless file_path&.file? && file_path.readable? diff --git a/spec/tasks_spec.rb b/spec/tasks_spec.rb index d2bdf3f..48cf4e9 100644 --- a/spec/tasks_spec.rb +++ b/spec/tasks_spec.rb @@ -3,15 +3,23 @@ require_relative "spec_helper" require "rake" -describe "support_table_data" do - let(:rake) { Rake::Application.new } +RSpec.describe "support_table_data" do let(:out) { StringIO.new } before do - Rake.application = rake - Rake.application.rake_require("lib/tasks/support_table_data", [File.join(__dir__, "..")]) + # Create a fresh Rake application for each test + Rake.application = Rake::Application.new + load File.join(__dir__, "..", "lib", "tasks", "support_table_data.rake") Rake::Task.define_task(:environment) allow(ActiveRecord::Base).to receive(:logger).and_return(Logger.new(out)) + + # Mock Rails for task execution + rails_app = double("Rails.application") + rails_config = double("Rails.application.config") + allow(rails_config).to receive(:eager_load).and_return(true) # Already loaded in spec_helper + allow(rails_config).to receive(:paths).and_return("app/models" => [File.join(__dir__, "models")]) + allow(rails_app).to receive(:config).and_return(rails_config) + stub_const("Rails", double("Rails", application: rails_app)) end describe "sync" do @@ -23,4 +31,33 @@ end end end + + describe "add_yard_docs" 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) + + # Track which files would be written to + written_files = [] + allow_any_instance_of(Pathname).to receive(:write) do |instance, content| + written_files << {path: instance.to_s, content: content} + end + + # Run the task + Rake.application.invoke_task "support_table_data:add_yard_docs" + + # Verify that at least one model had documentation added + expect(written_files).not_to be_empty + expect($stdout).to have_received(:puts).at_least(:once) + + # Verify the written content looks correct (check one example) + color_write = written_files.find { |f| f[:path].include?("color.rb") } + expect(color_write).not_to be_nil + expect(color_write[:content]).to include("# Begin YARD docs for support_table_data") + expect(color_write[:content]).to include("@!method self.red") + end + end end From eb7d2243b528c8ae523bd97e94272116032afc49 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sun, 4 Jan 2026 10:12:10 -0800 Subject: [PATCH 11/11] whitespace --- lib/tasks/utils.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/utils.rb b/lib/tasks/utils.rb index 16f1962..108fb2a 100644 --- a/lib/tasks/utils.rb +++ b/lib/tasks/utils.rb @@ -26,7 +26,7 @@ def support_table_sources ActiveRecord::Base.descendants.each do |klass| next unless klass.included_modules.include?(SupportTableData) - + begin next if klass.instance_names.empty? rescue NoMethodError