Skip to content

Commit a22dc46

Browse files
committed
Add rake task to generate YARD docs
1 parent 8d8e80d commit a22dc46

38 files changed

Lines changed: 833 additions & 148 deletions

.github/workflows/continuous_integration.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ jobs:
2828
- ruby: "ruby"
2929
standardrb: true
3030
yard: true
31+
- ruby: "4.0"
32+
appraisal: "activerecord_8.1"
3133
- ruby: "3.4"
3234
appraisal: "activerecord_8.0"
3335
- ruby: "3.2"

AGENTS.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Copilot Instructions for support_table_data
2+
3+
## Project Overview
4+
5+
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')`).
6+
7+
**Core concept**: Support tables blur the line between data and code—they contain small canonical datasets that must exist for the application to work.
8+
9+
## Architecture
10+
11+
### Key Components
12+
13+
- **`SupportTableData` module** ([lib/support_table_data.rb](lib/support_table_data.rb)): Main concern mixed into ActiveRecord models
14+
- **Named instance system**: Dynamically generates class methods (`.pending`), predicate methods (`.pending?`), and attribute helpers (`.pending_id`) from hash-based data files
15+
- **Data sync engine**: Compares canonical data files with database records, creating/updating as needed in atomic transactions
16+
- **File parsers**: Supports YAML, JSON, and CSV formats with unified interface
17+
18+
### Data Flow
19+
20+
1. Data files (YAML/JSON/CSV) define canonical records with unique key attributes
21+
2. `add_support_table_data` registers file paths and triggers method generation for hash-based files
22+
3. `sync_table_data!` parses files, loads matching DB records, and updates/creates within transactions
23+
4. Named instance methods are dynamically defined via `class_eval` with memoization
24+
25+
## Development Workflows
26+
27+
### Running Tests
28+
29+
```bash
30+
bundle exec rspec # Run all specs
31+
bundle exec rspec spec/support_table_data_spec.rb # Single file
32+
bundle exec rake appraisals # Test against all ActiveRecord versions
33+
```
34+
35+
Uses RSpec with in-memory SQLite database. Test models defined in [spec/models.rb](spec/models.rb), data files in `spec/data/`.
36+
37+
### Testing Against Multiple ActiveRecord Versions
38+
39+
The gem supports ActiveRecord 6.0-8.0. Uses Appraisal for multi-version testing:
40+
41+
```bash
42+
bundle exec appraisal install # Install all gemfiles
43+
bundle exec appraisal rspec # Run specs against all versions
44+
```
45+
46+
See `Appraisals` file and `gemfiles/` directory.
47+
48+
### Code Style
49+
50+
Uses Standard Ruby formatter:
51+
52+
```bash
53+
bundle exec rake standard:fix # Auto-fix style issues
54+
```
55+
56+
## Critical Patterns
57+
58+
### Named Instance Method Generation
59+
60+
**Hash-based data files** trigger dynamic method generation. Example from [spec/data/colors/named_colors.yml](spec/data/colors/named_colors.yml):
61+
62+
```yaml
63+
red:
64+
id: 1
65+
name: Red
66+
value: 16711680
67+
```
68+
69+
Generates:
70+
- `Color.red` → finds record by id
71+
- `color_instance.red?` → tests if `color_instance.id == 1`
72+
- `Color.red_id` → returns `1` (if `named_instance_attribute_helpers :id` defined)
73+
74+
**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.
75+
76+
### Custom Setters for Associations
77+
78+
Support tables often reference other support tables via named instances. Pattern from [spec/models.rb](spec/models.rb#L72-L74):
79+
80+
```ruby
81+
def group_name=(value)
82+
self.group = Group.named_instance(value)
83+
end
84+
```
85+
86+
Allows data files to reference related records by instance name instead of foreign keys.
87+
88+
### Key Attribute Configuration
89+
90+
By default, uses model's `primary_key`. Override for non-id keys:
91+
92+
```ruby
93+
self.support_table_key_attribute = :name # Use 'name' instead of 'id'
94+
```
95+
96+
Key attributes cannot be updated—changing them creates new records.
97+
98+
### Dependency Resolution
99+
100+
`sync_all!` automatically resolves dependencies via `belongs_to` associations and loads tables in correct order. For complex cases (join tables, indirect dependencies), explicitly declare:
101+
102+
```ruby
103+
support_table_dependency "OtherModel"
104+
```
105+
106+
See [lib/support_table_data.rb](lib/support_table_data.rb#L219-L222) and dependency resolution logic.
107+
108+
## Testing Conventions
109+
110+
- **Test data isolation**: Each test deletes all records in `before` block ([spec/spec_helper.rb](spec/spec_helper.rb))
111+
- **Sync before assertions**: Tests call `sync_table_data!` or `sync_all!` before verifying records exist
112+
- **Multi-file merging**: Tests verify that multiple data files for same model merge correctly (see `Color` model with 5 data files)
113+
- **STI handling**: See `Polygon`/`Triangle`/`Rectangle` tests for Single Table Inheritance patterns
114+
115+
## Common Pitfalls
116+
117+
1. **Method name conflicts**: Named instance methods raise `ArgumentError` if method already exists. Instance names must match `/\A[a-z][a-z0-9_]+\z/`
118+
2. **Array vs hash data**: Only hash-keyed data generates named instance methods. Use arrays or underscore-prefixed keys (`_others`) for records without helpers
119+
3. **Protected instances**: Records in data files cannot be deleted via `destroy` (though this gem doesn't enforce it—see companion caching gem)
120+
4. **Transaction safety**: All sync operations wrapped in transactions; changes rollback on failure
121+
122+
## Rails Integration
123+
124+
In Rails apps, the gem automatically:
125+
- Sets `SupportTableData.data_directory` to `Rails.root/db/support_tables`
126+
- Provides `rake support_table_data:sync` task ([lib/tasks/support_table_data.rake](lib/tasks/support_table_data.rake))
127+
- Handles eager loading in both classic and Zeitwerk autoloaders
128+
129+
## File References
130+
131+
- Main module: [lib/support_table_data.rb](lib/support_table_data.rb)
132+
- Test models: [spec/models.rb](spec/models.rb) - comprehensive examples of patterns
133+
- Sync task: [lib/tasks/support_table_data.rake](lib/tasks/support_table_data.rake)
134+
- Architecture docs: [ARCHITECTURE.md](ARCHITECTURE.md) - detailed diagrams and design decisions
135+
136+
## Version Compatibility
137+
138+
- Ruby ≥ 2.5
139+
- ActiveRecord ≥ 6.0
140+
- Ruby 3.4+: Requires `csv` gem in Gemfile (removed from stdlib)

Appraisals

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# frozen_string_literal: true
22

3+
appraise "activerecord_8.1" do
4+
gem "activerecord", "~> 8.1.0"
5+
gem "sqlite3", "~> 2.9.0"
6+
end
7+
38
appraise "activerecord_8.0" do
49
gem "activerecord", "~> 8.0.0"
510
gem "sqlite3", "~> 2.5.0"

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## 1.4.1
8+
9+
### Added
10+
11+
- 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.
12+
- 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.
13+
714
## 1.4.0
815

916
### Fixed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4.0
1+
1.4.1

gemfiles/activerecord_8.1.gemfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# This file was generated by Appraisal
2+
3+
source "https://rubygems.org"
4+
5+
gem "rspec", "~> 3.0"
6+
gem "rake"
7+
gem "sqlite3", "~> 2.9.0"
8+
gem "appraisal"
9+
gem "standard", "~>1.0"
10+
gem "pry-byebug"
11+
gem "yard"
12+
gem "csv"
13+
gem "activerecord", "~> 8.1.0"
14+
15+
gemspec path: "../"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
module SupportTableData
4+
class Documentation
5+
# Create a new documentation generator for a configuration class.
6+
#
7+
# @param config_class [Class] The configuration class to generate documentation for
8+
def initialize(klass)
9+
@klass = klass
10+
end
11+
12+
# Generate YARD documentation class definition for the model's helper methods.
13+
#
14+
# @return [String, nil] The YARD documentation class definition, or nil if no named instances
15+
def class_def_with_yard_docs
16+
instance_names = klass.instance_names
17+
return nil if instance_names.empty?
18+
19+
generate_yard_class(instance_names)
20+
end
21+
22+
# Generate YARD documentation comment for named instance singleton method.
23+
#
24+
# @param name [String] The name of the instance method.
25+
# @return [String] The YARD comment text
26+
def instance_helper_yard_doc(name)
27+
<<~YARD
28+
# Find the #{name} record from the database.
29+
#
30+
# @return [#{klass.name}] the #{name} record
31+
# @raise [ActiveRecord::RecordNotFound] if the record does not exist
32+
# @!method self.#{name}
33+
YARD
34+
end
35+
36+
# Generate YARD documentation comment for the predicate method for the named instance.
37+
#
38+
# @param name [String] The name of the instance method.
39+
# @return [String] The YARD comment text
40+
def predicate_helper_yard_doc(name)
41+
<<~YARD
42+
# Check if this record is the #{name} record.
43+
#
44+
# @return [Boolean] true if this is the #{name} record, false otherwise
45+
# @!method #{name}?
46+
YARD
47+
end
48+
49+
# Generate YARD documentation comment for the attribute method helper for the named instance.
50+
#
51+
# @param name [String] The name of the instance method.
52+
# @return [String] The YARD comment text
53+
def attribute_helper_yard_doc(name, attribute_name)
54+
<<~YARD
55+
# Get the #{name} record's #{attribute_name}.
56+
#
57+
# @return [Object] the #{name} record's #{attribute_name}
58+
# @!method #{name}_#{attribute_name}
59+
YARD
60+
end
61+
62+
private
63+
64+
attr_reader :klass
65+
66+
def generate_yard_class(instance_names)
67+
return nil if instance_names.empty?
68+
69+
yard_lines = ["class #{klass.name}"]
70+
71+
# Generate docs for each named instance
72+
instance_names.sort.each_with_index do |name, index|
73+
yard_lines << "" unless index.zero?
74+
instance_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" }
75+
yard_lines << ""
76+
predicate_helper_yard_doc(name).each_line(chomp: true) { |line| yard_lines << " #{line}" }
77+
klass.support_table_attribute_helpers.each do |attribute_name|
78+
yard_lines << ""
79+
attribute_helper_yard_doc(name, attribute_name).each_line(chomp: true) { |line| yard_lines << " #{line}" }
80+
end
81+
end
82+
83+
yard_lines << "end"
84+
85+
yard_lines.join("\n")
86+
end
87+
end
88+
end

lib/support_table_data/railtie.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
module SupportTableData
44
class Railtie < Rails::Railtie
5+
config.support_table_data_directory = "db/support_tables"
6+
7+
initializer "support_table_data" do |app|
8+
SupportTableData.data_directory ||= app.config.support_table_data_directory
9+
end
10+
511
rake_tasks do
612
load File.expand_path("../tasks/support_table_data.rake", __dir__)
713
end

lib/tasks/support_table_data.rake

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,9 @@
33
namespace :support_table_data do
44
desc "Syncronize data for all models that include SupportTableData."
55
task sync: :environment do
6-
# Eager load models if we are in a Rails enviroment with eager loading turned off.
7-
if defined?(Rails.application)
8-
unless Rails.application.config.eager_load
9-
if defined?(Rails.application.eager_load!)
10-
Rails.application.eager_load!
11-
elsif defined?(Rails.autoloaders.zeitwerk_enabled?) && Rails.autoloaders.zeitwerk_enabled?
12-
Rails.autoloaders.each(&:eager_load)
13-
else
14-
warn "Could not eager load models; some support table data may not load"
15-
end
16-
end
17-
end
6+
require_relative "utils"
7+
8+
SupportTableData::Tasks::Utils.eager_load!
189

1910
logger_callback = lambda do |name, started, finished, unique_id, payload|
2011
klass = payload[:class]
@@ -31,4 +22,35 @@ namespace :support_table_data do
3122
SupportTableData.sync_all!
3223
end
3324
end
25+
26+
desc "Adds YARD documentation comments to models to document the named instance methods."
27+
task add_yard_docs: :environment do
28+
require_relative "../support_table_data/documentation"
29+
require_relative "utils"
30+
31+
SupportTableData::Tasks::Utils.eager_load!
32+
33+
ActiveRecord::Base.descendants.each do |klass|
34+
next unless klass.included_modules.include?(SupportTableData)
35+
next if klass.instance_names.empty?
36+
37+
doc = SupportTableData::Documentation.new(klass)
38+
class_def_with_docs = doc.class_def_with_yard_docs
39+
next unless class_def_with_docs
40+
41+
file_path = SupportTableData::Tasks::Utils.model_file_path(klass)
42+
next unless file_path&.file? && file_path.readable?
43+
44+
begin_comment = "# Begin autogenerated YARD docs"
45+
end_comment = "# End autogenerated YARD docs"
46+
47+
file_contents = File.read(file_path)
48+
updated_contents = file_contents.sub(/#{begin_comment}.*#{end_comment}/m, "").strip
49+
updated_contents = "#{updated_contents}\n\n#{begin_comment}\n#{class_def_with_docs}\n#{end_comment}\n"
50+
next if file_contents == updated_contents
51+
52+
File.write(file_path, updated_contents)
53+
puts "Added YARD documentation to #{klass.name}."
54+
end
55+
end
3456
end

lib/tasks/utils.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module SupportTableData
4+
module Tasks
5+
module Utils
6+
class << self
7+
# Helper for eager loading a Rails application.
8+
def eager_load!
9+
return unless defined?(Rails.application.config.eager_load)
10+
return if Rails.application.config.eager_load
11+
12+
if defined?(Rails.application.eager_load!)
13+
Rails.application.eager_load!
14+
elsif defined?(Rails.autoloaders.zeitwerk_enabled?) && Rails.autoloaders.zeitwerk_enabled?
15+
Rails.autoloaders.each(&:eager_load)
16+
else
17+
raise "Failed to eager load application."
18+
end
19+
end
20+
21+
def model_file_path(klass)
22+
file_path = "#{klass.name.underscore}.rb"
23+
model_path = nil
24+
25+
Rails.application.config.paths["app/models"].each do |path_prefix|
26+
path = Pathname.new(path_prefix.to_s).join(file_path)
27+
if path&.file? && path.readable?
28+
model_path = path
29+
break
30+
end
31+
end
32+
33+
model_path
34+
end
35+
end
36+
end
37+
end
38+
end

0 commit comments

Comments
 (0)