├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── 5.2.gemfile ├── 6.0.gemfile ├── 6.1.gemfile └── 7.0.gemfile ├── lib ├── polymorpheus.rb └── polymorpheus │ ├── adapter.rb │ ├── interface.rb │ ├── interface │ ├── belongs_to_polymorphic.rb │ ├── has_many_as_polymorph.rb │ └── validates_polymorph.rb │ ├── interface_builder.rb │ ├── interface_builder │ └── association.rb │ ├── mysql_adapter.rb │ ├── postgresql_adapter.rb │ ├── railtie.rb │ ├── schema_dumper.rb │ ├── schema_statements.rb │ ├── trigger.rb │ └── version.rb ├── polymorpheus.gemspec └── spec ├── interface ├── belongs_to_polymorphic_spec.rb ├── has_many_as_polymorph_spec.rb └── validates_polymorph_spec.rb ├── interface_spec.rb ├── mysql2_adapter_spec.rb ├── schema_dumper_spec.rb ├── spec_helper.rb ├── support ├── class_defs.rb ├── connection_helpers.rb ├── custom_matchers.rb ├── schema_helpers.rb ├── sql_query_subscriber.rb └── sql_test_helpers.rb └── trigger_spec.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | services: 13 | mysql: 14 | image: mysql:5.7 15 | env: 16 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 17 | MYSQL_DATABASE: polymorpheus_test 18 | ports: 19 | - 3306:3306 20 | options: >- 21 | --health-cmd="mysqladmin ping" 22 | --health-interval=10s 23 | --health-timeout=5s 24 | --health-retries=3 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | gemfile: ['5.2', '6.0', '6.1', '7.0'] 30 | ruby-version: ['2.6', '2.7', '3.0'] 31 | exclude: 32 | - gemfile: '5.2' 33 | ruby-version: '2.7' 34 | - gemfile: '5.2' 35 | ruby-version: '3.0' 36 | - gemfile: '7.0' 37 | ruby-version: '2.6' 38 | 39 | env: 40 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 41 | 42 | steps: 43 | - name: Checkout Repository 44 | uses: actions/checkout@v2 45 | 46 | - name: Setup test database 47 | run: mysql --host 127.0.0.1 --port 3306 --user root -e 'CREATE DATABASE IF NOT EXISTS polymorpheus_test;' 48 | 49 | - name: Set up Ruby 50 | uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: ${{ matrix.ruby-version }} 53 | bundler-cache: true 54 | 55 | - name: Run tests 56 | env: 57 | DB_PORT: 3306 58 | DB_USER: root 59 | DB_PASSWORD: "" 60 | DB_NAME: polymorpheus_test 61 | run: bundle exec rspec spec 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rvmrc 2 | *.swp 3 | pkg/*.* 4 | Gemfile.lock 5 | gemfiles/*.gemfile.lock 6 | .tddium* 7 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise '5.2' do 2 | gem 'activerecord', '~> 5.2.0' 3 | gem 'mysql2', '~> 0.4.0' 4 | end 5 | 6 | appraise '6.0' do 7 | gem 'activerecord', '~> 6.0.3.3' 8 | gem 'mysql2', '~> 0.4.0' 9 | end 10 | 11 | appraise '6.1' do 12 | gem 'activerecord', '~> 6.1.2.1' 13 | gem 'mysql2', '~> 0.5.3' 14 | end 15 | 16 | appraise '7.0' do 17 | gem 'activerecord', '~> 7.0.0' 18 | gem 'mysql2', '~> 0.5.3' 19 | end 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'appraisal' 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT LICENSE 2 | 3 | Copyright (c) 2011 Wegowise Inc. 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 | [![Code Climate](https://codeclimate.com/github/wegowise/polymorpheus.png)](https://codeclimate.com/github/wegowise/polymorpheus) 2 | 3 | # Polymorpheus 4 | **Polymorphic relationships in Rails that keep your database happy with almost 5 | no setup** 6 | 7 | ### Installation 8 | 9 | If you are using Bundler, you can add the gem to your Gemfile: 10 | 11 | ```ruby 12 | gem 'polymorpheus' 13 | ``` 14 | 15 | ### Background 16 | * **What is polymorphism?** [Rails Guides has a great overview of what 17 | polymorphic relationships are and how Rails handles them]( 18 | http://guides.rubyonrails.org/association_basics.html#polymorphic-associations) 19 | 20 | * **If you don't think database constraints are important** then [here is a 21 | presentation that might change your mind]( 22 | http://bostonrb.org/presentations/databases-constraints-polymorphism). If 23 | you're still not convinced, this gem won't be relevant to you. 24 | 25 | * **What's wrong with Rails' built-in approach to polymorphism?** Using Rails, 26 | polymorphism is implemented in the database using a `type` column and an `id` 27 | column, where the `id` column references one of multiple other tables, 28 | depending on the `type`. This violates the basic principle that one column in 29 | a database should mean to one thing, and it prevents us from setting up any 30 | sort of database constraint on the `id` column. 31 | 32 | 33 | ## Basic Use 34 | 35 | We'll outline the use case to mirror the example [outline in the Rails Guides]( 36 | http://guides.rubyonrails.org/association_basics.html#polymorphic-associations): 37 | 38 | * You have a `Picture` object that can belong to an `Imageable`, where an 39 | `Imageable` is a polymorphic representation of either an `Employee` or a 40 | `Product`. 41 | 42 | With Polymorpheus, you would define this relationship as follows: 43 | 44 | **Database migration** 45 | 46 | ```ruby 47 | class SetUpPicturesTable < ActiveRecord::Migration 48 | def self.up 49 | create_table :pictures do |t| 50 | t.integer :employee_id 51 | t.integer :product_id 52 | end 53 | 54 | add_polymorphic_constraints 'pictures', 55 | { 'employee_id' => 'employees.id', 56 | 'product_id' => 'products.id' } 57 | end 58 | 59 | def self.down 60 | remove_polymorphic_constraints 'pictures', 61 | { 'employee_id' => 'employees.id', 62 | 'product_id' => 'products.id' } 63 | 64 | drop_table :pictures 65 | end 66 | end 67 | ``` 68 | 69 | **ActiveRecord model definitions** 70 | 71 | ```ruby 72 | class Picture < ActiveRecord::Base 73 | # takes same additional options as belongs_to 74 | belongs_to_polymorphic :employee, :product, :as => :imageable 75 | validates_polymorph :imageable 76 | end 77 | 78 | class Employee < ActiveRecord::Base 79 | # takes same additional options as has_many 80 | has_many_as_polymorph :pictures, inverse_of: employee 81 | end 82 | 83 | class Product < ActiveRecord::Base 84 | has_many_as_polymorph :pictures 85 | end 86 | ``` 87 | 88 | That's it! 89 | 90 | Now let's review what we've done. 91 | 92 | 93 | ## Database Migration 94 | 95 | * Instead of `imageable_type` and `imageable_id` columns in the pictures table, 96 | we've created explicit columns for the `employee_id` and `product_id` 97 | * The `add_polymorphic_constraints` call takes care of all of the database 98 | constraints you need, without you needing to worry about sql! Specifically it: 99 | * Creates foreign key relationships in the database as specified. So in this 100 | example, we have specified that the `employee_id` column in the `pictures` 101 | table should have a foreign key constraint with the `id` column of the 102 | `employees` table. 103 | * Creates appropriate triggers in our database that make sure that exactly one 104 | or the other of `employee_id` or `product_id` are specified for a given 105 | record. An exception will be raised if you try to save a database record 106 | that contains both or none of them. 107 | * **Options for migrations**: There are options to customize the foreign keys 108 | generated by Polymorpheus and add uniqueness constraints. For more info 109 | on this, [read the wiki entry](https://github.com/wegowise/polymorpheus/wiki/Migration-options). 110 | 111 | ## Model definitions 112 | 113 | * The `belongs_to_polymorphic` declaration in the `Picture` class specifies the 114 | polymorphic relationship. It provides all of the same methods that Rails does 115 | for its built-in polymorphic relationships, plus a couple additional features. 116 | See the Interface section below. 117 | * `validates_polymorph` declaration: checks that exactly one of the possible 118 | polymorphic relationships is specified. In this example, either an 119 | `employee_id` or `product_id` must be specified -- if both are nil or if both 120 | are non-nil a validation error will be added to the object. 121 | * The `has_many_as_polymorph` declaration generates a normal Rails `has_many` 122 | declaration, but adds a constraint that ensures that the correct records are 123 | retrieved. This means you can still use the same conditions with it that you 124 | would use with a `has_many` association (such as `:order`, `:class_name`, 125 | etc.). Specifically, the `has_many_as_polymorph` declaration in the `Employee` 126 | class of the example above is equivalant to 127 | `has_many :pictures, { product_id: nil }` 128 | and the `has_many_as_polymorph` declaration in the `Product` class is 129 | equivalent to `has_many :pictures, { employee_id: nil }` 130 | 131 | ## Requirements / Support 132 | 133 | * Currently the gem only supports MySQL. Please feel free to fork and submit a 134 | (well-tested) pull request if you want to add Postgres support. 135 | * For Rails 3.1+, you'll still need to use `up` and `down` methods in your 136 | migrations. 137 | 138 | ## Interface 139 | 140 | The nice thing about Polymorpheus is that under the hood it builds on top of the 141 | Rails conventions you're already used to which means that you can interface with 142 | your polymorphic relationships in simple, familiar ways. It also lets you 143 | introspect on the polymorphic associations. 144 | 145 | Let's use the example above to illustrate. 146 | 147 | ``` 148 | sam = Employee.create(name: 'Sam') 149 | nintendo = Product.create(name: 'Nintendo') 150 | 151 | pic = Picture.new 152 | => # 153 | 154 | pic.imageable 155 | => nil 156 | 157 | # The following two options are equivalent, just as they are normally with 158 | # ActiveRecord: 159 | # pic.employee = sam 160 | # pic.employee_id = sam.id 161 | 162 | # If we specify an employee, the imageable getter method will return that employee: 163 | pic.employee = sam; 164 | pic.imageable 165 | => # 166 | pic.employee 167 | => # 168 | pic.product 169 | => nil 170 | 171 | # If we specify a product, the imageable getting will return that product: 172 | Picture.new(product: nintendo).imageable 173 | => # 174 | 175 | # But, if we specify an employee and a product, the getter will know this makes 176 | # no sense and return nil for the imageable: 177 | Picture.new(employee: sam, product: nintendo).imageable 178 | => nil 179 | 180 | # A `polymorpheus` instance method is attached to your model that allows you 181 | # to introspect: 182 | 183 | pic.polymorpheus.associations 184 | => [ 185 | #, 186 | # 187 | ] 188 | 189 | pic.polymorpheus.associations.map(&:name) 190 | => ["employee", "product"] 191 | 192 | pic.polymorpheus.associations.map(&:key) 193 | => ["employee_id", "product_id"] 194 | 195 | pic.polymorpheus.active_association 196 | => #, 197 | 198 | pic.polymorpheus.query_condition 199 | => {"employee_id"=>"1"} 200 | ``` 201 | 202 | ## Credits and License 203 | 204 | * This gem was written by [Barun Singh](https://github.com/barunio) 205 | * Older, unsupported versions of this gem use [Foreigner gem](https://github.com/matthuhiggins/foreigner) 206 | under the hood for Rails < 4.2. 207 | 208 | polymorpheus is Copyright © 2011-2015 Barun Singh and [WegoWise]( 209 | http://wegowise.com). It is free software, and may be redistributed under the 210 | terms specified in the LICENSE file. 211 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'fileutils' 3 | 4 | def gemspec_name 5 | @gemspec_name ||= Dir['*.gemspec'][0] 6 | end 7 | 8 | def gemspec 9 | @gemspec ||= eval(File.read(gemspec_name), binding, gemspec_name) 10 | end 11 | 12 | desc "Build the gem" 13 | task :gem=>:gemspec do 14 | sh "gem build #{gemspec_name}" 15 | FileUtils.mkdir_p 'pkg' 16 | FileUtils.mv "#{gemspec.name}-#{gemspec.version}.gem", 'pkg' 17 | end 18 | 19 | desc "Install the gem locally" 20 | task :install => :gem do 21 | sh %{gem install pkg/#{gemspec.name}-#{gemspec.version}} 22 | end 23 | 24 | desc "Generate the gemspec" 25 | task :generate do 26 | puts gemspec.to_ruby 27 | end 28 | 29 | desc "Validate the gemspec" 30 | task :gemspec do 31 | gemspec.validate 32 | end 33 | 34 | desc 'Run tests' 35 | task :test do |t| 36 | sh 'rspec spec' 37 | end 38 | 39 | task :default => :test 40 | -------------------------------------------------------------------------------- /gemfiles/5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activerecord", "~> 5.2.0" 7 | gem "mysql2", "~> 0.4.0" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activerecord", "~> 6.0.3.3" 7 | gem "mysql2", "~> 0.4.0" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activerecord", "~> 6.1.2.1" 7 | gem "mysql2", "~> 0.5.3" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /gemfiles/7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "activerecord", "~> 7.0.0" 7 | gem "mysql2", "~> 0.5.3" 8 | 9 | gemspec path: "../" 10 | -------------------------------------------------------------------------------- /lib/polymorpheus.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | autoload :Adapter, 'polymorpheus/adapter' 3 | autoload :Interface, 'polymorpheus/interface' 4 | autoload :InterfaceBuilder, 'polymorpheus/interface_builder' 5 | autoload :Trigger, 'polymorpheus/trigger' 6 | autoload :SchemaDumper, 'polymorpheus/schema_dumper' 7 | 8 | module Interface 9 | autoload :BelongsToPolymorphic, 'polymorpheus/interface/belongs_to_polymorphic' 10 | autoload :ValidatesPolymorph, 'polymorpheus/interface/validates_polymorph' 11 | autoload :HasManyAsPolymorph, 'polymorpheus/interface/has_many_as_polymorph' 12 | end 13 | 14 | class InterfaceBuilder 15 | autoload :Association, 'polymorpheus/interface_builder/association' 16 | end 17 | 18 | module ConnectionAdapters 19 | autoload :SchemaStatements, 'polymorpheus/schema_statements' 20 | end 21 | end 22 | 23 | Polymorpheus::Adapter.register 'mysql2', 'polymorpheus/mysql_adapter' 24 | Polymorpheus::Adapter.register 'postgresql', 'polymorpheus/postgresql_adapter' 25 | 26 | require 'polymorpheus/railtie' if defined?(Rails) 27 | -------------------------------------------------------------------------------- /lib/polymorpheus/adapter.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | class Adapter 3 | 4 | class << self 5 | @@registered_adapters = {} 6 | 7 | def register(adapter_name, file_name) 8 | @@registered_adapters[adapter_name] = file_name 9 | end 10 | 11 | def load! 12 | if file = @@registered_adapters[configured_adapter] 13 | require file 14 | end 15 | end 16 | 17 | def configured_adapter 18 | if ActiveRecord::Base.respond_to?(:connection_db_config) 19 | ActiveRecord::Base.connection_db_config.adapter # ActiveRecord >= 6.1 20 | else 21 | ActiveRecord::Base.connection_pool.spec.config[:adapter] 22 | end 23 | end 24 | end 25 | 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module Interface 3 | class PolymorphicError < ::StandardError; end 4 | 5 | class InvalidTypeError < PolymorphicError 6 | def initialize(*accepted_classes) 7 | error = "Invalid type." 8 | error += " Must be one of {#{accepted_classes.join(', ')}}" 9 | super(error) 10 | end 11 | end 12 | 13 | class AmbiguousTypeError < PolymorphicError 14 | def initialize 15 | super("Ambiguous polymorphic interface or object type") 16 | end 17 | end 18 | 19 | def self.included(base) 20 | base.extend(BelongsToPolymorphic) 21 | base.extend(HasManyAsPolymorph) 22 | base.extend(ValidatesPolymorph) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface/belongs_to_polymorphic.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module Interface 3 | module BelongsToPolymorphic 4 | def belongs_to_polymorphic(*association_names, options) 5 | polymorphic_api = options.delete(:as) 6 | builder = Polymorpheus::InterfaceBuilder.new(polymorphic_api, 7 | association_names, 8 | options) 9 | 10 | # The POLYMORPHEUS_ASSOCIATIONS constant is useful for two reasons: 11 | # 12 | # 1. It is useful for other classes to be able to ask this class 13 | # about its polymorphic relationship. 14 | # 15 | # 2. It prevents a class from defining multiple polymorphic 16 | # relationships. Doing so would be a bad idea from a design 17 | # standpoint, and we don't want to allow for (and support) 18 | # that added complexity. 19 | # 20 | const_set('POLYMORPHEUS_ASSOCIATIONS', builder.association_names) 21 | 22 | # Set belongs_to associations 23 | builder.associations.each do |association| 24 | belongs_to association.name.to_sym, **association.options 25 | end 26 | 27 | # Exposed interface for introspection 28 | define_method 'polymorpheus' do 29 | builder.exposed_interface(self) 30 | end 31 | 32 | # Getter method 33 | define_method polymorphic_api do 34 | builder.get_associated_object(self) 35 | end 36 | 37 | # Setter method 38 | define_method "#{polymorphic_api}=" do |object_to_associate| 39 | builder.set_associated_object(self, object_to_associate) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface/has_many_as_polymorph.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module Interface 3 | module HasManyAsPolymorph 4 | def has_many_as_polymorph(association, scope = nil, options = {}) 5 | if scope.instance_of?(Hash) 6 | options = scope 7 | scope = nil 8 | end 9 | 10 | options.symbolize_keys! 11 | fkey = name.foreign_key 12 | 13 | class_name = options[:class_name] || association.to_s.classify 14 | 15 | conditions = proc do 16 | keys = class_name.constantize 17 | .const_get('POLYMORPHEUS_ASSOCIATIONS') 18 | .map(&:foreign_key) 19 | keys.delete(fkey) 20 | 21 | nil_columns = keys.reduce({}) { |hash, key| hash.merge!(key => nil) } 22 | 23 | relation = where(nil_columns) 24 | relation = scope.call.merge(relation) unless scope.nil? 25 | relation 26 | end 27 | 28 | has_many association, conditions, **options 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface/validates_polymorph.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module Interface 3 | module ValidatesPolymorph 4 | def validates_polymorph(polymorphic_api) 5 | validate Proc.new { 6 | unless polymorpheus.active_association 7 | association_names = polymorpheus.associations.map(&:name) 8 | errors.add(:base, "You must specify exactly one of the following: "\ 9 | "{#{association_names.join(', ')}}") 10 | end 11 | } 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface_builder.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module Polymorpheus 4 | class InterfaceBuilder 5 | 6 | attr_reader :interface_name, 7 | :associations 8 | 9 | def initialize(interface_name, association_names, options) 10 | @interface_name = interface_name 11 | @associations = association_names.map do |association_name| 12 | Polymorpheus::InterfaceBuilder::Association.new(association_name, options) 13 | end 14 | end 15 | 16 | def exposed_interface(calling_object) 17 | OpenStruct.new( 18 | associations: associations, 19 | active_association: active_association(calling_object), 20 | query_condition: query_condition(calling_object) 21 | ) 22 | end 23 | 24 | def association_keys 25 | @association_keys ||= associations.map(&:key) 26 | end 27 | 28 | def association_names 29 | @association_names ||= associations.map(&:name) 30 | end 31 | 32 | def active_association(calling_object) 33 | active_associations = associations.select do |association| 34 | # If the calling object has a non-nil value for the association 35 | # key, we know it has an active associatin without having to 36 | # make a database query to retrieve the associated object itself. 37 | # 38 | # If it has a nil value for the association key, we then ask if 39 | # it has a non-nil result for the association itself, since it 40 | # may have an active association that has not yet been saved to 41 | # the database. 42 | # 43 | calling_object.public_send(association.key).present? || 44 | calling_object.public_send(association.name).present? 45 | end 46 | 47 | active_associations.first if active_associations.length == 1 48 | end 49 | 50 | def active_association_key(calling_object) 51 | association = active_association(calling_object) 52 | return unless association 53 | 54 | association.key if calling_object.public_send(association.key) 55 | end 56 | 57 | def query_condition(calling_object) 58 | key = active_association_key(calling_object) 59 | object = calling_object.public_send(key) if key 60 | 61 | { key.to_s => object } if object 62 | end 63 | 64 | def get_associated_object(calling_object) 65 | association = active_association(calling_object) 66 | calling_object.public_send(association.name) if association 67 | end 68 | 69 | def set_associated_object(calling_object, object_to_associate) 70 | association = get_relevant_association_for_object(object_to_associate) 71 | calling_object.public_send("#{association.name}=", object_to_associate) 72 | 73 | (associations - [association]).each do |association| 74 | calling_object.public_send("#{association.name}=", nil) 75 | end 76 | end 77 | 78 | def get_relevant_association_for_object(object_to_associate) 79 | match = associations.select do |association| 80 | object_to_associate.is_a?(association.association_class) 81 | end 82 | 83 | if match.blank? 84 | raise Polymorpheus::Interface::InvalidTypeError, association_names 85 | elsif match.length > 1 86 | raise Polymorpheus::Interface::AmbiguousTypeError 87 | end 88 | 89 | match.first 90 | end 91 | 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/polymorpheus/interface_builder/association.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | class InterfaceBuilder 3 | class Association 4 | 5 | include ActiveSupport::Inflector 6 | 7 | attr_reader :name, 8 | :key, 9 | :options 10 | 11 | def initialize(name, options) 12 | @name, @options = name.to_s.downcase, options 13 | @key = "#{@name}_id" 14 | end 15 | 16 | # The association class may not be loaded at the time this object 17 | # is initialized, so we can't set it via an accessor in the initializer. 18 | def association_class 19 | @association_class ||= name.classify.constantize 20 | end 21 | 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/polymorpheus/mysql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module ConnectionAdapters 3 | module MysqlAdapter 4 | 5 | INSERT = 'INSERT' 6 | UPDATE = 'UPDATE' 7 | 8 | # See the README for more information about the use of these methods. 9 | # 10 | # table: a string equal to the name of the db table 11 | # 12 | # columns: a hash, with keys equal to the column names in the table we 13 | # are operating on, and values indicating the foreign key 14 | # association through the form "table.column". 15 | # 16 | # For example: 17 | # 18 | # { 19 | # 'employee_id' => 'employees.ssn', 20 | # 'product_id' => 'products.id' 21 | # } 22 | # 23 | # This indicates that the `employee_id` column in `table` 24 | # should have a foreign key constraint connecting it to the 25 | # `ssn` column in the `employees` table, and the `product_id` 26 | # column should have a foreign key constraint with the `id` 27 | # column in the `products` table. 28 | # 29 | # options: a hash, accepting the following options 30 | # 31 | # :unique 32 | # 33 | # If the columns hash was specified as above, and :unique is true: 34 | # 35 | # { :unique => true } 36 | # 37 | # Then this creates a uniqueness constraint in the database that 38 | # will ensure that any given employee_id can only be in the table 39 | # once, and that any given product_id can only be in the table 40 | # once. 41 | # 42 | # Alternatively, you can supply a column name or array of column 43 | # names to the :unique option: 44 | # 45 | # { :unique => 'picture_url' } 46 | # 47 | # This will allow an employee_id (or product_id) to appear multiple 48 | # times in the table, but no two employee IDs would be able to have 49 | # the same picture_url. 50 | # 51 | # :on_delete 52 | # 53 | # Action that happens ON DELETE. Valid values are :nullify, 54 | # :cascade and :restrict. 55 | # 56 | # :on_update 57 | # 58 | # Action that happens ON UPDATE. Valid values are :nullify, 59 | # :cascade and :restrict. 60 | 61 | 62 | def add_polymorphic_constraints(table, columns, options={}) 63 | column_names = columns.keys.sort 64 | add_polymorphic_triggers(table, column_names) 65 | options.symbolize_keys! 66 | if options[:unique].present? 67 | poly_create_indexes(table, column_names, Array(options[:unique])) 68 | end 69 | 70 | column_names.each do |col_name| 71 | ref_table, ref_col = columns[col_name].to_s.split('.') 72 | fk_options = { 73 | :column => col_name, 74 | :name => "#{table}_#{col_name}_fk", 75 | :primary_key => (ref_col || 'id' ) 76 | }.merge(generate_constraints(options)) 77 | add_foreign_key(table, ref_table, **fk_options) 78 | end 79 | end 80 | 81 | def remove_polymorphic_constraints(table, columns, options = {}) 82 | poly_drop_triggers(table, columns.keys.sort) 83 | columns.each do |(col, reference)| 84 | remove_foreign_key table, :column => col, :name => "#{table}_#{col}_fk" 85 | end 86 | if options[:unique].present? 87 | poly_remove_indexes(table, columns.keys, Array(options[:unique])) 88 | end 89 | end 90 | 91 | def triggers 92 | execute("show triggers").collect {|t| Polymorpheus::Trigger.new(t) } 93 | end 94 | 95 | # 96 | # DO NOT USE THIS METHOD DIRECTLY 97 | # 98 | # it will not create the foreign key relationships you want. the only 99 | # reason it is here is because it is used by the schema dumper, since 100 | # the schema dump will contains separate statements for foreign keys, 101 | # and we don't want to duplicate those 102 | def add_polymorphic_triggers(table, column_names) 103 | column_names.sort! 104 | poly_drop_triggers(table, column_names) 105 | poly_create_triggers(table, column_names) 106 | end 107 | 108 | 109 | ########################################################################## 110 | private 111 | 112 | def poly_trigger_name(table, action, columns) 113 | prefix = "pfk#{action.first}_#{table}_".downcase 114 | generate_name prefix, columns.sort 115 | end 116 | 117 | def poly_drop_trigger(table, action, columns) 118 | trigger_name = poly_trigger_name(table, action, columns) 119 | execute %{DROP TRIGGER IF EXISTS #{trigger_name}} 120 | end 121 | 122 | def poly_create_trigger(table, action, columns) 123 | trigger_name = poly_trigger_name(table, action, columns) 124 | colchecks = columns.collect { |col| "IF(NEW.#{col} IS NULL, 0, 1)" }. 125 | join(' + ') 126 | 127 | sql = %{ 128 | CREATE TRIGGER #{trigger_name} BEFORE #{action} ON #{table} 129 | FOR EACH ROW 130 | BEGIN 131 | IF(#{colchecks}) <> 1 THEN 132 | SET NEW = 'Error'; 133 | END IF; 134 | END} 135 | 136 | execute sql 137 | end 138 | 139 | def poly_drop_triggers(table, columns) 140 | poly_drop_trigger(table, 'INSERT', columns) 141 | poly_drop_trigger(table, 'UPDATE', columns) 142 | end 143 | 144 | def poly_create_triggers(table, columns) 145 | poly_create_trigger(table, 'INSERT', columns) 146 | poly_create_trigger(table, 'UPDATE', columns) 147 | end 148 | 149 | def poly_create_index(table, column, unique_cols) 150 | if unique_cols == [true] 151 | unique_cols = [column] 152 | else 153 | unique_cols = [column] + unique_cols 154 | end 155 | name = poly_index_name(table, unique_cols) 156 | execute %{ 157 | CREATE UNIQUE INDEX #{name} ON #{table} (#{unique_cols.join(', ')}) 158 | } 159 | end 160 | 161 | def poly_remove_index(table, column, unique_cols) 162 | if unique_cols == [true] 163 | unique_cols = [column] 164 | else 165 | unique_cols = [column] + unique_cols 166 | end 167 | name = poly_index_name(table, unique_cols) 168 | execute %{ DROP INDEX #{name} ON #{table} } 169 | end 170 | 171 | def poly_index_name(table, columns) 172 | prefix = "pfk_#{table}_" 173 | generate_name prefix, columns 174 | end 175 | 176 | def poly_create_indexes(table, columns, unique_cols) 177 | columns.each do |column| 178 | poly_create_index(table, column, unique_cols) 179 | end 180 | end 181 | 182 | def poly_remove_indexes(table, columns, unique_cols) 183 | columns.each do |column| 184 | poly_remove_index(table, column, unique_cols) 185 | end 186 | end 187 | 188 | def generate_name(prefix, columns) 189 | # names can be at most 64 characters long 190 | col_length = (64 - prefix.length) / columns.length 191 | 192 | prefix + 193 | columns.map { |c| c.to_s.gsub('_','').first(col_length-1) }.join('_') 194 | end 195 | 196 | def generate_constraints(options) 197 | options.slice(:on_delete, :on_update) 198 | end 199 | end 200 | end 201 | end 202 | 203 | [:MysqlAdapter, :Mysql2Adapter].each do |adapter| 204 | begin 205 | ActiveRecord::ConnectionAdapters.const_get(adapter).class_eval do 206 | include Polymorpheus::ConnectionAdapters::MysqlAdapter 207 | end 208 | rescue 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/polymorpheus/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module ConnectionAdapters 3 | module PostgresqlAdapter 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/polymorpheus/railtie.rb: -------------------------------------------------------------------------------- 1 | # Thanks to matthuhiggins/foreigner gem for the template used here 2 | module Polymorpheus 3 | class Railtie < Rails::Railtie 4 | 5 | initializer 'polymorpheus.load_adapter' do 6 | ActiveSupport.on_load :active_record do 7 | 8 | ActiveRecord::Base.send :include, Polymorpheus::Interface 9 | 10 | ActiveRecord::ConnectionAdapters.module_eval do 11 | include Polymorpheus::ConnectionAdapters::SchemaStatements 12 | end 13 | 14 | ActiveRecord::SchemaDumper.class_eval do 15 | include Polymorpheus::SchemaDumper 16 | end 17 | 18 | Polymorpheus::Adapter.load! 19 | end 20 | end 21 | 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /lib/polymorpheus/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module SchemaDumper 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | alias_method :tables_without_triggers, :tables 7 | alias_method :tables, :tables_with_triggers 8 | end 9 | 10 | def tables_with_triggers(stream) 11 | tables_without_triggers(stream) 12 | 13 | if @connection.respond_to?(:triggers) 14 | @connection.triggers.collect(&:schema_statement).uniq.each do |statement| 15 | stream.puts statement 16 | end 17 | end 18 | 19 | stream.puts 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/polymorpheus/schema_statements.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | module ConnectionAdapters 3 | module SchemaStatements 4 | def self.included(base) 5 | base::AbstractAdapter.class_eval do 6 | include Polymorpheus::ConnectionAdapters::AbstractAdapter 7 | end 8 | end 9 | end 10 | 11 | module AbstractAdapter 12 | def add_polymorphic_constraints(table, columns, options = {}) 13 | end 14 | end 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /lib/polymorpheus/trigger.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | class Trigger 3 | 4 | attr_accessor :name, :event, :table, :statement, :timing, :created, :sql_mode, 5 | :definer, :charset, :collation_connection, :db_collation 6 | 7 | def initialize(arr) 8 | raise ArgumentError unless arr.is_a?(Array) && arr.length == 11 9 | [:name, :event, :table, :statement, :timing, :created, :sql_mode, 10 | :definer, :charset, :collation_connection, :db_collation]. 11 | each_with_index do |attr, ind| 12 | self.send("#{attr}=", arr[ind]) 13 | end 14 | end 15 | 16 | def columns 17 | /IF\((.*)\) \<\> 1/.match(self.statement) do |match| 18 | match[1].split(' + ').collect do |submatch| 19 | /NEW\.([^ ]*)/.match(submatch)[1] 20 | end 21 | end 22 | end 23 | 24 | def schema_statement 25 | # note that we don't need to worry about unique indices or foreign keys 26 | # because separate schema statements will be generated for them 27 | " add_polymorphic_triggers(:#{table}, #{columns.to_s})" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/polymorpheus/version.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | VERSION = '3.4.0' 3 | end 4 | -------------------------------------------------------------------------------- /polymorpheus.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require 'rubygems' unless defined? Gem 3 | require File.dirname(__FILE__) + "/lib/polymorpheus/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "polymorpheus" 7 | s.version = Polymorpheus::VERSION 8 | 9 | s.authors = ["Barun Singh"] 10 | s.email = "bsingh@wegowise.com" 11 | s.homepage = "http://github.com/wegowise/polymorpheus" 12 | s.summary = "Provides a database-friendly method for polymorphic relationships" 13 | s.description = "Provides a database-friendly method for polymorphic relationships" 14 | 15 | s.required_ruby_version = ">= 2.6" 16 | s.required_rubygems_version = ">= 1.3.6" 17 | s.files = Dir.glob(%w[{lib,spec}/**/*.rb [A-Z]*.{txt,rdoc,md} *.gemspec]) + %w{Rakefile} 18 | s.extra_rdoc_files = ["README.md", "LICENSE.txt"] 19 | s.license = 'MIT' 20 | 21 | s.add_dependency('activerecord', '>= 5.2', '< 7.1') 22 | 23 | s.add_development_dependency('rake', '~> 12.3.3') 24 | s.add_development_dependency('rspec', '~> 3.9.0') 25 | end 26 | -------------------------------------------------------------------------------- /spec/interface/belongs_to_polymorphic_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::Interface::BelongsToPolymorphic do 4 | let(:hero) { Hero.create! } 5 | let(:villain) { Villain.create! } 6 | let(:superhero) { Superhero.create! } 7 | let(:alien_demigod) { AlienDemigod.create! } 8 | 9 | before do 10 | create_table :story_arcs do |t| 11 | t.references :hero 12 | t.references :villain 13 | end 14 | create_table :heros 15 | create_table :villains 16 | end 17 | 18 | specify do 19 | expect(StoryArc::POLYMORPHEUS_ASSOCIATIONS).to eq(%w[hero villain]) 20 | end 21 | specify do 22 | expect(Superpower::POLYMORPHEUS_ASSOCIATIONS).to eq(%w[superhero supervillain]) 23 | end 24 | 25 | describe "setter methods for ActiveRecord objects" do 26 | let(:story_arc) { StoryArc.new(attributes) } 27 | let(:attributes) { {} } 28 | 29 | it "sets the correct attribute value for the setter" do 30 | story_arc.character = hero 31 | expect(story_arc.hero_id).to eq(hero.id) 32 | expect(story_arc.villain_id).to eq(nil) 33 | end 34 | 35 | it "sets competing associations to nil" do 36 | story_arc.character = hero 37 | expect(story_arc.hero_id).to eq(hero.id) 38 | story_arc.character = villain 39 | expect(story_arc.villain_id).to eq(villain.id) 40 | expect(story_arc.hero_id).to eq(nil) 41 | end 42 | 43 | it "throws an error if the assigned object isn't a valid type" do 44 | create_table :trees 45 | 46 | tree = Tree.create! 47 | expect { story_arc.character = tree }.to raise_error( 48 | Polymorpheus::Interface::InvalidTypeError, 49 | "Invalid type. Must be one of {hero, villain}" 50 | ) 51 | end 52 | 53 | it "does not throw an error if the assigned object is a subclass of a 54 | valid type" do 55 | expect { story_arc.character = superhero }.not_to raise_error 56 | expect(story_arc.hero_id).to eq(superhero.id) 57 | end 58 | 59 | it "does not throw an error if the assigned object is a descendant of a 60 | valid type" do 61 | expect { story_arc.character = alien_demigod }.not_to raise_error 62 | expect(story_arc.hero_id).to eq(alien_demigod.id) 63 | end 64 | end 65 | 66 | describe "setter methods for objects inheriting from ActiveRecord objects" do 67 | let(:superpower) { Superpower.new } 68 | 69 | before do 70 | create_table :superpowers do |t| 71 | t.references :superhero 72 | t.references :supervillain 73 | end 74 | end 75 | 76 | it "throws an error if the assigned object is an instance of the parent 77 | ActiveRecord class" do 78 | expect { superpower.wielder = hero }.to raise_error( 79 | Polymorpheus::Interface::InvalidTypeError, 80 | "Invalid type. Must be one of {superhero, supervillain}" 81 | ) 82 | end 83 | 84 | it "works if the assigned object is of the specified class" do 85 | expect { superpower.wielder = superhero }.not_to raise_error 86 | expect(superpower.superhero_id).to eq(superhero.id) 87 | end 88 | 89 | it "works if the assigned object is an instance of a child class" do 90 | expect { superpower.wielder = alien_demigod }.not_to raise_error 91 | expect(superpower.superhero_id).to eq(alien_demigod.id) 92 | end 93 | end 94 | 95 | describe '#polymorpheus exposed interface method' do 96 | subject(:interface) { story_arc.polymorpheus } 97 | 98 | context 'when there is no relationship defined' do 99 | let(:story_arc) { StoryArc.new } 100 | 101 | specify do 102 | expect(interface.associations).to match_associations(:hero, :villain) 103 | end 104 | specify { expect(interface.active_association).to eq nil } 105 | specify { expect(interface.query_condition).to eq nil } 106 | end 107 | 108 | context 'when there is are multiple relationships defined' do 109 | let(:story_arc) { StoryArc.new(hero_id: hero.id, villain_id: villain.id) } 110 | 111 | specify do 112 | expect(interface.associations).to match_associations(:hero, :villain) 113 | end 114 | specify { expect(interface.active_association).to eq nil } 115 | specify { expect(interface.query_condition).to eq nil } 116 | end 117 | 118 | context 'when there is one relationship defined through the id value' do 119 | let(:story_arc) { StoryArc.new(hero_id: hero.id) } 120 | 121 | specify do 122 | expect(interface.associations).to match_associations(:hero, :villain) 123 | end 124 | specify { expect(interface.active_association).to be_association(:hero) } 125 | specify { expect(interface.query_condition).to eq('hero_id' => hero.id) } 126 | end 127 | 128 | context 'when there is one relationship defined through the setter' do 129 | let(:story_arc) { StoryArc.new(character: hero) } 130 | 131 | specify do 132 | expect(interface.associations).to match_associations(:hero, :villain) 133 | end 134 | specify { expect(interface.active_association).to be_association(:hero) } 135 | specify { expect(interface.query_condition).to eq('hero_id' => hero.id) } 136 | end 137 | 138 | context 'when there is one association, to a new record' do 139 | let(:new_hero) { Hero.new } 140 | let(:story_arc) { StoryArc.new(character: new_hero) } 141 | 142 | specify do 143 | expect(interface.associations).to match_associations(:hero, :villain) 144 | end 145 | specify { expect(interface.active_association).to be_association(:hero) } 146 | specify { expect(interface.query_condition).to eq nil } 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/interface/has_many_as_polymorph_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::Interface::HasManyAsPolymorph do 4 | before do 5 | create_table :story_arcs do |t| 6 | t.references :hero 7 | t.references :villain 8 | t.references :battle 9 | t.references :issue 10 | end 11 | create_table :battles 12 | create_table :heros 13 | create_table :issues 14 | create_table :villains 15 | end 16 | 17 | it 'sets conditions on association to ensure we retrieve correct result' do 18 | hero = Hero.create! 19 | expect(hero.story_arcs.to_sql).to match_sql <<-EOS 20 | SELECT `story_arcs`.* FROM `story_arcs` 21 | WHERE `story_arcs`.`hero_id` = #{hero.id} 22 | AND `story_arcs`.`villain_id` IS NULL 23 | EOS 24 | end 25 | 26 | it 'supports existing conditions on the association' do 27 | villain = Villain.create! 28 | expect(villain.story_arcs.to_sql).to match_sql <<-EOS 29 | SELECT `story_arcs`.* FROM `story_arcs` 30 | WHERE `story_arcs`.`villain_id` = #{villain.id} 31 | AND `story_arcs`.`hero_id` IS NULL 32 | ORDER BY id DESC 33 | EOS 34 | end 35 | 36 | it 'returns the correct result when used with new records' do 37 | villain = Villain.create! 38 | story_arc = StoryArc.create!(villain: villain, issue_id: 10) 39 | expect(Hero.new.story_arcs.where(issue_id: 10)).to eq([]) 40 | end 41 | 42 | it 'sets conditions on associations with enough specificity that they work 43 | in conjunction with has_many :through relationships' do 44 | hero = Hero.create! 45 | expect(hero.battles.to_sql).to match_sql <<-EOS 46 | SELECT `battles`.* FROM `battles` 47 | INNER JOIN `story_arcs` 48 | ON `battles`.`id` = `story_arcs`.`battle_id` 49 | WHERE `story_arcs`.`hero_id` = #{hero.id} 50 | AND `story_arcs`.`villain_id` IS NULL 51 | EOS 52 | end 53 | 54 | it 'uses the correct association table name when used in conjunction with a 55 | join condition' do 56 | battle = Battle.create! 57 | expect(battle.heros.to_sql).to match_sql <<-EOS 58 | SELECT `heros`.* FROM `heros` 59 | INNER JOIN `story_arcs` 60 | ON `heros`.`id` = `story_arcs`.`hero_id` 61 | WHERE `story_arcs`.`battle_id` = #{battle.id} 62 | EOS 63 | 64 | if ActiveRecord::VERSION::MAJOR >= 6 65 | expect(battle.heros.joins(:story_arcs).to_sql).to match_sql <<-EOS 66 | SELECT `heros`.* FROM `heros` 67 | INNER JOIN `story_arcs` 68 | ON `heros`.`id` = `story_arcs`.`hero_id` 69 | INNER JOIN `story_arcs` `story_arcs_heros` 70 | ON `story_arcs_heros`.`villain_id` IS NULL 71 | AND `story_arcs_heros`.`hero_id` = `heros`.`id` 72 | WHERE `story_arcs`.`battle_id` = #{battle.id} 73 | EOS 74 | else 75 | expect(battle.heros.joins(:story_arcs).to_sql).to match_sql <<-EOS 76 | SELECT `heros`.* FROM `heros` 77 | INNER JOIN `story_arcs` `story_arcs_heros` 78 | ON `story_arcs_heros`.`hero_id` = `heros`.`id` 79 | AND `story_arcs_heros`.`villain_id` IS NULL 80 | INNER JOIN `story_arcs` 81 | ON `heros`.`id` = `story_arcs`.`hero_id` 82 | WHERE `story_arcs`.`battle_id` = #{battle.id} 83 | EOS 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/interface/validates_polymorph_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::Interface::ValidatesPolymorph do 4 | let(:hero) { Hero.create! } 5 | let(:villain) { Villain.create! } 6 | 7 | before do 8 | create_table(:story_arcs) do |t| 9 | t.references :hero 10 | t.references :villain 11 | end 12 | create_table(:heros) 13 | create_table(:villains) 14 | end 15 | 16 | specify { expect(StoryArc.new(character: hero).valid?).to eq(true) } 17 | specify { expect(StoryArc.new(character: villain).valid?).to eq(true) } 18 | specify { expect(StoryArc.new(hero_id: hero.id).valid?).to eq(true) } 19 | specify { expect(StoryArc.new(hero: hero).valid?).to eq(true) } 20 | specify { expect(StoryArc.new(hero: Hero.new).valid?).to eq(true) } 21 | 22 | it 'is invalid if no association is specified' do 23 | story_arc = StoryArc.new 24 | expect(story_arc.valid?).to eq(false) 25 | expect(story_arc.errors[:base]).to eq( 26 | ["You must specify exactly one of the following: {hero, villain}"] 27 | ) 28 | end 29 | 30 | it 'is invalid if multiple associations are specified' do 31 | story_arc = StoryArc.new(hero_id: hero.id, villain_id: villain.id) 32 | expect(story_arc.valid?).to eq(false) 33 | expect(story_arc.errors[:base]).to eq( 34 | ["You must specify exactly one of the following: {hero, villain}"] 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/interface_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::Interface do 4 | describe 'association options' do 5 | it 'without options' do 6 | create_table :drawings 7 | create_table :books 8 | create_table :binders 9 | 10 | expect(Drawing.new.association(:book).reflection.inverse_of).to eq(nil) 11 | expect(Drawing.new.association(:binder).reflection.inverse_of).to eq(nil) 12 | expect(Book.new.association(:drawings).reflection.inverse_of).to eq(nil) 13 | expect(Binder.new.association(:drawings).reflection.inverse_of).to eq(nil) 14 | end 15 | 16 | it 'with options' do 17 | create_table :pictures 18 | create_table :web_pages 19 | create_table :printed_works 20 | 21 | expect(Picture.new.association(:web_page).reflection.inverse_of.name).to eq(:pictures) 22 | expect(Picture.new.association(:printed_work).reflection.inverse_of.name).to eq(:pictures) 23 | expect(WebPage.new.association(:pictures).reflection.inverse_of.name).to eq(:web_page) 24 | expect(PrintedWork.new.association(:pictures).reflection.inverse_of.name).to eq(:printed_work) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/mysql2_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::ConnectionAdapters::MysqlAdapter do 4 | # The foreign key name is not truncated, so the maximum column name 5 | # length ends up being: 64 - "pets_" - "_fk" == 56 6 | # 7 | # Using `t.references` also adds an index on this column, with a slightly 8 | # longer name: 64 - "index_pets_on_" - "_id" == 46 9 | # 10 | # Go with the shorter of the two here, since it's still long enough to test 11 | # the generation of Polymorpheus' trigger names. 12 | let(:long_column1) { ('x' * 46).to_sym } 13 | let(:long_column2) { ('y' * 46).to_sym } 14 | 15 | before do 16 | create_table(:pets) do |t| 17 | t.references :cat 18 | t.references :dog 19 | t.string :name 20 | t.string :color 21 | end 22 | 23 | create_table(:cats) 24 | create_table(:dogs) 25 | 26 | clear_sql_history 27 | end 28 | 29 | after do 30 | drop_table :pets 31 | drop_table :cats 32 | drop_table :dogs 33 | end 34 | 35 | ####################################################### 36 | # Specs 37 | ####################################################### 38 | 39 | describe '#add_polymorphic_constraints' do 40 | it 'adds foreign keys with no uniqueness constraints' do 41 | add_polymorphic_constraints( 42 | 'pets', 43 | { cat_id: 'cats.id', dog_id: 'dogs.id' } 44 | ) 45 | 46 | should_execute_sql <<-EOS 47 | DROP TRIGGER IF EXISTS pfki_pets_catid_dogid 48 | DROP TRIGGER IF EXISTS pfku_pets_catid_dogid 49 | CREATE TRIGGER pfki_pets_catid_dogid BEFORE INSERT ON pets 50 | FOR EACH ROW 51 | BEGIN 52 | IF(IF(NEW.cat_id IS NULL, 0, 1) + IF(NEW.dog_id IS NULL, 0, 1)) <> 1 THEN 53 | SET NEW = 'Error'; 54 | END IF; 55 | END 56 | CREATE TRIGGER pfku_pets_catid_dogid BEFORE UPDATE ON pets 57 | FOR EACH ROW 58 | BEGIN 59 | IF(IF(NEW.cat_id IS NULL, 0, 1) + IF(NEW.dog_id IS NULL, 0, 1)) <> 1 THEN 60 | SET NEW = 'Error'; 61 | END IF; 62 | END 63 | 64 | ALTER TABLE `pets` ADD CONSTRAINT `pets_cat_id_fk` 65 | FOREIGN KEY (`cat_id`) 66 | REFERENCES `cats`(id) 67 | 68 | ALTER TABLE `pets` ADD CONSTRAINT `pets_dog_id_fk` 69 | FOREIGN KEY (`dog_id`) 70 | REFERENCES `dogs`(id) 71 | EOS 72 | end 73 | 74 | it 'adds uniqueness specified with true' do 75 | add_polymorphic_constraints( 76 | 'pets', 77 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 78 | unique: true 79 | ) 80 | 81 | should_execute_sql <<-EOS 82 | CREATE UNIQUE INDEX pfk_pets_catid ON pets (cat_id) 83 | CREATE UNIQUE INDEX pfk_pets_dogid ON pets (dog_id) 84 | EOS 85 | end 86 | 87 | it 'adds uniqueness specified with a string' do 88 | add_polymorphic_constraints('pets', { cat_id: 'cats.id' }, unique: 'name') 89 | should_execute_sql <<-EOS 90 | CREATE UNIQUE INDEX pfk_pets_catid_name ON pets (cat_id, name) 91 | EOS 92 | end 93 | 94 | it 'adds uniqueness specified as an array' do 95 | add_polymorphic_constraints( 96 | 'pets', 97 | { cat_id: 'cats.id' }, 98 | unique: [:name, :color] 99 | ) 100 | should_execute_sql <<-EOS 101 | CREATE UNIQUE INDEX pfk_pets_catid_name_color ON pets (cat_id, name, color) 102 | EOS 103 | end 104 | 105 | it 'adds an on update constraint' do 106 | add_polymorphic_constraints( 107 | 'pets', 108 | { cat_id: 'cats.id' }, 109 | on_update: :cascade 110 | ) 111 | should_execute_sql <<-EOS 112 | ALTER TABLE `pets` ADD CONSTRAINT `pets_cat_id_fk` 113 | FOREIGN KEY (`cat_id`) 114 | REFERENCES `cats`(id) 115 | ON UPDATE CASCADE 116 | EOS 117 | end 118 | 119 | it 'raises an error when on_update has invalid arguments' do 120 | expect do 121 | add_polymorphic_constraints( 122 | 'pets', 123 | { cat_id: 'cats.id' }, 124 | on_update: :invalid 125 | ) 126 | end.to raise_error ArgumentError 127 | end 128 | 129 | it 'adds an on delete constraint' do 130 | add_polymorphic_constraints( 131 | 'pets', 132 | { cat_id: 'cats.id' }, 133 | on_delete: :cascade 134 | ) 135 | should_execute_sql <<-EOS 136 | ALTER TABLE `pets` ADD CONSTRAINT `pets_cat_id_fk` 137 | FOREIGN KEY (`cat_id`) 138 | REFERENCES `cats`(id) 139 | ON DELETE CASCADE 140 | EOS 141 | end 142 | 143 | it 'raises an error when on_delete has invalid arguments' do 144 | expect do 145 | add_polymorphic_constraints( 146 | 'pets', 147 | { cat_id: 'cats.id' }, 148 | on_update: :invalid 149 | ) 150 | end.to raise_error ArgumentError 151 | end 152 | 153 | it 'truncates long trigger names to 64 characters' do 154 | create_table(:pets) do |t| 155 | t.references long_column1 156 | t.references long_column2 157 | end 158 | 159 | add_polymorphic_constraints( 160 | 'pets', 161 | { "#{long_column1}_id" => 'cats.id', "#{long_column2}_id" => 'dogs.id' } 162 | ) 163 | 164 | should_execute_sql <<-EOS 165 | DROP TRIGGER IF EXISTS pfki_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy 166 | DROP TRIGGER IF EXISTS pfku_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy 167 | CREATE TRIGGER pfki_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy BEFORE INSERT ON pets 168 | FOR EACH ROW 169 | BEGIN 170 | IF(IF(NEW.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_id IS NULL, 0, 1) + IF(NEW.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy_id IS NULL, 0, 1)) <> 1 THEN 171 | SET NEW = 'Error'; 172 | END IF; 173 | END 174 | CREATE TRIGGER pfku_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy BEFORE UPDATE ON pets 175 | FOR EACH ROW 176 | BEGIN 177 | IF(IF(NEW.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_id IS NULL, 0, 1) + IF(NEW.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy_id IS NULL, 0, 1)) <> 1 THEN 178 | SET NEW = 'Error'; 179 | END IF; 180 | END 181 | EOS 182 | end 183 | end 184 | 185 | describe '#remove_polymorphic_constraints' do 186 | it 'removes triggers and foreign keys with no uniqueness constraints' do 187 | add_polymorphic_constraints( 188 | 'pets', 189 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 190 | unique: true 191 | ) 192 | clear_sql_history 193 | 194 | remove_polymorphic_constraints( 195 | 'pets', 196 | { cat_id: 'cats.id', dog_id: 'dogs.id' } 197 | ) 198 | should_execute_sql <<-EOS 199 | DROP TRIGGER IF EXISTS pfki_pets_catid_dogid 200 | DROP TRIGGER IF EXISTS pfku_pets_catid_dogid 201 | EOS 202 | end 203 | 204 | it 'removes uniqueness index specified with true' do 205 | add_polymorphic_constraints( 206 | 'pets', 207 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 208 | unique: true 209 | ) 210 | clear_sql_history 211 | 212 | remove_polymorphic_constraints( 213 | 'pets', 214 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 215 | unique: true 216 | ) 217 | should_execute_sql <<-EOS 218 | DROP INDEX pfk_pets_catid ON pets 219 | DROP INDEX pfk_pets_dogid ON pets 220 | EOS 221 | end 222 | 223 | it 'removes uniqueness index specified with a string' do 224 | add_polymorphic_constraints( 225 | 'pets', 226 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 227 | unique: 'name' 228 | ) 229 | clear_sql_history 230 | 231 | remove_polymorphic_constraints( 232 | 'pets', 233 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 234 | unique: 'name' 235 | ) 236 | 237 | end 238 | 239 | it 'removes uniqueness index specified with an array' do 240 | add_polymorphic_constraints( 241 | 'pets', 242 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 243 | unique: [:name, :color] 244 | ) 245 | clear_sql_history 246 | 247 | remove_polymorphic_constraints( 248 | 'pets', 249 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 250 | unique: [:name, :color] 251 | ) 252 | 253 | end 254 | 255 | it 'removes an on update constraint' do 256 | add_polymorphic_constraints( 257 | 'pets', 258 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 259 | on_update: :cascade 260 | ) 261 | clear_sql_history 262 | 263 | remove_polymorphic_constraints( 264 | 'pets', 265 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 266 | on_update: :cascade 267 | ) 268 | 269 | end 270 | 271 | it 'removes an on delete constraint' do 272 | add_polymorphic_constraints( 273 | 'pets', 274 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 275 | on_delete: :cascade 276 | ) 277 | clear_sql_history 278 | 279 | remove_polymorphic_constraints( 280 | 'pets', 281 | { cat_id: 'cats.id', dog_id: 'dogs.id' }, 282 | on_update: :cascade 283 | ) 284 | 285 | end 286 | 287 | it 'truncates long trigger names to 64 characters' do 288 | create_table(:pets) do |t| 289 | t.references long_column1 290 | t.references long_column2 291 | end 292 | args = [ 293 | 'pets', 294 | { "#{long_column1}_id" => 'cats.id', "#{long_column2}_id" => 'dogs.id' } 295 | ] 296 | add_polymorphic_constraints(*args) 297 | clear_sql_history 298 | remove_polymorphic_constraints(*args) 299 | should_execute_sql <<-EOS 300 | DROP TRIGGER IF EXISTS pfki_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy 301 | DROP TRIGGER IF EXISTS pfku_pets_xxxxxxxxxxxxxxxxxxxxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyyyy 302 | EOS 303 | end 304 | end 305 | 306 | describe "#triggers" do 307 | it 'returns the triggers for the current schema' do 308 | add_polymorphic_constraints( 309 | 'pets', 310 | { cat_id: 'cats.id', dog_id: 'dogs.id' } 311 | ) 312 | expect(triggers.map(&:name)).to eq( 313 | ['pfki_pets_catid_dogid', 'pfku_pets_catid_dogid'] 314 | ) 315 | end 316 | end 317 | end 318 | -------------------------------------------------------------------------------- /spec/schema_dumper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::SchemaDumper do 4 | let(:connection) { ActiveRecord::Base.connection } 5 | let(:stream) { StringIO.new } 6 | 7 | before do 8 | create_table :story_arcs do |t| 9 | t.references :hero 10 | t.references :villain 11 | end 12 | create_table :heros 13 | create_table :villains 14 | ActiveRecord::Base.connection.add_polymorphic_constraints( 15 | 'story_arcs', 16 | { hero_id: 'heros.id', villain_id: 'villains.id' } 17 | ) 18 | 19 | ActiveRecord::SchemaDumper.dump(connection, stream) 20 | end 21 | 22 | after do 23 | drop_table :story_arcs # drop first, due to the foreign key 24 | end 25 | 26 | subject { stream.string } 27 | 28 | let(:schema_statement) do 29 | %{ add_polymorphic_triggers(:story_arcs, ["hero_id", "villain_id"])} 30 | end 31 | 32 | specify "the schema statement is part of the dump" do 33 | expect(subject.index(schema_statement)).to be_a(Integer) 34 | end 35 | 36 | specify "there is exactly one instance of the schema statement" do 37 | expect(subject.index(schema_statement)).to eq(subject.rindex(schema_statement)) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'polymorpheus' 3 | require 'stringio' 4 | 5 | ActiveRecord::Base.establish_connection( 6 | adapter: 'mysql2', 7 | database: ENV.fetch('DB_NAME', 'polymorpheus_test'), 8 | host: ENV.fetch('DB_HOST', '127.0.0.1'), 9 | password: ENV.fetch('DB_PASSWORD', ''), 10 | port: ENV.fetch('DB_PORT', '3306'), 11 | username: ENV.fetch('DB_USERNAME', 'root') 12 | ) 13 | 14 | Dir[File.dirname(__FILE__) + '/support/*.rb'].sort.each { |path| require path } 15 | 16 | Polymorpheus::Adapter.load! 17 | 18 | # This is normally done via a Railtie in non-testing situations. 19 | ActiveRecord::SchemaDumper.class_eval { include Polymorpheus::SchemaDumper } 20 | ActiveRecord::Migration.verbose = false 21 | 22 | RSpec.configure do |config| 23 | config.order = :random 24 | Kernel.srand config.seed 25 | 26 | config.include ConnectionHelpers 27 | config.include SchemaHelpers 28 | config.include SqlTestHelpers 29 | 30 | config.after do 31 | ActiveRecord::Base.connection.tables.each do |table| 32 | ActiveRecord::Base.connection.drop_table(table) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/support/class_defs.rb: -------------------------------------------------------------------------------- 1 | # this is normally done via a Railtie in non-testing situations 2 | ActiveRecord::Base.send :include, Polymorpheus::Interface 3 | 4 | # Han Solo is a hero, but not a superhero 5 | class Hero < ActiveRecord::Base 6 | has_many_as_polymorph :story_arcs 7 | has_many :battles, through: :story_arcs 8 | end 9 | 10 | # Hannibal Lecter is a villain, but not a supervillain 11 | class Villain < ActiveRecord::Base 12 | if ActiveRecord::VERSION::MAJOR >= 4 13 | has_many_as_polymorph :story_arcs, -> { order('id DESC') } 14 | else 15 | has_many_as_polymorph :story_arcs, order: 'id DESC' 16 | end 17 | 18 | has_many :battles, through: :story_arcs 19 | end 20 | 21 | # Flash is a superhero but not an alien demigod 22 | class Superhero < Hero 23 | end 24 | 25 | # Superman is an alien demigod 26 | class AlienDemigod < Superhero 27 | end 28 | 29 | # Darkseid is a supervillain 30 | class Supervillain < Villain 31 | end 32 | 33 | # All heros and villains have story arcs 34 | class StoryArc < ActiveRecord::Base 35 | belongs_to_polymorphic :hero, :villain, as: :character 36 | belongs_to :battle 37 | validates_polymorph :character 38 | end 39 | 40 | class Battle < ActiveRecord::Base 41 | has_many :story_arcs 42 | has_many :heros, through: :story_arcs 43 | end 44 | 45 | class Issue < ActiveRecord::Base 46 | has_many :story_arcs 47 | has_many :heros, through: :story_arcs 48 | end 49 | 50 | # But only super-people have superpowers 51 | class Superpower < ActiveRecord::Base 52 | belongs_to_polymorphic :superhero, :supervillain, as: :wielder 53 | end 54 | 55 | # Trees, though, are masters of zen. They sway with the wind. 56 | # (Unless this is LOTR, but let's ignore that for now.) 57 | class Tree < ActiveRecord::Base 58 | end 59 | 60 | class Drawing < ActiveRecord::Base 61 | belongs_to_polymorphic :book, :binder, as: :one_way_rel 62 | end 63 | class Book < ActiveRecord::Base 64 | has_many_as_polymorph :drawings 65 | end 66 | class Binder < ActiveRecord::Base 67 | has_many_as_polymorph :drawings 68 | end 69 | 70 | class Picture < ActiveRecord::Base 71 | belongs_to_polymorphic :web_page, :printed_work, as: :inverted_rel, inverse_of: :pictures 72 | end 73 | class WebPage < ActiveRecord::Base 74 | has_many_as_polymorph :pictures, inverse_of: :web_page 75 | end 76 | class PrintedWork < ActiveRecord::Base 77 | has_many_as_polymorph :pictures, inverse_of: :printed_work 78 | end 79 | 80 | class Pet < ActiveRecord::Base 81 | end 82 | class Cat < ActiveRecord::Base 83 | end 84 | class Dog < ActiveRecord::Base 85 | end 86 | -------------------------------------------------------------------------------- /spec/support/connection_helpers.rb: -------------------------------------------------------------------------------- 1 | module ConnectionHelpers 2 | def add_polymorphic_constraints(*args) 3 | connection.add_polymorphic_constraints(*args) 4 | end 5 | 6 | def remove_polymorphic_constraints(*args) 7 | connection.remove_polymorphic_constraints(*args) 8 | end 9 | 10 | def triggers 11 | connection.triggers 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/custom_matchers.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :be_association do |association_name| 2 | match do |actual| 3 | expect(actual).to be_instance_of Polymorpheus::InterfaceBuilder::Association 4 | expect(actual.name).to eq association_name.to_s 5 | end 6 | end 7 | 8 | RSpec::Matchers.define :match_associations do |*association_names| 9 | match do |actual| 10 | expect(actual.length).to eq association_names.length 11 | actual.each_with_index do |item, ind| 12 | expect(item).to be_association(association_names[ind]) 13 | end 14 | end 15 | end 16 | 17 | RSpec::Matchers.define :match_sql do |expected| 18 | match do |actual| 19 | expect(format(expected)).to eq format(actual) 20 | end 21 | 22 | failure_message do |actual| 23 | "expected the following SQL statements to match: 24 | #{format(actual)} 25 | #{format(expected)}" 26 | end 27 | 28 | def format(sql) 29 | sql.squish 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/schema_helpers.rb: -------------------------------------------------------------------------------- 1 | module SchemaHelpers 2 | def create_table(name, options = {}) 3 | options.merge!(force: true) 4 | ActiveRecord::Schema.define do 5 | create_table(name, **options) do |t| 6 | yield(t) if block_given? 7 | end 8 | end 9 | name.to_s.classify.constantize.reset_column_information 10 | end 11 | 12 | def drop_table(name) 13 | ActiveRecord::Schema.define do 14 | drop_table(name) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/sql_query_subscriber.rb: -------------------------------------------------------------------------------- 1 | module Polymorpheus 2 | class SqlQuerySubscriber 3 | attr_reader :sql_statements 4 | 5 | def initialize 6 | @sql_statements = [] 7 | end 8 | 9 | def call(_name, _start, _finish, _id, payload) 10 | sql_statements << payload[:sql] 11 | end 12 | 13 | def clear_sql_history 14 | @sql_statements.clear 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/sql_test_helpers.rb: -------------------------------------------------------------------------------- 1 | module SqlTestHelpers 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before do 6 | ActiveSupport::Notifications.subscribe('sql.active_record', sql_logger) 7 | end 8 | 9 | after do 10 | ActiveSupport::Notifications.unsubscribe(sql_logger) 11 | end 12 | 13 | let(:connection) { ActiveRecord::Base.connection } 14 | let(:sql_logger) { Polymorpheus::SqlQuerySubscriber.new } 15 | 16 | def clean_sql(sql_string) 17 | sql_string 18 | .squish 19 | .gsub('`', '') 20 | .gsub(/\ FOREIGN KEY/, "\nFOREIGN KEY") 21 | .gsub(/\ REFERENCES/, "\nREFERENCES") 22 | .gsub(/\ ON DELETE/, "\nON DELETE") 23 | .gsub(/\ ON UPDATE/, "\nON UPDATE") 24 | .gsub(/([[:alpha:]])\(/, '\1 (') 25 | end 26 | 27 | def clear_sql_history 28 | sql_logger.clear_sql_history 29 | end 30 | 31 | def should_execute_sql(expected) 32 | expect(clean_sql(sql.join("\n"))).to include(clean_sql(expected)) 33 | end 34 | 35 | def sql 36 | sql_logger.sql_statements 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/trigger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Polymorpheus::Trigger do 4 | let(:name) { "pets_unique_polyfk_on_INSERT" } 5 | let(:event) { "INSERT" } 6 | let(:table) { "pets"} 7 | let(:statement) do 8 | %{BEGIN 9 | IF(IF(NEW.dog_id IS NULL, 0, 1) + IF(NEW.kitty_id IS NULL, 0, 1)) <> 1 THEN 10 | SET NEW = 'Error'; 11 | END IF; 12 | END} 13 | end 14 | let(:timing) { "BEFORE" } 15 | let(:created) { nil } 16 | let(:sql_mode) { "" } 17 | let(:definer) { "production@%" } 18 | let(:charset) { "utf8" } 19 | let(:collation_connection) { "utf8_general_ci" } 20 | let(:db_collation) { "utf8_unicode_ci" } 21 | 22 | let(:trigger) do 23 | described_class.new( 24 | [ 25 | name, 26 | event, 27 | table, 28 | statement, 29 | timing, 30 | created, 31 | sql_mode, 32 | definer, 33 | charset, 34 | collation_connection, 35 | db_collation 36 | ] 37 | ) 38 | end 39 | 40 | specify { expect(trigger.name).to eq name } 41 | specify { expect(trigger.event).to eq event } 42 | specify { expect(trigger.table).to eq table } 43 | specify { expect(trigger.statement).to eq statement } 44 | specify { expect(trigger.timing).to eq timing } 45 | specify { expect(trigger.created).to eq created } 46 | specify { expect(trigger.sql_mode).to eq sql_mode } 47 | specify { expect(trigger.definer).to eq definer } 48 | specify { expect(trigger.charset).to eq charset } 49 | specify { expect(trigger.collation_connection).to eq collation_connection } 50 | specify { expect(trigger.db_collation).to eq db_collation } 51 | 52 | specify { expect(trigger.columns).to eq %w[dog_id kitty_id] } 53 | 54 | specify do 55 | expect(trigger.schema_statement).to eq %{ add_polymorphic_triggers(:pets, ["dog_id", "kitty_id"])} 56 | end 57 | end 58 | --------------------------------------------------------------------------------