A long-lived project that still receives updates
Redefine a class method on a subclass to return a new value, so hashes, arrays and sets declared on a parent class can be added to by each subclass while the parent keeps its own copy. Built for DSLs that collect attributes, options or callbacks at the class level. Also includes a helper for creating named classes inside a module.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

inheritance-helper

Class-level settings that subclasses can extend without changing their parents.

CI Coverage Branch Coverage Gem Version

API reference · Changelog · Upgrading to 1.0

DSLs often collect data at the class level: attributes, options, callbacks. With a class instance variable, subclasses don't see the parent's data. With a class variable (@@attributes), every class in the chain shares and changes the same object. inheritance-helper avoids both: each call replaces the class method on the class it's called on, so a subclass starts with its parent's value, adds to it, and the parent keeps its own.

It has no dependencies. It is used by schema-model to collect attribute definitions.

Installation

Requires Ruby 3.3 or newer. Add this line to your application's Gemfile:

gem 'inheritance-helper'

And then execute:

$ bundle install

Usage

Extend a class with InheritanceHelper::Methods and define a class method that returns the starting value. Freezing the value is recommended: values built from a frozen value are frozen too, so no class can change another's data by mutating it.

require 'inheritance-helper'

class Model
  extend InheritanceHelper::Methods

  def self.attributes
    {}.freeze
  end

  def self.attribute(name, type)
    add_value_to_class_method(:attributes, name => type)
    attr_accessor name
  end
end

class Person < Model
  attribute :name, :string
  attribute :phone, :string
end

class Employee < Person
  attribute :employee_id, :integer
end

Model.attributes     # => {}
Person.attributes    # => {name: :string, phone: :string}
Employee.attributes  # => {name: :string, phone: :string, employee_id: :integer}

add_value_to_class_method

Redefines a class method to return its current value with more added:

  • a Hash is merged with the new hash
  • an Array or Set is combined with Array(value), so an array of values adds each one and the result stays flat
class Base
  extend InheritanceHelper::Methods

  def self.fields = [].freeze
  def self.tags = Set.new.freeze
end

class Child < Base
  add_value_to_class_method :fields, :name
  add_value_to_class_method :fields, %i[email phone]
  add_value_to_class_method :tags, :admin
end

Child.fields  # => [:name, :email, :phone]
Child.tags    # => Set[:admin]
Base.fields   # => []

append_value_to_class_method

Redefines a class method to return its current array with one element appended. Unlike add_value_to_class_method, an array is added as a single element:

class Base
  extend InheritanceHelper::Methods

  def self.callbacks = [].freeze

  def self.before_save(*methods)
    append_value_to_class_method(:callbacks, methods)
  end
end

class Child < Base
  before_save :normalize, :validate
  before_save :log
end

Child.callbacks  # => [[:normalize, :validate], [:log]]

redefine_class_method

Replaces a class method with one that returns the given value. Use it for settings that are replaced rather than added to:

class Base
  extend InheritanceHelper::Methods

  def self.table_name = 'records'

  def self.table(name)
    redefine_class_method(:table_name, name.freeze)
  end
end

class User < Base
  table 'users'
end

User.table_name  # => "users"
Base.table_name  # => "records"

The method returns the same object on every call, so freeze values that callers shouldn't change. The new method keeps the visibility of the one it replaces, so a private class method stays private.

InheritanceHelper::Methods.redefine_class_method(klass, method, value) does the same for a class that doesn't extend the module.

Copy nested values before changing them

Once a value is set with these helpers, every call returns the same object, and a subclass starts with its parent's object. Freezing covers only the top-level value: a hash or array inside it is shared by the parent and the subclass, so changing it in place changes both:

class Base
  extend InheritanceHelper::Methods

  def self.config = {}.freeze

  add_value_to_class_method :config, skip: []
end

class Child < Base
  # wrong: appends to Base.config[:skip] too
  config[:skip] << :logs

  # right: build a new nested value and replace the key
  add_value_to_class_method :config, skip: config[:skip] + [:logs]
end

The same applies to a value from redefine_class_method: dup the value (and any nested values you change) before changing it, then pass the copy to redefine_class_method.

Notes

  • The class method must already return a value: add_value_to_class_method and append_value_to_class_method call it to get the current value, and raise a TypeError naming the method when it returns nil.
  • Each call defines a method on the class's singleton class. Classes declared at load time (the usual DSL case) are fine; redefining class methods from several threads at once is not synchronized.

ClassBuilder::Utils

InheritanceHelper::ClassBuilder::Utils creates named classes at runtime, which is useful for DSLs that build nested classes (such as a has_many :items do ... end block):

module Shop; end

klass = InheritanceHelper::ClassBuilder::Utils.create_class(Shop, :line_item, nil, 'Schema', nil) do
  attr_accessor :quantity
end

klass       # => Shop::SchemaLineItem
klass.name  # => "Shop::SchemaLineItem"

InheritanceHelper::ClassBuilder::Utils.get_class_name(:line_item, 'Has', 'Class')
# => "HasLineItemClass"

create_class(base_module, name, base_class, prefix, suffix, &block) subclasses base_class (or Object when nil), assigns it to the constant in base_module, and evaluates the block in the new class. If the constant already exists it is replaced, with Ruby's "already initialized constant" warning.

get_class_name splits the name on underscores and capitalizes the first letter of each part, so line_items becomes LineItems. The result is the same whether or not ActiveSupport is loaded (before 0.3.0 it used String#classify, which singularized the name; see UPGRADING.md).

Development

bundle install
bundle exec rspec      # tests, with line and branch coverage in coverage/
bundle exec rubocop    # lint
bundle exec yard doc   # API docs in doc/

CI requires 100% line and branch coverage, no RuboCop offenses, and every public method documented with YARD.

Releases are automated with release-please: conventional commits on master (fix:, feat:) update a release PR, and merging it tags the release and publishes the gem.

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

The gem is available as open source under the terms of the MIT License.