├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── console ├── lib ├── modularity.rb └── modularity │ ├── as_trait.rb │ └── version.rb ├── modularity.gemspec └── spec ├── modularity └── as_trait_spec.rb └── spec_helper.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 'on': 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | jobs: 11 | test: 12 | runs-on: ubuntu-24.04 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - ruby: 2.5.7 18 | gemfile: Gemfile 19 | - ruby: 2.6.10 20 | gemfile: Gemfile 21 | - ruby: 2.7.4 22 | gemfile: Gemfile 23 | - ruby: 3.0.2 24 | gemfile: Gemfile 25 | - ruby: 3.1.2 26 | gemfile: Gemfile 27 | - ruby: 3.2.1 28 | gemfile: Gemfile 29 | - ruby: 3.3.6 30 | gemfile: Gemfile 31 | - ruby: 3.4.1 32 | gemfile: Gemfile 33 | env: 34 | BUNDLE_GEMFILE: "${{ matrix.gemfile }}" 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Install ruby 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: "${{ matrix.ruby }}" 41 | - name: Bundle 42 | run: | 43 | gem install bundler:2.3.27 44 | bundle install --no-deployment 45 | - name: Run tests 46 | run: bundle exec rspec 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | pkg 3 | *.gem 4 | .idea 5 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.7 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 5 | 6 | ## Unreleased 7 | 8 | ### Breaking changes 9 | 10 | ### Compatible changes 11 | 12 | - Add support for Ruby 3.3 13 | - Add support for Ruby 3.4 14 | 15 | ## 3.2.0 - 2023-03-01 16 | 17 | ### Compatible changes 18 | 19 | - Add support for Ruby 3.2 20 | 21 | 22 | ## 3.1.0 - 2022-06-01 23 | 24 | ### Compatible changes 25 | 26 | - Add support for separation of positional and keyword arguments in ruby 3 27 | 28 | 29 | ## 3.0.1 - 2022-03-09 30 | 31 | ### Compatible changes 32 | 33 | - Activate Rubygems MFA 34 | 35 | 36 | ## 3.0.0 - 2021-08-24 37 | 38 | ### Breaking changes 39 | 40 | - Removed migration guide for modularity version 1. 41 | - Removed support for Ruby < `2.5.0`. 42 | 43 | ### Compatible changes 44 | 45 | - Added this CHANGELOG file. 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | gem 'rspec' 7 | gem 'pry-byebug' 8 | gem 'gemika' 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | modularity (3.2.0) 5 | 6 | GEM 7 | remote: http://rubygems.org/ 8 | specs: 9 | byebug (11.1.3) 10 | coderay (1.1.3) 11 | diff-lcs (1.4.4) 12 | gemika (0.8.1) 13 | method_source (1.0.0) 14 | pry (0.13.1) 15 | coderay (~> 1.1) 16 | method_source (~> 1.0) 17 | pry-byebug (3.9.0) 18 | byebug (~> 11.0) 19 | pry (~> 0.13.0) 20 | rake (13.0.6) 21 | rspec (3.10.0) 22 | rspec-core (~> 3.10.0) 23 | rspec-expectations (~> 3.10.0) 24 | rspec-mocks (~> 3.10.0) 25 | rspec-core (3.10.1) 26 | rspec-support (~> 3.10.0) 27 | rspec-expectations (3.10.1) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.10.0) 30 | rspec-mocks (3.10.2) 31 | diff-lcs (>= 1.2.0, < 2.0) 32 | rspec-support (~> 3.10.0) 33 | rspec-support (3.10.2) 34 | 35 | PLATFORMS 36 | ruby 37 | x86_64-linux 38 | 39 | DEPENDENCIES 40 | gemika 41 | modularity! 42 | pry-byebug 43 | rake 44 | rspec 45 | 46 | BUNDLED WITH 47 | 2.3.27 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2009 Henning Koch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Tests](https://github.com/makandra/modularity/workflows/Tests/badge.svg)](https://github.com/makandra/modularity/actions) 2 | 3 | # Modularity - Traits and partial classes for Ruby 4 | 5 | Modularity enhances Ruby's [`Module`](http://apidock.com/ruby/Module) so it can be used traits and partial classes. 6 | This allows very simple definition of meta-programming macros like the 7 | `has_many` that you know from Rails. 8 | 9 | Modularity also lets you organize large models into multiple source files 10 | in a way that is less awkward than using modules. 11 | 12 | ## Installation 13 | 14 | Add the following to your `Gemfile`: 15 | 16 | ```ruby 17 | gem 'modularity' 18 | ``` 19 | 20 | Now run `bundle install`. 21 | 22 | ## Example 1: Easy meta-programming macros 23 | 24 | Ruby allows you to construct classes using meta-programming macros like 25 | `acts_as_tree` or `has_many :items`. These macros will add methods, 26 | callbacks, etc. to the calling class. However, right now Ruby (and Rails) makes it awkward to define 27 | such macros in your project as part of your application domain. 28 | 29 | Modularity allows you to extract common behaviour into reusable macros by defining traits with parameters. 30 | Your macros can live in your application, allowing you to express your application domain in both classes 31 | and macros. 32 | 33 | Here is an example of a `strip_field` macro, which created setter methods that remove leading and trailing whitespace from newly assigned values: 34 | 35 | ```ruby 36 | # app/models/article.rb 37 | class Article < ActiveRecord::Base 38 | include DoesStripFields[:name, :brand] 39 | end 40 | 41 | # app/models/shared/does_strip_fields.rb 42 | module DoesStripFields 43 | as_trait do |*fields| 44 | fields.each do |field| 45 | define_method("#{field}=") do |value| 46 | self[field] = value.strip 47 | end 48 | end 49 | end 50 | end 51 | ``` 52 | 53 | Notice the `as_trait` block. 54 | 55 | We like to add `app/models/shared` and `app/controllers/shared` to the load paths of our Rails projects. 56 | These are great places to store macros that are re-used from multiple classes. 57 | 58 | ## Example 2: Mixins with class methods 59 | 60 | Using a module to add both instance methods and class methods is 61 | [very awkward](http://redcorundum.blogspot.com/2006/06/mixing-in-class-methods.html). 62 | Modularity does away with the clutter and lets you say this: 63 | 64 | ```ruby 65 | # app/models/model.rb 66 | class Model 67 | include Mixin 68 | end 69 | 70 | # app/models/mixin.rb 71 | module Mixin 72 | as_trait do 73 | def instance_method 74 | # ... 75 | end 76 | def self.class_method 77 | # .. 78 | end 79 | end 80 | end 81 | ``` 82 | 83 | `private` and `protected` will also work as expected when defining a trait. 84 | 85 | ## Example 3: Splitting a model into multiple source files 86 | 87 | Models are often concerned with multiple themes like "authentication", "contact info" or "permissions", each requiring 88 | a couple of validations and callbacks here, and some method there. Modularity lets you organize your model into multiple 89 | partial classes, so each file can deal with a single aspect of your model: 90 | 91 | ```ruby 92 | # app/models/user.rb 93 | class User < ActiveRecord::Base 94 | include DoesAuthentication 95 | include DoesPermissions 96 | end 97 | 98 | # app/models/user/does_authentication.rb 99 | module User::DoesAuthentication 100 | as_trait do 101 | # methods, validations, etc. regarding usernames and passwords go here 102 | end 103 | end 104 | 105 | # app/models/user/does_permissions.rb 106 | module User::DoesPermissions 107 | as_trait do 108 | # methods, validations, etc. regarding contact information go here 109 | end 110 | end 111 | ``` 112 | 113 | Some criticism has been raised for splitting large models into files like this. 114 | Essentially, even though have an easier time navigating your code, you will still 115 | have one giant model with many side effects. 116 | 117 | There are [many better ways](http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/) 118 | to decompose a huge Ruby class. 119 | 120 | ## Development 121 | 122 | * Install Bundler 2 `gem install bundler:2.2.22` and run `bundle install` to have a working development setup. 123 | * Running tests for the current Ruby version: `bundle exec rake` 124 | * Running tests for all supported Ruby version: Push the changes to Github in a feature branch, open a merge request and have a look at the test matrix in Github actions 125 | 126 | ## Credits 127 | 128 | Henning Koch from [makandra.com](http://makandra.com/) 129 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'bundler/setup' 3 | require 'gemika/tasks' 4 | 5 | task default: 'matrix:spec' 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'modularity' 5 | require 'pry' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | Pry.start 10 | -------------------------------------------------------------------------------- /lib/modularity.rb: -------------------------------------------------------------------------------- 1 | require 'modularity/as_trait' 2 | -------------------------------------------------------------------------------- /lib/modularity/as_trait.rb: -------------------------------------------------------------------------------- 1 | module Modularity 2 | 3 | class ParametrizedTrait < Module 4 | 5 | def initialize(blank_trait, args, kwargs) 6 | @args = args 7 | @kwargs = kwargs 8 | @macro = blank_trait.instance_variable_get(:@modularity_macro) 9 | include(blank_trait) 10 | end 11 | 12 | def included(base) 13 | if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.7') 14 | base.class_exec(*@args, &@macro) 15 | else 16 | base.class_exec(*@args, **@kwargs, &@macro) 17 | end 18 | end 19 | 20 | end 21 | 22 | module AsTrait 23 | 24 | def as_trait(¯o) 25 | 26 | @modularity_macro = macro 27 | 28 | def self.included(base) 29 | unless base.is_a?(ParametrizedTrait) 30 | base.class_exec(&@modularity_macro) 31 | end 32 | 33 | end 34 | 35 | if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.7') 36 | def self.[](*args) 37 | blank_trait = self 38 | ParametrizedTrait.new(blank_trait, args, {}) 39 | end 40 | else 41 | def self.[](*args, **kwargs) 42 | blank_trait = self 43 | ParametrizedTrait.new(blank_trait, args, kwargs) 44 | end 45 | end 46 | 47 | end 48 | 49 | end 50 | end 51 | 52 | Module.send(:include, Modularity::AsTrait) 53 | -------------------------------------------------------------------------------- /lib/modularity/version.rb: -------------------------------------------------------------------------------- 1 | module Modularity 2 | VERSION = '3.2.0' 3 | end 4 | -------------------------------------------------------------------------------- /modularity.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'modularity/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'modularity' 7 | spec.version = Modularity::VERSION 8 | spec.required_ruby_version = '>= 2.5.0' 9 | spec.authors = ['Henning Koch'] 10 | spec.email = ['henning.koch@makandra.de'] 11 | 12 | spec.summary = 'Traits and partial classes for Ruby' 13 | spec.description = 'Traits and partial classes for Ruby' 14 | spec.homepage = 'https://github.com/makandra/modularity' 15 | spec.license = 'MIT' 16 | spec.metadata = { 'rubygems_mfa_required' => 'true' } 17 | 18 | # Specify which files should be added to the gem when it is released. 19 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 20 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 21 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 22 | end 23 | spec.bindir = 'exe' 24 | spec.executables = spec.files.grep(%r(^exe/)) { |f| File.basename(f) } 25 | spec.require_paths = ['lib'] 26 | 27 | # Development dependencies are defined in the Gemfile (therefore no `spec.add_development_dependency` directives) 28 | end 29 | -------------------------------------------------------------------------------- /spec/modularity/as_trait_spec.rb: -------------------------------------------------------------------------------- 1 | describe Modularity::AsTrait do 2 | 3 | describe '.included' do 4 | 5 | before :each do 6 | @doing_class = Class.new 7 | end 8 | 9 | describe 'without parameters' do 10 | 11 | it "applies the trait macro of the given module" do 12 | 13 | module DoesSome 14 | as_trait do 15 | some_trait_included 16 | end 17 | end 18 | 19 | @doing_class.should_receive(:some_trait_included) 20 | 21 | @doing_class.class_eval do 22 | include DoesSome 23 | end 24 | 25 | end 26 | 27 | it "applies the trait macro of the given namespaced module" do 28 | 29 | module Some 30 | module DoesChild 31 | as_trait do 32 | some_child_trait_included 33 | end 34 | end 35 | end 36 | 37 | @doing_class.should_receive(:some_child_trait_included) 38 | 39 | @doing_class.class_eval do 40 | include Some::DoesChild 41 | end 42 | 43 | end 44 | 45 | it "lets a trait define methods with different visibility" do 46 | 47 | module DoesVisibility 48 | as_trait do 49 | def public_method_from_trait 50 | end 51 | protected 52 | def protected_method_from_trait 53 | end 54 | private 55 | def private_method_from_trait 56 | end 57 | end 58 | end 59 | 60 | @doing_class.class_eval do 61 | include DoesVisibility 62 | end 63 | 64 | instance = @doing_class.new 65 | 66 | instance.public_methods.collect(&:to_s).should include("public_method_from_trait") 67 | instance.protected_methods.collect(&:to_s).should include("protected_method_from_trait") 68 | instance.private_methods.collect(&:to_s).should include("private_method_from_trait") 69 | 70 | end 71 | 72 | it 'appends methods outside the trait macro' do 73 | 74 | module HybridModule 75 | 76 | as_trait do 77 | define_method :trait_method do 78 | end 79 | end 80 | 81 | def vanilla_method 82 | end 83 | 84 | end 85 | 86 | @doing_class.class_eval do 87 | include HybridModule 88 | end 89 | 90 | instance = @doing_class.new 91 | 92 | instance.should respond_to(:trait_method) 93 | instance.should respond_to(:vanilla_method) 94 | 95 | end 96 | 97 | it 'applies multiple trait macros' do 98 | 99 | module FirstTrait 100 | as_trait do 101 | define_method :first do 102 | end 103 | end 104 | end 105 | 106 | module SecondTrait 107 | as_trait do 108 | define_method :second do 109 | end 110 | end 111 | end 112 | 113 | @doing_class.class_eval do 114 | include FirstTrait 115 | include SecondTrait 116 | end 117 | 118 | instance = @doing_class.new 119 | 120 | instance.should respond_to(:first) 121 | instance.should respond_to(:second) 122 | 123 | end 124 | 125 | end 126 | 127 | describe "with parameters" do 128 | 129 | it "it applies a trait macro with parameters" do 130 | 131 | module DoesCallMethod 132 | as_trait do |field| 133 | send(field) 134 | end 135 | end 136 | 137 | @doing_class.should_receive(:foo) 138 | @doing_class.class_eval do 139 | include DoesCallMethod[:foo] 140 | end 141 | 142 | end 143 | 144 | it "facilitates metaprogramming acrobatics" do 145 | 146 | module DoesDefineConstantMethod 147 | as_trait do |name, return_value| 148 | define_method name do 149 | return_value 150 | end 151 | end 152 | end 153 | 154 | @doing_class.class_eval do 155 | include DoesDefineConstantMethod["some_method", "some_return_value"] 156 | end 157 | 158 | instance = @doing_class.new 159 | instance.should respond_to(:some_method) 160 | instance.some_method.should == "some_return_value" 161 | end 162 | 163 | it "allies to call an unparametrized trait macro with an empty parameter list" do 164 | 165 | module DoesSome 166 | as_trait do 167 | some_trait_included 168 | end 169 | end 170 | 171 | @doing_class.should_receive(:some_trait_included) 172 | 173 | @doing_class.class_eval do 174 | include DoesSome[] 175 | end 176 | 177 | end 178 | 179 | it 'appends methods outside the trait macro' do 180 | 181 | module HybridModuleWithParameters 182 | 183 | as_trait do |name| 184 | define_method name do 185 | end 186 | end 187 | 188 | def vanilla_method 189 | end 190 | 191 | end 192 | 193 | @doing_class.class_eval do 194 | include HybridModuleWithParameters[:trait_method] 195 | end 196 | 197 | instance = @doing_class.new 198 | 199 | instance.should respond_to(:trait_method) 200 | instance.should respond_to(:vanilla_method) 201 | 202 | end 203 | 204 | it 'passes keyword args to the block given to as_trait' do 205 | 206 | module ModuleWithKeywordArgs 207 | as_trait do |hash, required_kwarg:, optional_kwarg: 'foo'| 208 | define_method :passed_hash do 209 | hash 210 | end 211 | 212 | define_method :required_keyword do 213 | required_kwarg 214 | end 215 | 216 | define_method :optional_keyword do 217 | optional_kwarg 218 | end 219 | 220 | end 221 | end 222 | 223 | @doing_class.class_eval do 224 | include ModuleWithKeywordArgs[{ first_hash_key: 'value_one', second_hash_key: 'value_two' }, required_kwarg: 'bar'] 225 | end 226 | 227 | instance = @doing_class.new 228 | instance.passed_hash.should eq({ first_hash_key: 'value_one', second_hash_key: 'value_two' }) 229 | instance.required_keyword.should eq('bar') 230 | instance.optional_keyword.should eq('foo') 231 | end 232 | 233 | end 234 | 235 | end 236 | 237 | end 238 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'modularity' 2 | 3 | RSpec.configure do |config| 4 | config.expect_with(:rspec) { |expects| expects.syntax = :should } 5 | config.mock_with(:rspec) { |mocks| mocks.syntax = [:should, :receive] } 6 | end 7 | --------------------------------------------------------------------------------