Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
bundle config set gemfile "gemfiles/${{ matrix.appraisal }}.gemfile"
- name: Install gems
run: |
bundle update
bundle install
- name: Run Tests
run: bundle exec rake
- name: standardrb
Expand Down
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ 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
## 1.5.0

### Added

- The default data directory in a Rails application can be set with the `config.support_table_data_directory` option in the Rails application configuration.
- 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.
- The data synchronization task is now automatically attached to several Rails tasks: `db:seed`, `db:seed:replant`, `db:prepare`, `db:test:prepare`, `db:fixtures:load`. Support tables will be synced after running any of these tasks. This can be disabled by setting `config.support_table.auto_sync = false` in the Rails application configuration.

### Changed

- 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.
- 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.
- The `support_table_key_attribute` method now returns the primary key of the model if not explicitly set instead of implicitly interpreting `nil` as the primary key. This makes the behavior more consistent and explicit.

## 1.4.0

Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ gemspec

gem "rspec", "~> 3.0"
gem "rake"
gem "irb"
gem "sqlite3"
gem "appraisal"
gem "standard", "~>1.0"
Expand Down
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ This gem provides a mixin for ActiveRecord support table models that allows you

These kinds of models blur the line between data and code. You'll often end up with constants and application logic based on specific values that need to exist in the table. By using this gem, you can easily define methods for loading and comparing specific instances. This can give you cleaner code that reads far more naturally. You can also avoid defining dozens of constants or referencing magic values (i.e. no more hard-coded strings or ids in the code to look up specific records).

## Table of Contents

- [Usage](#usage)
- [Specifying Data Files](#specifying-data-files)
- [Named Instances](#named-instances)
- [Documenting Named Instance Helpers](#documenting-named-instance-helpers)
- [Caching](#caching)
- [Loading Data](#loading-data)
- [Testing](#testing)
- [Installation](#installation)
- [Contributing](#contributing)
- [License](#license)

## Usage

In the examples below, suppose we have a simple `Status` model in which each row has an id and a name, and the name can only have a handful of statuses: "Pending", "In Progress", and "Completed".
Expand Down Expand Up @@ -57,7 +70,7 @@ You cannot update the value of the key attribute in a record in the data file. I

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.
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.

Expand Down Expand Up @@ -212,6 +225,9 @@ class Thing < ApplicationRecord
end
```

> [!TIP]
> The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications.

### Loading Data

Calling `sync_table_data!` on your model class will synchronize the data in the database table with the values from the data files.
Expand Down Expand Up @@ -260,18 +276,33 @@ end

If you use a method to set a `has_many` association on your model, you **must** set the `autosave` option to `true` on the association (see the above example). This will ensure the association records are always saved even if there were no changes to the parent record.

You need to call `SupportTableData.sync_all!` when deploying your application. This gem includes a rake task `support_table_data:sync` that is suitable for hooking into deploy scripts. An easy way to hook it into a Rails application is by enhancing the `db:migrate` task so that the sync task runs immediately after database migrations are run. You can do this by adding code to a Rakefile in your application's `lib/tasks` directory:
You will need to call `SupportTableData.sync_all!` when deploying your application or running your test suite. This gem includes a rake task `support_table_data:sync` that is suitable for hooking into deploy or CI scripts.

This task is automatically run whenever you run any of these Rails tasks so if these are already part of your deploy or CI scripts, then no additional setup is required:

- `db:seed`
- `db:seed:replant`
- `db:prepare`
- `db:test:prepare`
- `db:fixtures:load`

You can disable these task enhancements by setting `config.support_table.auto_sync = false` in your Rails application configuration.

> [!TIP]
> If you also want to hook into the `db:migrate` task so that syncs are run immediately after database migrations, you can do this by adding code to a Rakefile in your application's `lib/tasks` directory. Migrations do funny things with the database connection especially when using multiple databases so you need to re-establish the connection before syncing the support table data.

```ruby
if Rake::Task.task_defined?("db:migrate")
Rake::Task["db:migrate"].enhance do
# The main database connection may have artifacts from the migration, so re-establish it
# to get a clean connection before syncing support table data.
ActiveRecord::Base.establish_connection

Rake::Task["support_table_data:sync"].invoke
end
end
```

Enhancing the `db:migrate` task also ensures that local development environments will stay up to date.

### Testing

You must also call `SupportTableData.sync_all!` before running your test suite. This method should be called in the test suite setup code after any data in the test database has been purged and before any tests are run.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.4.1
1.5.0
99 changes: 57 additions & 42 deletions lib/support_table_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ module SupportTableData
@support_table_instance_keys = nil
@support_table_dependencies = []

# Define the attribute used as the key of the hash in the data files.
# This should be a value that never changes. By default the key attribute will be the id.
class_attribute :support_table_key_attribute, instance_accessor: false
# Private class attribute to hold the key attribute name. Use `support_table_key_attribute` instead.
# @private
class_attribute :_support_table_key_attribute, instance_accessor: false
class << self
private :_support_table_key_attribute=
private :_support_table_key_attribute
end

# Define the directory where data files should be loaded from. This value will override the global
# value set by SupportTableData.data_directory. This is only used if relative paths are passed
Expand All @@ -30,6 +34,17 @@ module SupportTableData
end

class_methods do
# Define the attribute used as the key of the hash in the data files.
# This should be an attribute with values that never change.
# By default the key attribute will be the table's primary key.
def support_table_key_attribute=(attribute_name)
self._support_table_key_attribute = attribute_name&.to_s
end

def support_table_key_attribute
_support_table_key_attribute || primary_key
end

# Synchronize the rows in the table with the values defined in the data files added with
# `add_support_table_data`. Note that rows will not be deleted if they are no longer in
# the data files.
Expand All @@ -38,36 +53,41 @@ module SupportTableData
def sync_table_data!
return unless table_exists?

key_attribute = (support_table_key_attribute || primary_key).to_s
canonical_data = support_table_data.each_with_object({}) { |attributes, hash| hash[attributes[key_attribute].to_s] = attributes }
records = where(key_attribute => canonical_data.keys)
canonical_data = support_table_data.each_with_object({}) do |attributes, hash|
hash[attributes[support_table_key_attribute].to_s] = attributes
end
records = where(support_table_key_attribute => canonical_data.keys)
changes = []

ActiveSupport::Notifications.instrument("support_table_data.sync", class: self) do
transaction do
records.each do |record|
key = record[key_attribute].to_s
attributes = canonical_data.delete(key)
attributes&.each do |name, value|
record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
begin
ActiveSupport::Notifications.instrument("support_table_data.sync", class: self) do
transaction do
records.each do |record|
key = record[support_table_key_attribute].to_s
attributes = canonical_data.delete(key)
attributes&.each do |name, value|
record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
end
if support_table_record_changed?(record)
changes << record.changes
record.save!
end
end
if support_table_record_changed?(record)

canonical_data.each_value do |attributes|
class_name = attributes[inheritance_column]
klass = class_name ? sti_class_for(class_name) : self
record = klass.new
attributes.each do |name, value|
record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
end
changes << record.changes
record.save!
end
end

canonical_data.each_value do |attributes|
class_name = attributes[inheritance_column]
klass = class_name ? sti_class_for(class_name) : self
record = klass.new
attributes.each do |name, value|
record.send(:"#{name}=", value) if record.respond_to?(:"#{name}=", true)
end
changes << record.changes
record.save!
end
end
rescue ActiveRecord::RecordInvalid => e
raise SupportTableData::ValidationError.new(e.record)
end

changes
Expand Down Expand Up @@ -118,14 +138,12 @@ def support_table_attribute_helpers
# @return [Array<Hash>] List of attributes for all records in the data files.
def support_table_data
data = {}
key_attribute = (support_table_key_attribute || primary_key).to_s

@support_table_data_files.each do |data_file_path|
file_data = support_table_parse_data_file(data_file_path)
file_data = file_data.values if file_data.is_a?(Hash)
file_data = Array(file_data).flatten
file_data.each do |attributes|
key_value = attributes[key_attribute].to_s
key_value = attributes[support_table_key_attribute].to_s
existing = data[key_value]
if existing
existing.merge!(attributes)
Expand Down Expand Up @@ -173,23 +191,21 @@ def instance_names
# @return [ActiveRecord::Base] The instance loaded from the database.
# @raise [ActiveRecord::RecordNotFound] If the instance does not exist.
def named_instance(instance_name)
key_attribute = (support_table_key_attribute || primary_key).to_s
instance_name = instance_name.to_s
find_by!(key_attribute => @support_table_instance_names[instance_name])
find_by!(support_table_key_attribute => @support_table_instance_names[instance_name])
end

# Get the key values for all instances loaded from the data files.
#
# @return [Array] List of all the key attribute values.
def instance_keys
if @support_table_instance_keys.nil?
key_attribute = (support_table_key_attribute || primary_key).to_s
values = []
support_table_data.each do |attributes|
key_value = attributes[key_attribute]
key_value = attributes[support_table_key_attribute]
instance = new
instance.send(:"#{key_attribute}=", key_value)
values << instance.send(key_attribute)
instance.send(:"#{support_table_key_attribute}=", key_value)
values << instance.send(support_table_key_attribute)
end
@support_table_instance_keys = values.uniq
end
Expand All @@ -200,14 +216,12 @@ def instance_keys
#
# @return [Boolean]
def protected_instance?(instance)
key_attribute = (support_table_key_attribute || primary_key).to_s

unless defined?(@protected_keys)
keys = support_table_data.collect { |attributes| attributes[key_attribute].to_s }
keys = support_table_data.collect { |attributes| attributes[support_table_key_attribute].to_s }
@protected_keys = keys
end

@protected_keys.include?(instance[key_attribute].to_s)
@protected_keys.include?(instance[support_table_key_attribute].to_s)
end

# Explicitly define other support tables that this model depends on. A support table depends
Expand Down Expand Up @@ -251,12 +265,11 @@ def define_support_table_named_instance_methods(name, attributes)
raise ArgumentError.new("Cannot define named instance #{method_name} on #{name}; name contains illegal characters")
end

key_attribute = (support_table_key_attribute || primary_key).to_s
key_value = attributes[key_attribute]
key_value = attributes[support_table_key_attribute]

unless @support_table_instance_names.include?(method_name)
define_support_table_instance_helper(method_name, key_attribute, key_value)
define_support_table_predicates_helper("#{method_name}?", key_attribute, key_value)
define_support_table_instance_helper(method_name, support_table_key_attribute, key_value)
define_support_table_predicates_helper("#{method_name}?", support_table_key_attribute, key_value)
@support_table_instance_names = @support_table_instance_names.merge(method_name => key_value)
end

Expand Down Expand Up @@ -467,6 +480,8 @@ def protected_instance?
end
end

require_relative "support_table_data/validation_error"

if defined?(Rails::Railtie)
require_relative "support_table_data/railtie"
end
21 changes: 18 additions & 3 deletions lib/support_table_data/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@

module SupportTableData
class Railtie < Rails::Railtie
config.support_table_data_directory = "db/support_tables"
unless config.respond_to?(:support_table) && config.support_table
config.support_table = ActiveSupport::OrderedOptions.new
end

config.support_table.data_directory ||= "db/support_tables"
config.support_table.auto_sync ||= true

initializer "support_table_data" do |app|
SupportTableData.data_directory ||= app.root.join(app.config.support_table_data_directory).to_s
SupportTableData.data_directory ||= app.root.join(app.config.support_table&.data_directory).to_s
end

rake_tasks do
rake_tasks do |app|
load File.expand_path("../tasks/support_table_data.rake", __dir__)

if app.config.support_table.auto_sync
["db:seed", "db:seed:replant", "db:prepare", "db:test:prepare", "db:fixtures:load"].each do |task_name|
next unless Rake::Task.task_defined?(task_name)

Rake::Task[task_name].enhance do
Rake::Task["support_table_data:sync"].invoke
end
end
end
end
end
end
16 changes: 16 additions & 0 deletions lib/support_table_data/validation_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module SupportTableData
# Error class that is raised when validation fails when loading support table data.
# It provides more context than the standard ActiveRecord::RecordInvalid to help identify
# which record caused the validation failure.
class ValidationError < StandardError
def initialize(invalid_record)
key_attribute = invalid_record.class.support_table_key_attribute
key_value = invalid_record[key_attribute]
message = "Validation failed for #{invalid_record.class} with #{key_attribute}: #{key_value.inspect} - " \
"#{invalid_record.errors.full_messages.join(", ")}"
super(message)
end
end
end
2 changes: 1 addition & 1 deletion spec/models/color.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Color < ActiveRecord::Base
# Intentionally invalid association
belongs_to :non_existent, class_name: "NonExistent"

validates_uniqueness_of :name
validates :name, presence: true, uniqueness: true

def group_name=(value)
self.group = Group.named_instance(value)
Expand Down
10 changes: 9 additions & 1 deletion spec/support_table_data_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,19 @@
expect(Polygon.rectangle).to be_a Rectangle
expect(Polygon.pentagon).to be_a Polygon
end

it "honors the single table inheritance column when creating new records" do
allow(Polygon).to receive(:support_table_data).and_return([
{"name" => "Triangle", "type" => "Triangle", "side_count" => 4}
])
expect { Polygon.sync_table_data! }.to raise_error(ActiveRecord::RecordInvalid)
expect { Polygon.sync_table_data! }.to raise_error(SupportTableData::ValidationError)
end

it "reraises validation errors with more context" do
allow(Color).to receive(:support_table_data).and_return([
{"id" => 20, "name" => nil, "hex" => "0x123456"}
])
expect { Color.sync_table_data! }.to raise_error(SupportTableData::ValidationError, /Validation failed for Color with id: 20 - Name can't be blank/)
end
end

Expand Down
5 changes: 5 additions & 0 deletions test_app/app/models/application_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
7 changes: 7 additions & 0 deletions test_app/app/models/secondary_application_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class SecondaryApplicationRecord < ApplicationRecord
self.abstract_class = true

connects_to database: {writing: :secondary, reading: :secondary}
end
Loading