sm-model-extension: Extend Engine Models from Host App
Add custom behavior to SourceMonitor engine models without monkey-patching.
When to Use
- Adding associations, scopes, or methods to
Source,Item, or other engine models - Adding custom validations to engine models
- Changing the database table name prefix
- Understanding how
ModelExtensions.registerworks - Debugging model extension issues
Extension Mechanism
SourceMonitor uses ModelExtensions.register to apply host-defined concerns and validations to engine models at load time. When SourceMonitor.configure runs, it calls ModelExtensions.reload! to re-apply all extensions.
Flow
1. Host app defines concern modules and validations
2. config/initializers/source_monitor.rb registers them:
config.models.source.include_concern "MyApp::SourceExtension"
config.models.source.validate :custom_check
3. SourceMonitor.configure { |c| ... } runs
4. ModelExtensions.reload! applies all concerns and validations
5. Engine models now have the extended behavior
Available Extension Points
Extendable Models
| Config Accessor | Engine Model | DB Table |
|---|---|---|
config.models.source |
SourceMonitor::Source |
sourcemon_sources |
config.models.item |
SourceMonitor::Item |
sourcemon_items |
config.models.fetch_log |
SourceMonitor::FetchLog |
sourcemon_fetch_logs |
config.models.scrape_log |
SourceMonitor::ScrapeLog |
sourcemon_scrape_logs |
config.models.health_check_log |
SourceMonitor::HealthCheckLog |
sourcemon_health_check_logs |
config.models.item_content |
SourceMonitor::ItemContent |
sourcemon_item_contents |
config.models.log_entry |
SourceMonitor::LogEntry |
sourcemon_log_entries |
Table Name Prefix
config.models.table_name_prefix = "sm_" # Changes all tables from sourcemon_* to sm_*
Default: "sourcemon_"
Including Concerns
Three forms supported:
# 1. String (lazy constantization -- recommended for autoloaded modules)
config.models.source.include_concern "MyApp::SourceMonitor::SourceExtensions"
# 2. Module reference (immediate)
config.models.source.include_concern MyApp::SourceMonitor::SourceExtensions
# 3. Anonymous block (creates Module.new)
config.models.source.include_concern do
has_many :tags, dependent: :destroy, foreign_key: :source_id
scope :tagged, ->(tag) { joins(:tags).where(tags: { name: tag }) }
end
Concerns are deduplicated by signature -- including the same concern twice is safe.
Adding Validations
Two forms:
# 1. Symbol -- method name (must be defined in a concern or the model)
config.models.source.validate :enforce_custom_rules
# 2. Callable (proc/lambda) -- receives the record
config.models.source.validate ->(record) {
record.errors.add(:url, "must be HTTPS") unless record.url&.start_with?("https://")
}
# With validation options
config.models.source.validate :check_plan_limits, on: :create
Creating a Host Extension
Step 1: Define the Concern
# app/models/concerns/my_app/source_monitor/source_extensions.rb
module MyApp
module SourceMonitor
module SourceExtensions
extend ActiveSupport::Concern
included do
# Associations
has_many :source_tags, class_name: "MyApp::SourceTag",
foreign_key: :source_monitor_source_id, dependent: :destroy
# Scopes
scope :by_team, ->(team_id) { where(team_id: team_id) }
scope :premium, -> { where(premium: true) }
# Callbacks
after_create :notify_team
end
# Instance methods
def team_name
team&.name || "Unassigned"
end
private
def notify_team
TeamNotifier.source_added(self) if team_id.present?
end
end
end
end
Step 2: Register in Configuration
# config/initializers/source_monitor.rb
SourceMonitor.configure do |config|
config.models.source.include_concern "MyApp::SourceMonitor::SourceExtensions"
config.models.source.validate :validate_team_assignment
config.models.item.include_concern "MyApp::SourceMonitor::ItemExtensions"
end
Step 3: Add Database Columns (if needed)
If your extension requires new columns on engine tables, create a migration in the host app:
# db/migrate/YYYYMMDDHHMMSS_add_team_to_sourcemon_sources.rb
class AddTeamToSourcemonSources < ActiveRecord::Migration[8.0]
def change
add_column :sourcemon_sources, :team_id, :bigint
add_index :sourcemon_sources, :team_id
end
end
How ModelExtensions Works Internally
Registration (ModelExtensions.register)
Called by each engine model during class loading:
# In SourceMonitor::Source (simplified)
SourceMonitor::ModelExtensions.register(self, :source)
This:
- Looks up the
ModelDefinitionfor the given key - Sets
table_namebased ontable_name_prefix + base_table - Includes all registered concerns (deduped by signature)
- Applies all registered validations
Reload (ModelExtensions.reload!)
Called by SourceMonitor.configure after the block runs. Re-applies all extensions to all registered models. Safe to call multiple times.
Concern Deduplication
Concerns are tracked by signature:
- Named module:
[:module, object_id] - String constant:
[:constant, "MyApp::SourceExtensions"] - Anonymous block:
[:anonymous_module, block.object_id]
Validation Management
Extension validations are tracked separately from model-native validations. On reload:
- Previous extension validations are removed
- New extension validations are applied
- Model-native validations are untouched
Limitations and Gotchas
Table name prefix is global -- changing it affects all engine tables. Must match existing migration table names or you need to rename tables.
Concern order matters -- concerns are included in registration order. If concern B depends on an association from concern A, register A first.
Anonymous blocks create new modules -- each
configurecall with a block creates a new anonymous module. In development with code reloading, this is fine becausereload!re-applies everything.Validations with symbols require the method to exist on the model. Define it in a concern and register the concern before the validation.
Foreign keys -- when adding associations to engine models, use explicit
foreign_keyandclass_nameoptions to avoid namespace confusion.Engine table names -- always reference tables by their prefixed name (e.g.,
sourcemon_sources), not the model name.
Key Source Files
| File | Purpose |
|---|---|
lib/source_monitor/model_extensions.rb |
Registration, reload, apply logic |
lib/source_monitor/configuration/models.rb |
Models config with MODEL_KEYS |
lib/source_monitor/configuration/model_definition.rb |
Per-model concern + validation storage |
lib/source_monitor/configuration/validation_definition.rb |
Validation wrapper |
lib/source_monitor.rb |
configure and reset_configuration! |
References
reference/extension-api.md-- Detailed API referencedocs/configuration.md-- Configuration documentation (Model Extensions section)
Testing
require "test_helper"
module TestExtensions
extend ActiveSupport::Concern
included do
scope :test_scope, -> { where.not(url: nil) }
end
def test_method
"extended"
end
end
class ModelExtensionTest < ActiveSupport::TestCase
setup do
SourceMonitor.reset_configuration!
end
test "include_concern adds methods to source" do
SourceMonitor.configure do |config|
config.models.source.include_concern TestExtensions
end
source = create_source!
assert_equal "extended", source.test_method
assert_respond_to SourceMonitor::Source, :test_scope
end
test "validate adds custom validation" do
SourceMonitor.configure do |config|
config.models.source.validate ->(record) {
record.errors.add(:base, "test error")
}
end
source = SourceMonitor::Source.new
source.valid?
assert_includes source.errors[:base], "test error"
end
test "table_name_prefix changes table names" do
SourceMonitor.configure do |config|
config.models.table_name_prefix = "custom_"
end
SourceMonitor::ModelExtensions.reload!
assert_equal "custom_sources", SourceMonitor::Source.table_name
end
end
Checklist
- Extension concern defined under
app/models/concerns/ - Concern registered via
config.models.<model>.include_concern - Custom validations registered via
config.models.<model>.validate - Foreign keys use explicit
foreign_key:option - Host migration created for any new columns on engine tables
- Tests verify extension behavior
- Tests use
SourceMonitor.reset_configuration!in setup - Extension is idempotent (safe to apply multiple times)