├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .standard.yml ├── Appraisals ├── CODEOWNERS ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── 7.1.gemfile ├── 7.2.gemfile └── 8.0.gemfile ├── lib ├── static_association.rb └── static_association │ └── version.rb ├── spec ├── spec_helper.rb └── static_association_spec.rb └── static_association.gemspec /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: '3.1' 14 | bundler-cache: true 15 | - run: bundle exec standardrb 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | ruby: ['3.2', '3.3', '3.4'] 11 | gemfile: ['7.1', '7.2', '8.0'] 12 | 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | - run: bundle exec rspec 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.gemfile.lock 3 | *.rbc 4 | .bundle 5 | .config 6 | .yardoc 7 | .tool-versions 8 | Gemfile.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | vendor/bundle 21 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | fix: false 2 | parallel: false 3 | format: progress 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "7.1" do 2 | gem "activesupport", "7.1" 3 | end 4 | 5 | appraise "7.2" do 6 | gem "activesupport", "7.2" 7 | end 8 | 9 | appraise "8.0" do 10 | gem "activesupport", "8.0" 11 | end 12 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | # Global rule: 15 | 16 | * @sidane 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in static_association.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Oliver Nightingale 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StaticAssociation 2 | 3 | ![test](https://github.com/thoughtbot/static_association/actions/workflows/test.yml/badge.svg) 4 | ![lint](https://github.com/thoughtbot/static_association/actions/workflows/lint.yml/badge.svg) 5 | 6 | Adds basic ActiveRecord-like associations to static data. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem "static_association" 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install static_association 23 | 24 | ## Usage 25 | 26 | ### Static Models 27 | 28 | Create your static association class: 29 | 30 | ```ruby 31 | class Day 32 | include StaticAssociation 33 | 34 | attr_accessor :name 35 | 36 | record id: 0 do |day| 37 | day.name = :monday 38 | end 39 | end 40 | ``` 41 | 42 | Calling `record` will allow you to create an instance of this static model, 43 | a unique id is mandatory. The newly created object is yielded to the passed 44 | block. 45 | 46 | The `Day` class will gain the following methods: 47 | 48 | - `.all`: returns all the static records defined in the class. 49 | - `.ids`: returns an array of all the ids of the static records. 50 | - `.find`: accepts a single id and returns the matching record. If the record 51 | does not exist, a `RecordNotFound` error is raised. 52 | - `.find_by_id`: behaves similarly to the `.find` method, except it returns 53 | `nil` when a record does not exist. 54 | - `.find_by`: finds the first record matching the specified conditions. If no 55 | record is found, returns `nil`. 56 | - `.where`: accepts an array of ids and returns all records with matching ids. 57 | 58 | ### Associations 59 | 60 | Currently just a `belongs_to` association can be created. This behaviour can be 61 | mixed into an `ActiveRecord` model: 62 | 63 | ```ruby 64 | class Event < ActiveRecord::Base 65 | extend StaticAssociation::AssociationHelpers 66 | 67 | belongs_to_static :day 68 | end 69 | ``` 70 | 71 | This assumes your model has a field `day_id`. 72 | 73 | ## Contributing 74 | 75 | 1. Fork it 76 | 2. Create your feature branch (`git checkout -b my-new-feature`) 77 | 3. Commit your changes (`git commit -am 'Add some feature'`) 78 | 4. Run lint checks and tests (`bundle exec rake`) 79 | 5. Push to the branch (`git push origin my-new-feature`) 80 | 6. Create new Pull Request 81 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "appraisal" 4 | require "standard/rake" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: [:standard, :spec] 9 | -------------------------------------------------------------------------------- /gemfiles/7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "7.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "7.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activesupport", "8.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/static_association.rb: -------------------------------------------------------------------------------- 1 | require "static_association/version" 2 | require "active_support/concern" 3 | require "active_support/core_ext/module/delegation" 4 | require "active_support/core_ext/hash/keys" 5 | require "active_support/core_ext/string/inflections" 6 | 7 | module StaticAssociation 8 | extend ActiveSupport::Concern 9 | 10 | class ArgumentError < StandardError; end 11 | 12 | class DuplicateID < StandardError; end 13 | 14 | class RecordNotFound < StandardError; end 15 | 16 | class UndefinedAttribute < StandardError; end 17 | 18 | attr_reader :id 19 | 20 | private 21 | 22 | def initialize(id) 23 | @id = id 24 | end 25 | 26 | module ClassMethods 27 | include Enumerable 28 | 29 | delegate :each, to: :all 30 | 31 | def index 32 | @index ||= {} 33 | end 34 | 35 | def all 36 | index.values 37 | end 38 | 39 | def ids 40 | index.keys 41 | end 42 | 43 | def find(id) 44 | find_by_id(id) or raise RecordNotFound.new( 45 | "Couldn't find DummyClass with 'id'=#{id}" 46 | ) 47 | end 48 | 49 | def find_by_id(id) 50 | index[ 51 | Integer(id, exception: false) || id 52 | ] 53 | end 54 | 55 | def where(id: []) 56 | all.select { |record| id.include?(record.id) } 57 | end 58 | 59 | def find_by(**args) 60 | args.any? or raise ArgumentError 61 | 62 | all.find { |record| matches_attributes?(record: record, attributes: args) } 63 | end 64 | 65 | def record(settings, &block) 66 | settings.assert_valid_keys(:id) 67 | id = settings.fetch(:id) 68 | 69 | if index.has_key?(id) 70 | raise DuplicateID.new("Duplicate record with 'id'=#{id} found") 71 | end 72 | 73 | record = new(id) 74 | record.instance_exec(record, &block) if block 75 | index[id] = record 76 | end 77 | 78 | private 79 | 80 | def matches_attributes?(record:, attributes:) 81 | attributes.all? do |attribute, value| 82 | record.respond_to?(attribute) or raise UndefinedAttribute.new( 83 | "Undefined attribute '#{attribute}'" 84 | ) 85 | 86 | record.public_send(attribute) == value 87 | end 88 | end 89 | end 90 | 91 | module AssociationHelpers 92 | def belongs_to_static(name, opts = {}) 93 | class_name = opts.fetch(:class_name, name.to_s.camelize) 94 | 95 | send(:define_method, name) do 96 | foreign_key = send(:"#{name}_id") 97 | class_name.constantize.find_by_id(foreign_key) 98 | end 99 | 100 | send(:define_method, "#{name}=") do |assoc| 101 | send(:"#{name}_id=", assoc.id) 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/static_association/version.rb: -------------------------------------------------------------------------------- 1 | module StaticAssociation 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | require "bundler/setup" 8 | 9 | require "static_association" 10 | 11 | RSpec.configure do |config| 12 | config.order = "random" 13 | end 14 | -------------------------------------------------------------------------------- /spec/static_association_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "static_association" 3 | 4 | class DummyClass 5 | include StaticAssociation 6 | attr_accessor :name 7 | end 8 | 9 | class AssociationClass 10 | attr_accessor :dummy_class_id 11 | attr_accessor :dodo_class_id 12 | 13 | extend StaticAssociation::AssociationHelpers 14 | belongs_to_static :dummy_class 15 | belongs_to_static :dodo_class, class_name: "DummyClass" 16 | end 17 | 18 | RSpec.describe StaticAssociation do 19 | after do 20 | DummyClass.instance_variable_set(:@index, {}) 21 | end 22 | 23 | describe ".record" do 24 | it "adds a record" do 25 | expect { DummyClass.record(id: 1) { self.name = "test" } } 26 | .to change(DummyClass, :count).by(1) 27 | end 28 | 29 | context "when using `self`" do 30 | it "assigns attributes" do 31 | record = DummyClass.record(id: 1) { self.name = "test" } 32 | 33 | expect(record.id).to eq(1) 34 | expect(record.name).to eq("test") 35 | end 36 | end 37 | 38 | context "when using the object" do 39 | it "assigns attributes" do 40 | record = DummyClass.record(id: 1) { |i| i.name = "test" } 41 | 42 | expect(record.id).to eq(1) 43 | expect(record.name).to eq("test") 44 | end 45 | end 46 | 47 | context "when the id is a duplicate" do 48 | it "raises an error" do 49 | DummyClass.record(id: 1) { self.name = "test0" } 50 | 51 | expect { DummyClass.record(id: 1) { self.name = "test1" } } 52 | .to raise_error( 53 | StaticAssociation::DuplicateID, 54 | "Duplicate record with 'id'=1 found" 55 | ) 56 | end 57 | end 58 | 59 | context "when an attribute is not defined" do 60 | it "raises an error" do 61 | expect { DummyClass.record(id: 1) { self.foo = "bar" } } 62 | .to raise_error(NoMethodError) 63 | end 64 | end 65 | 66 | context "when a key is invalid" do 67 | it "raises an error" do 68 | expect { DummyClass.record(id: 1, foo: "bar") } 69 | .to raise_error(ArgumentError) 70 | end 71 | end 72 | 73 | context "without a block" do 74 | it "adds a record" do 75 | expect { DummyClass.record(id: 1) }.to change(DummyClass, :count).by(1) 76 | end 77 | end 78 | end 79 | 80 | describe ".all" do 81 | it "returns all records" do 82 | record1 = DummyClass.record(id: 1) 83 | record2 = DummyClass.record(id: 2) 84 | 85 | records = DummyClass.all 86 | 87 | expect(records).to contain_exactly(record1, record2) 88 | end 89 | end 90 | 91 | describe ".ids" do 92 | it "returns array of ids for all records" do 93 | _record1 = DummyClass.record(id: 1) 94 | _record2 = DummyClass.record(id: 2) 95 | 96 | ids = DummyClass.ids 97 | 98 | expect(ids).to contain_exactly(1, 2) 99 | end 100 | end 101 | 102 | describe ".find" do 103 | context "when the record exists" do 104 | it "returns the record" do 105 | record = DummyClass.record(id: 1) 106 | 107 | found_record = DummyClass.find(1) 108 | 109 | expect(found_record).to eq(record) 110 | end 111 | end 112 | 113 | context "when the record does not exist" do 114 | it "raises an error" do 115 | expect { DummyClass.find(1) } 116 | .to raise_error( 117 | StaticAssociation::RecordNotFound, 118 | "Couldn't find DummyClass with 'id'=1" 119 | ) 120 | end 121 | end 122 | end 123 | 124 | describe ".find_by_id" do 125 | context "when the record exists" do 126 | it "returns the record" do 127 | record = DummyClass.record(id: 1) 128 | 129 | found_record = DummyClass.find_by_id(1) 130 | 131 | expect(found_record).to eq(record) 132 | end 133 | end 134 | 135 | context "when the record does not exist" do 136 | it "returns nil" do 137 | found_record = DummyClass.find_by_id(1) 138 | 139 | expect(found_record).to be_nil 140 | end 141 | end 142 | 143 | context "when id argument is a numeric string" do 144 | it "returns the record" do 145 | record = DummyClass.record(id: 1) 146 | 147 | found_record = DummyClass.find_by_id("1") 148 | 149 | expect(found_record).to eq(record) 150 | end 151 | end 152 | 153 | context "when id argument is a non-numeric string" do 154 | it "returns the record" do 155 | DummyClass.record(id: 1) 156 | 157 | found_record = DummyClass.find_by_id("foo") 158 | 159 | expect(found_record).to be_nil 160 | end 161 | end 162 | 163 | context "when record ids are strings and id argument matches a record" do 164 | it "returns the record" do 165 | record = DummyClass.record(id: "foo") 166 | 167 | found_record = DummyClass.find_by_id("foo") 168 | 169 | expect(found_record).to eq(record) 170 | end 171 | end 172 | 173 | context "when record ids are strings and id argument doesn't match a record" do 174 | it "returns nil" do 175 | DummyClass.record(id: "foo") 176 | 177 | found_record = DummyClass.find_by_id("bar") 178 | 179 | expect(found_record).to be_nil 180 | end 181 | end 182 | end 183 | 184 | describe ".where" do 185 | it "returns all records with the given ids" do 186 | record1 = DummyClass.record(id: 1) 187 | _record2 = DummyClass.record(id: 2) 188 | record3 = DummyClass.record(id: 3) 189 | 190 | results = DummyClass.where(id: [1, 3, 4]) 191 | 192 | expect(results).to contain_exactly(record1, record3) 193 | end 194 | 195 | describe ".find_by" do 196 | context "when record exists with the specified attribute value" do 197 | it "returns the record" do 198 | record1 = DummyClass.record(id: 1) do |r| 199 | r.name = "foo" 200 | end 201 | _record2 = DummyClass.record(id: 2) do |r| 202 | r.name = "bar" 203 | end 204 | 205 | found_record = DummyClass.find_by(name: "foo") 206 | 207 | expect(found_record).to eq(record1) 208 | end 209 | end 210 | 211 | context "when no record exists that matches the specified attribute value" do 212 | it "returns nil" do 213 | DummyClass.record(id: 1) do |r| 214 | r.name = "foo" 215 | end 216 | 217 | found_record = DummyClass.find_by(name: "bar") 218 | 219 | expect(found_record).to be_nil 220 | end 221 | end 222 | 223 | context "when multiple records match the specified attribute value" do 224 | it "returns the first matching record" do 225 | record1 = DummyClass.record(id: 1) do |r| 226 | r.name = "foo" 227 | end 228 | _record2 = DummyClass.record(id: 2) do |r| 229 | r.name = "foo" 230 | end 231 | 232 | found_record = DummyClass.find_by(name: "foo") 233 | 234 | expect(found_record).to eq(record1) 235 | end 236 | end 237 | 238 | context "when specifying multiple attribute values" do 239 | it "returns the record matching all attributes" do 240 | _record1 = DummyClass.record(id: 1) do |r| 241 | r.name = "foo" 242 | end 243 | record2 = DummyClass.record(id: 2) do |r| 244 | r.name = "foo" 245 | end 246 | 247 | found_record = DummyClass.find_by(id: 2, name: "foo") 248 | 249 | expect(found_record).to eq(record2) 250 | end 251 | end 252 | 253 | context "when specifying multiple attribute values but no record " \ 254 | "matches all attributes" do 255 | it "returns nil" do 256 | _record1 = DummyClass.record(id: 1) do |r| 257 | r.name = "foo" 258 | end 259 | 260 | found_record = DummyClass.find_by(id: 1, name: "bar") 261 | 262 | expect(found_record).to be_nil 263 | end 264 | end 265 | 266 | context "with undefined attributes" do 267 | it "raises a StaticAssociation::UndefinedAttribute" do 268 | DummyClass.record(id: 1) 269 | 270 | expect { 271 | DummyClass.find_by(undefined_attribute: 1) 272 | }.to raise_error( 273 | StaticAssociation::UndefinedAttribute, 274 | "Undefined attribute 'undefined_attribute'" 275 | ) 276 | end 277 | end 278 | 279 | context "with no attributes" do 280 | it "raises a StaticAssociation::ArgumentError" do 281 | expect { 282 | DummyClass.find_by 283 | }.to raise_error(StaticAssociation::ArgumentError) 284 | end 285 | end 286 | end 287 | end 288 | 289 | describe ".belongs_to_static" do 290 | it "defines a reader method for the association" do 291 | associated_class = AssociationClass.new 292 | allow(DummyClass).to receive(:find_by_id) 293 | 294 | associated_class.dummy_class 295 | 296 | expect(DummyClass).to have_received(:find_by_id) 297 | end 298 | 299 | context "when `class_name` is specified" do 300 | it "defines a reader method for the association" do 301 | associated_class = AssociationClass.new 302 | allow(DummyClass).to receive(:find_by_id) 303 | 304 | associated_class.dodo_class 305 | 306 | expect(DummyClass).to have_received(:find_by_id) 307 | end 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /static_association.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "static_association/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "static_association" 7 | spec.version = StaticAssociation::VERSION 8 | spec.authors = ["Oliver Nightingale"] 9 | spec.email = ["oliver.nightingale1@gmail.com"] 10 | spec.description = "StaticAssociation adds a simple enum type that can act like an ActiveRecord association for static data." 11 | spec.summary = "ActiveRecord like associations for static data" 12 | spec.license = "MIT" 13 | spec.homepage = "https://github.com/thoughtbot/static_association" 14 | 15 | spec.files = `git ls-files`.split($/) 16 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 17 | spec.require_paths = ["lib"] 18 | 19 | spec.add_dependency "activesupport", ">= 7.1.0" 20 | 21 | spec.add_development_dependency "rspec" 22 | spec.add_development_dependency "appraisal" 23 | spec.add_development_dependency "standard" 24 | end 25 | --------------------------------------------------------------------------------