Cattri
Cattri is a minimal-footprint Ruby DSL for defining class-level and instance-level attributes with clarity, safety, and full visibility control โ without relying on ActiveSupport.
It offers subclass-safe inheritance, lazy or static defaults, optional coercion, and write-once (final
) semantics, while remaining lightweight and idiomatic.
โจ Features
- โ
Unified
cattri
API for both class and instance attributes - ๐ Tracks visibility:
public
,protected
,private
- ๐ Inheritance-safe attribute copying
- ๐งผ Lazy defaults or static values
- ๐ Write-once
final: true
support - ๐ Predicate support (
admin?
, etc.) - ๐ Introspection: list all attributes and methods
- ๐งช 100% test and branch coverage
- ๐ Zero runtime dependencies
๐ก Why Use Cattri?
Ruby’s built-in attribute helpers and Rails’ class_attribute
are either too limited or too invasive. Cattri offers:
Capability | Cattri | attr_* / cattr_* |
class_attribute (Rails) |
---|---|---|---|
Single DSL for class & instance attributes | โ | โ | โ |
Subclass-safe value & metadata inheritance | โ | โ | โ ๏ธ |
Visibility-aware (private , protected ) |
โ | โ | โ |
Lazy or static defaults | โ | โ ๏ธ | โ |
Optional coercion or transformation | โ | โ | โ ๏ธ |
Write-once (final: true ) semantics |
โ | โ | โ |
๐ Usage Examples
Cattri uses a single DSL method, cattri
, to define both class-level and instance-level attributes.
Use the scope:
option to indicate whether the attribute belongs to the class (:class
) or the instance (:instance
). If omitted, it defaults to :instance
.
class User include Cattri # Final class-level attribute cattri :type, :standard, final: true, scope: :class # Writable class-level attribute cattri :config, -> { {} }, scope: :class # Final instance-level attribute cattri :id, -> { SecureRandom.uuid }, final: true # Writable instance-level attributes cattri :name, "anonymous" do |value| value.to_s.capitalize # custom setter/coercer end cattri :admin, false, predicate: true def initialize(id) self.id = id # set the value for `cattri :id` end end # Class-level access User.type # => :standard User.config # => {} # Instance-level access user = User.new user.name # => "anonymous" user.admin? # => false user.id # => uuid
๐ Accessing Attributes Within the Class
class User include Cattri cattri :id, -> { SecureRandom.uuid }, final: true cattri :type, :standard, final: true, scope: :class def initialize(id) self.id = id # Sets instance-level attribute end def summary "#{self.class.type}-#{id}" # Accesses class-level and instance-level attributes end def self.default_type type # Same as self.type โ resolves on the singleton end end
๐งญ Attribute Scope
By default, attributes are defined per-instance. You can change this behavior using scope:
.
class Config include Cattri cattri :global_timeout, 30, scope: :class cattri :retries, 3 # implicitly scope: :instance end Config.global_timeout # => 30 instance = Config.new instance.retries # => 3 instance.global_timeout # => NoMethodError
scope: :class
defines the attribute on the class (i.e., the singleton).scope: :instance
(or omitting scope) defines the attribute per instance.
๐ก Final Attributes
class Settings include Cattri cattri :version, -> { "1.0.0" }, final: true, scope: :class end Settings.version # => "1.0.0" Settings.version = "2.0" # => Raises Cattri::AttributeError
final: true, scope: :class
defines a constant class-level attribute. It cannot be reassigned and uses the value provided at definition.final: true
(with instance scope) defines a write-once attribute. If not explicitly set during initialization, the default value will be used.
> Note: final_cattri
is a shorthand for cattri(..., final: true)
, included for API symmetry but not required.
๐ Attribute Exposure
The expose:
option controls what public methods are generated for an attribute. You can fine-tune whether the reader, writer, or neither is available.
class Profile include Cattri cattri :name, "guest", expose: :read_write cattri :token, "secret", expose: :read cattri :attempts, 0, expose: :write cattri :internal_flag, true, expose: :none end
Exposure Levels
:read_write
โ defines both reader and writer:read
โ defines a reader only:write
โ defines a writer only:none
โ defines no public methods (internal only)
> Predicate methods (admin?
, etc.) are enabled via predicate: true
.
๐ Visibility
Cattri respects Ruby’s public
, protected
, and private
scoping when defining methods. You can also explicitly override visibility using visibility:
.
class Document include Cattri private cattri :token protected cattri :internal_flag public cattri :title cattri :owner, "system", visibility: :protected end
- If defined inside a visibility scope, Cattri applies that visibility automatically
- Use
visibility:
to override the inferred scope - Applies only to generated methods (reader, writer, predicate), not internal store access
๐ Introspection
Enable introspection with:
User.with_cattri_introspection User.attributes # => [:type, :name, :admin] User.attribute(:type).final? # => true User.attribute_methods # => { type: [:type], name: [:name], admin: [:admin, :admin?] } User.attribute_source(:name) # => User
๐ฆ Installation
Add to your Gemfile:
gem "cattri"
Or via Bundler:
bundle add cattri
๐งฑ Design Overview
Cattri includes:
InternalStore
for final-safe value trackingContextRegistry
andContext
for method definition logicAttribute
andAttributeOptions
for metadata handlingVisibility
tracking for DSL-defined methodsInitializerPatch
for final attribute enforcement on#initialize
Dsl
forcattri
andfinal_cattri
Inheritance
to ensure subclass copying
๐งช Test Coverage
Cattri is tested with 100% line and branch coverage. All dynamic definitions are validated via RSpec, and edge cases are covered, including:
- Predicate methods
- Final value enforcement
- Class vs. instance scope
- Attribute inheritance
- Visibility and expose interaction
Contributing
- Fork the repo
bundle install
- Run the test suite with
bundle exec rake
- Submit a pull request โ ensure new code is covered and rubocop passes
License
This gem is released under the MIT License โ see LICENSE for details.
๐ Credits
Created with โค๏ธ by Nathan Lucas