├── .gitignore ├── .rspec ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── activerecord-jsonb-associations.gemspec ├── benchmarks └── habtm.rake ├── bin ├── console └── setup ├── doc └── images │ ├── adding-associations.png │ └── deleting-associations.png ├── lib ├── activerecord │ └── jsonb │ │ ├── associations.rb │ │ ├── associations │ │ ├── association.rb │ │ ├── association_scope.rb │ │ ├── belongs_to_association.rb │ │ ├── builder │ │ │ ├── belongs_to.rb │ │ │ ├── has_many.rb │ │ │ └── has_one.rb │ │ ├── class_methods.rb │ │ ├── has_many_association.rb │ │ ├── join_dependency │ │ │ └── join_association.rb │ │ ├── preloader │ │ │ ├── association.rb │ │ │ └── has_many.rb │ │ └── version.rb │ │ └── connection_adapters │ │ └── reference_definition.rb └── arel │ └── nodes │ ├── jsonb_at_arrow.rb │ ├── jsonb_dash_double_arrow.rb │ ├── jsonb_double_pipe.rb │ ├── jsonb_hash_arrow.rb │ ├── jsonb_operator.rb │ └── jsonb_operators.rb └── spec ├── config └── database.yml.sample ├── integration ├── add_references_migration_spec.rb ├── belongs_to_integration_spec.rb ├── habtm_integration_spec.rb ├── has_many_integration_spec.rb └── has_one_integration_spec.rb ├── spec_helper.rb ├── support ├── factories.rb ├── helpers │ └── queries_counter.rb ├── models.rb └── schema.rb └── unit └── builder ├── belongs_to_spec.rb ├── has_many_spec.rb └── has_one_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .bundle/ 3 | log/*.log 4 | pkg/ 5 | .DS_Store 6 | 7 | spec/config/database.yml 8 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'pg' 6 | 7 | gem 'benchmark-ips' 8 | gem 'database_cleaner' 9 | gem 'factory_bot' 10 | gem 'pry' 11 | gem 'rake' 12 | gem 'rubocop' 13 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Yury Lebedev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # activerecord-json-associations 2 | 3 | [![Gem Version](https://badge.fury.io/rb/activerecord-jsonb-associations.svg)](https://badge.fury.io/rb/activerecord-jsonb-associations) 4 | 5 | Use PostgreSQL JSONB fields to store association information of your models. 6 | 7 | This gem was created as a solution to this [task](http://cultofmartians.com/tasks/active-record-jsonb-associations.html) from [EvilMartians](http://evilmartians.com). 8 | 9 | **Requirements:** 10 | 11 | - PostgreSQL (>= 9.6) 12 | 13 | ## Usage 14 | 15 | ### One-to-one and One-to-many associations 16 | 17 | You can store all foreign keys of your model in one JSONB column, without having to create multiple columns: 18 | 19 | ```ruby 20 | class Profile < ActiveRecord::Base 21 | # Setting additional :store option on :belongs_to association 22 | # enables saving of foreign ids in :extra JSONB column 23 | belongs_to :user, store: :extra 24 | end 25 | 26 | class SocialProfile < ActiveRecord::Base 27 | belongs_to :user, store: :extra 28 | end 29 | 30 | class User < ActiveRecord::Base 31 | # Parent model association needs to specify :foreign_store 32 | # for associations with JSONB storage 33 | has_one :profile, foreign_store: :extra 34 | has_many :social_profiles, foreign_store: :extra 35 | end 36 | ``` 37 | 38 | Foreign keys for association on one model have to be unique, even if they use different store column. 39 | 40 | You can also use `add_references` in your migration to add JSONB column and index for it (if `index: true` option is set): 41 | 42 | ```ruby 43 | add_reference :profiles, :users, store: :extra, index: true 44 | ``` 45 | 46 | ### Many-to-many associations 47 | 48 | You can also use JSONB columns on 2 sides of a HABTM association. This way you won't have to create a join table. 49 | 50 | ```ruby 51 | class Label < ActiveRecord::Base 52 | # extra['user_ids'] will store associated user ids 53 | has_and_belongs_to_many :users, store: :extra 54 | end 55 | 56 | class User < ActiveRecord::Base 57 | # extra['label_ids'] will store associated label ids 58 | has_and_belongs_to_many :labels, store: :extra 59 | end 60 | ``` 61 | 62 | #### Performance 63 | 64 | Compared to regular associations, fetching models associated via JSONB column has no drops in performance. 65 | 66 | Getting the count of connected records is ~35% faster with associations via JSONB (tested on associations with up to 10 000 connections). 67 | 68 | Adding new connections is slightly faster with JSONB, for scopes up to 500 records connected to another record (total count of records in the table does not matter that much. If you have more then ~500 records connected to one record on average, and you want to add new records to the scope, JSONB associations will be slower then traditional: 69 | 70 | JSONB HAMTB is slower on adding associations 71 | 72 | On the other hand, unassociating models from a big amount of associated models if faster with JSONB HABTM as the associations count grows: 73 | 74 | JSONB HAMTB is faster on removing associations 75 | 76 | ## Installation 77 | 78 | Add this line to your application's Gemfile: 79 | 80 | ```ruby 81 | gem 'activerecord-jsonb-associations' 82 | ``` 83 | 84 | And then execute: 85 | 86 | ```bash 87 | $ bundle install 88 | ``` 89 | 90 | ## Developing 91 | 92 | To setup development environment, just run: 93 | 94 | ```bash 95 | $ bin/setup 96 | ``` 97 | 98 | To run specs: 99 | 100 | ```bash 101 | $ bundle exec rspec 102 | ``` 103 | 104 | To run benchmarks (that will take a while): 105 | 106 | ```bash 107 | $ bundle exec rake benchmarks:habtm 108 | ``` 109 | 110 | ## License 111 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 112 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | Rake.add_rakelib 'benchmarks' 8 | 9 | require 'rdoc/task' 10 | 11 | RDoc::Task.new(:rdoc) do |rdoc| 12 | rdoc.rdoc_dir = 'rdoc' 13 | rdoc.title = 'ActiveRecord::JSONB::Associations' 14 | rdoc.options << '--line-numbers' 15 | rdoc.rdoc_files.include('README.md') 16 | rdoc.rdoc_files.include('lib/**/*.rb') 17 | end 18 | 19 | require 'bundler/gem_tasks' 20 | -------------------------------------------------------------------------------- /activerecord-jsonb-associations.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 2 | 3 | require 'activerecord/jsonb/associations/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'activerecord-jsonb-associations' 7 | spec.version = ActiveRecord::JSONB::Associations::VERSION 8 | spec.authors = ['Yury Lebedev'] 9 | spec.email = ['lebedev.yurii@gmail.com'] 10 | spec.license = 'MIT' 11 | spec.homepage = 12 | 'https://github.com/lebedev-yury/activerecord-jsonb-associations' 13 | spec.summary = 14 | 'Gem for storing association information using PostgreSQL JSONB columns' 15 | spec.description = 16 | 'Use PostgreSQL JSONB fields to store association information '\ 17 | 'of your models' 18 | 19 | spec.files = Dir[ 20 | '{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md' 21 | ] 22 | 23 | spec.required_ruby_version = '~> 2.0' 24 | 25 | spec.add_dependency 'activerecord', '~> 5.1.0' 26 | 27 | spec.add_development_dependency 'rspec', '~> 3.7.0' 28 | end 29 | -------------------------------------------------------------------------------- /benchmarks/habtm.rake: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift('./spec') 2 | require 'rspec' 3 | require 'spec_helper' 4 | require 'benchmark/ips' 5 | 6 | # rubocop:disable Metrics/BlockLength 7 | namespace :benchmarks do 8 | desc 'Regular vs JSONB HABTM benchmarks' 9 | task :habtm do 10 | [ 11 | 10, 100, 500, 1000, 2_500, 5_000, 10_000 12 | ].to_a.each do |associations_count| 13 | user = User.create 14 | user_with_groups_only = User.create 15 | user_with_labels_only = User.create 16 | 17 | FactoryBot.create_list :group, 18 | associations_count, 19 | users: [user, user_with_groups_only] 20 | 21 | FactoryBot.create_list :label, 22 | associations_count, 23 | users: [user, user_with_labels_only] 24 | 25 | Benchmark.ips do |x| 26 | x.config(warmup: 0) 27 | 28 | x.report( 29 | "Regular: fetching associations with #{associations_count} existing" 30 | ) do 31 | ActiveRecord::Base.transaction do 32 | Group.uncached { user.groups.reload } 33 | raise ActiveRecord::Rollback 34 | end 35 | end 36 | 37 | x.report( 38 | "JSONB: fetching associations with #{associations_count} existing" 39 | ) do 40 | ActiveRecord::Base.transaction do 41 | Label.uncached { user.labels.reload } 42 | raise ActiveRecord::Rollback 43 | end 44 | end 45 | 46 | x.compare! 47 | end 48 | 49 | Benchmark.ips do |x| 50 | x.config(warmup: 0) 51 | 52 | x.report( 53 | "Regular: getting association ids with #{associations_count} existing" 54 | ) do 55 | ActiveRecord::Base.transaction do 56 | Group.uncached { user.group_ids } 57 | raise ActiveRecord::Rollback 58 | end 59 | end 60 | 61 | x.report( 62 | "JSONB: getting association ids with #{associations_count} existing" 63 | ) do 64 | ActiveRecord::Base.transaction do 65 | Label.uncached { user.label_ids } 66 | raise ActiveRecord::Rollback 67 | end 68 | end 69 | 70 | x.compare! 71 | end 72 | 73 | Benchmark.ips do |x| 74 | x.config(warmup: 0) 75 | 76 | x.report( 77 | "Regular: #count on association with #{associations_count} existing" 78 | ) do 79 | ActiveRecord::Base.transaction do 80 | Group.uncached { user.groups.count } 81 | raise ActiveRecord::Rollback 82 | end 83 | end 84 | 85 | x.report( 86 | "JSONB: #count on association with #{associations_count} existing" 87 | ) do 88 | ActiveRecord::Base.transaction do 89 | Label.uncached { user.labels.count } 90 | raise ActiveRecord::Rollback 91 | end 92 | end 93 | 94 | x.compare! 95 | end 96 | 97 | Benchmark.ips do |x| 98 | x.config(warmup: 0) 99 | 100 | x.report( 101 | "Regular: adding new association to #{associations_count} existing" 102 | ) do 103 | ActiveRecord::Base.transaction do 104 | user.groups.create 105 | raise ActiveRecord::Rollback 106 | end 107 | end 108 | 109 | x.report( 110 | "JSONB: adding new association to #{associations_count} existing" 111 | ) do 112 | ActiveRecord::Base.transaction do 113 | user.labels.create 114 | raise ActiveRecord::Rollback 115 | end 116 | end 117 | 118 | x.compare! 119 | end 120 | 121 | Benchmark.ips do |x| 122 | x.config(warmup: 0) 123 | 124 | x.report( 125 | 'Regular: removing association with on model with '\ 126 | "#{associations_count} associations" 127 | ) do 128 | ActiveRecord::Base.transaction do 129 | user_with_groups_only.destroy 130 | raise ActiveRecord::Rollback 131 | end 132 | end 133 | 134 | x.report( 135 | 'JSONB: removing association with on model with '\ 136 | "#{associations_count} associations" 137 | ) do 138 | ActiveRecord::Base.transaction do 139 | user_with_labels_only.destroy 140 | raise ActiveRecord::Rollback 141 | end 142 | end 143 | 144 | x.compare! 145 | end 146 | 147 | User.delete_all 148 | Group.delete_all 149 | Label.delete_all 150 | end 151 | end 152 | end 153 | # rubocop:enable Metrics/BlockLength 154 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'activerecord/jsonb/associations' 5 | 6 | ActiveRecord::Base.logger = Logger.new(STDOUT) 7 | 8 | require 'pry' 9 | Pry.start 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'pathname' 4 | require 'fileutils' 5 | 6 | def system!(*args) 7 | system(*args) || abort("\n== Command #{args} failed ==") 8 | end 9 | 10 | FileUtils.chdir(Pathname.new(File.expand_path('../../', __FILE__))) do 11 | puts '== Installing dependencies ==' 12 | system! 'gem install bundler --conservative' 13 | system('bundle check') || system!('bundle install') 14 | 15 | puts "\n== Creating db config file ==" 16 | unless File.exist? 'spec/config/database.yml' 17 | db_config = File.read('spec/config/database.yml.sample') 18 | 19 | print 'Enter your PostgreSQL username: ' 20 | db_username = gets.chomp 21 | 22 | File.write( 23 | 'spec/config/database.yml', 24 | db_config.gsub('%USERNAME%', db_username) 25 | ) 26 | end 27 | 28 | puts "\n== Creating test database ==" 29 | system! 'dropdb activerecord_jsonb_associations_test --if-exists' 30 | system! 'createdb activerecord_jsonb_associations_test' 31 | end 32 | -------------------------------------------------------------------------------- /doc/images/adding-associations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y9v/activerecord-jsonb-associations/e369dc67856930327015afc5035047e659c3695c/doc/images/adding-associations.png -------------------------------------------------------------------------------- /doc/images/deleting-associations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y9v/activerecord-jsonb-associations/e369dc67856930327015afc5035047e659c3695c/doc/images/deleting-associations.png -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | require 'arel/nodes/jsonb_operators' 4 | require 'activerecord/jsonb/associations/class_methods' 5 | require 'activerecord/jsonb/associations/builder/belongs_to' 6 | require 'activerecord/jsonb/associations/builder/has_one' 7 | require 'activerecord/jsonb/associations/builder/has_many' 8 | require 'activerecord/jsonb/associations/belongs_to_association' 9 | require 'activerecord/jsonb/associations/association' 10 | require 'activerecord/jsonb/associations/has_many_association' 11 | require 'activerecord/jsonb/associations/association_scope' 12 | require 'activerecord/jsonb/associations/preloader/association' 13 | require 'activerecord/jsonb/associations/preloader/has_many' 14 | require 'activerecord/jsonb/associations/join_dependency/join_association' 15 | require 'activerecord/jsonb/connection_adapters/reference_definition' 16 | 17 | module ActiveRecord #:nodoc: 18 | module JSONB #:nodoc: 19 | module Associations #:nodoc: 20 | class ConflictingAssociation < StandardError; end 21 | end 22 | end 23 | end 24 | 25 | # rubocop:disable Metrics/BlockLength 26 | ActiveSupport.on_load :active_record do 27 | ::ActiveRecord::Base.extend( 28 | ActiveRecord::JSONB::Associations::ClassMethods 29 | ) 30 | 31 | ::ActiveRecord::Associations::Builder::BelongsTo.extend( 32 | ActiveRecord::JSONB::Associations::Builder::BelongsTo 33 | ) 34 | 35 | ::ActiveRecord::Associations::Builder::HasOne.extend( 36 | ActiveRecord::JSONB::Associations::Builder::HasOne 37 | ) 38 | 39 | ::ActiveRecord::Associations::Builder::HasMany.extend( 40 | ActiveRecord::JSONB::Associations::Builder::HasMany 41 | ) 42 | 43 | ::ActiveRecord::Associations::Association.prepend( 44 | ActiveRecord::JSONB::Associations::Association 45 | ) 46 | 47 | ::ActiveRecord::Associations::BelongsToAssociation.prepend( 48 | ActiveRecord::JSONB::Associations::BelongsToAssociation 49 | ) 50 | 51 | ::ActiveRecord::Associations::HasManyAssociation.prepend( 52 | ActiveRecord::JSONB::Associations::HasManyAssociation 53 | ) 54 | 55 | ::ActiveRecord::Associations::AssociationScope.prepend( 56 | ActiveRecord::JSONB::Associations::AssociationScope 57 | ) 58 | 59 | ::ActiveRecord::Associations::Preloader::Association.prepend( 60 | ActiveRecord::JSONB::Associations::Preloader::Association 61 | ) 62 | 63 | ::ActiveRecord::Associations::Preloader::HasMany.prepend( 64 | ActiveRecord::JSONB::Associations::Preloader::HasMany 65 | ) 66 | 67 | ::ActiveRecord::Associations::JoinDependency::JoinAssociation.prepend( 68 | ActiveRecord::JSONB::Associations::JoinDependency::JoinAssociation 69 | ) 70 | 71 | ::ActiveRecord::ConnectionAdapters::ReferenceDefinition.prepend( 72 | ActiveRecord::JSONB::ConnectionAdapters::ReferenceDefinition 73 | ) 74 | end 75 | # rubocop:enable Metrics/BlockLength 76 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Association #:nodoc: 5 | def creation_attributes 6 | return super unless reflection.options.key?(:foreign_store) 7 | 8 | attributes = {} 9 | jsonb_store = reflection.options[:foreign_store] 10 | attributes[jsonb_store] ||= {} 11 | attributes[jsonb_store][reflection.foreign_key] = 12 | owner[reflection.active_record_primary_key] 13 | 14 | attributes 15 | end 16 | 17 | # rubocop:disable Metrics/AbcSize 18 | def create_scope 19 | super.tap do |scope| 20 | next unless options.key?(:foreign_store) 21 | scope[options[:foreign_store].to_s] ||= {} 22 | scope[options[:foreign_store].to_s][reflection.foreign_key] = 23 | owner[reflection.active_record_primary_key] 24 | end 25 | end 26 | # rubocop:enable Metrics/AbcSize 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/association_scope.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module AssociationScope #:nodoc: 5 | # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 6 | def last_chain_scope(scope, table, owner_reflection, owner) 7 | reflection = owner_reflection.instance_variable_get(:@reflection) 8 | return super unless reflection 9 | 10 | join_keys = reflection.join_keys 11 | key = join_keys.key 12 | value = transform_value(owner[join_keys.foreign_key]) 13 | 14 | if reflection.options.key?(:foreign_store) 15 | apply_jsonb_scope( 16 | scope, 17 | jsonb_equality(table, reflection.options[:foreign_store], key), 18 | key, value 19 | ) 20 | elsif reflection && reflection.options.key?(:store) 21 | return super if reflection.belongs_to? 22 | pluralized_key = key.pluralize 23 | 24 | apply_jsonb_scope( 25 | scope, 26 | jsonb_containment( 27 | table, reflection.options[:store], pluralized_key 28 | ), 29 | pluralized_key, value 30 | ) 31 | else 32 | super 33 | end 34 | end 35 | # rubocop:enable Metrics/MethodLength, Metrics/AbcSize 36 | 37 | def apply_jsonb_scope(scope, predicate, key, value) 38 | scope.where!(predicate).tap do |arel_scope| 39 | arel_scope.where_clause.binds << Relation::QueryAttribute.new( 40 | key.to_s, value, ActiveModel::Type::String.new 41 | ) 42 | end 43 | end 44 | 45 | def jsonb_equality(table, jsonb_column, key) 46 | Arel::Nodes::JSONBDashDoubleArrow.new( 47 | table, table[jsonb_column], key 48 | ).eq(Arel::Nodes::BindParam.new) 49 | end 50 | 51 | def jsonb_containment(table, jsonb_column, key) 52 | Arel::Nodes::JSONBHashArrow.new( 53 | table, table[jsonb_column], key 54 | ).contains(Arel::Nodes::BindParam.new) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/belongs_to_association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module BelongsToAssociation #:nodoc: 5 | def replace_keys(record) 6 | return super unless reflection.options.key?(:store) 7 | 8 | owner[reflection.options[:store]][reflection.foreign_key] = 9 | record._read_attribute( 10 | reflection.association_primary_key(record.class) 11 | ) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/builder/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Builder 5 | module BelongsTo #:nodoc: 6 | def valid_options(options) 7 | super + [:store] 8 | end 9 | 10 | def define_accessors(mixin, reflection) 11 | if reflection.options.key?(:store) 12 | mixin.attribute reflection.foreign_key, :integer 13 | add_association_accessor_methods(mixin, reflection) 14 | end 15 | 16 | super 17 | end 18 | 19 | def add_association_accessor_methods(mixin, reflection) 20 | foreign_key = reflection.foreign_key.to_s 21 | 22 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 23 | if method_defined?(foreign_key) 24 | raise ActiveRecord::JSONB::Associations:: 25 | ConflictingAssociation, 26 | "Association with foreign key :#{foreign_key} already "\ 27 | "exists on #{reflection.active_record.name}" 28 | end 29 | 30 | def #{foreign_key}=(value) 31 | #{reflection.options[:store]}['#{foreign_key}'] = value 32 | end 33 | 34 | def #{foreign_key} 35 | #{reflection.options[:store]}['#{foreign_key}'] 36 | end 37 | 38 | def _read_attribute(attr_name) 39 | key = attr_name.to_s 40 | if key.ends_with?('_id') && #{reflection.options[:store]}.keys.include?(key) 41 | #{reflection.options[:store]}[key] 42 | else 43 | super 44 | end 45 | end 46 | 47 | def [](key) 48 | key = key.to_s 49 | if key.ends_with?('_id') && 50 | #{reflection.options[:store]}.keys.include?(key) 51 | #{reflection.options[:store]}[key] 52 | else 53 | super 54 | end 55 | end 56 | 57 | def []=(key, value) 58 | key = key.to_s 59 | if key.ends_with?('_id') && 60 | #{reflection.options[:store]}.keys.include?(key) 61 | #{reflection.options[:store]}[key] = value 62 | else 63 | super 64 | end 65 | end 66 | CODE 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/builder/has_many.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Builder 5 | module HasMany #:nodoc: 6 | def valid_options(options) 7 | super + %i[store foreign_store] 8 | end 9 | 10 | def define_accessors(mixin, reflection) 11 | if reflection.options.key?(:store) 12 | add_association_accessor_methods(mixin, reflection) 13 | end 14 | 15 | super 16 | end 17 | 18 | def add_association_accessor_methods(mixin, reflection) 19 | mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 20 | def [](key) 21 | key = key.to_s 22 | if key.ends_with?('_ids') && 23 | #{reflection.options[:store]}.keys.include?(key) 24 | #{reflection.options[:store]}[key] 25 | else 26 | super 27 | end 28 | end 29 | CODE 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/builder/has_one.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Builder 5 | module HasOne #:nodoc: 6 | def valid_options(options) 7 | super + [:foreign_store] 8 | end 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/class_methods.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module ClassMethods #:nodoc: 5 | # rubocop:disable Naming/PredicateName 6 | def has_and_belongs_to_many(name, scope = nil, **options, &extension) 7 | return super unless options.key?(:store) 8 | has_many(name, scope, **options, &extension) 9 | end 10 | # rubocop:enable Naming/PredicateName 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/has_many_association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module HasManyAssociation #:nodoc: 5 | def ids_reader 6 | return super unless reflection.options.key?(:store) 7 | 8 | Array( 9 | owner[reflection.options[:store]][ 10 | "#{reflection.name.to_s.singularize}_ids" 11 | ] 12 | ) 13 | end 14 | 15 | # rubocop:disable Naming/AccessorMethodName 16 | def set_owner_attributes(record) 17 | return super unless reflection.options.key?(:store) 18 | 19 | creation_attributes.each do |key, value| 20 | if key == reflection.options[:store] 21 | set_store_attributes(record, key, value) 22 | else 23 | record[key] = value 24 | end 25 | end 26 | end 27 | # rubocop:enable Naming/AccessorMethodName 28 | 29 | def set_store_attributes(record, store_column, attributes) 30 | attributes.each do |key, value| 31 | if value.is_a?(Array) 32 | record[store_column][key] ||= [] 33 | record[store_column][key] = 34 | record[store_column][key].concat(value).uniq 35 | else 36 | record[store_column] = value 37 | end 38 | end 39 | end 40 | 41 | # rubocop:disable Metrics/AbcSize 42 | def creation_attributes 43 | return super unless reflection.options.key?(:store) 44 | 45 | attributes = {} 46 | jsonb_store = reflection.options[:store] 47 | attributes[jsonb_store] ||= {} 48 | attributes[jsonb_store][reflection.foreign_key.pluralize] = [] 49 | attributes[jsonb_store][reflection.foreign_key.pluralize] << 50 | owner[reflection.active_record_primary_key] 51 | 52 | attributes 53 | end 54 | # rubocop:enable Metrics/AbcSize 55 | 56 | # rubocop:disable Metrics/AbcSize 57 | def create_scope 58 | super.tap do |scope| 59 | next unless options.key?(:store) 60 | 61 | key = reflection.foreign_key.pluralize 62 | scope[options[:store].to_s] ||= {} 63 | scope[options[:store].to_s][key] ||= [] 64 | scope[options[:store].to_s][key] << owner[ 65 | reflection.active_record_primary_key 66 | ] 67 | end 68 | end 69 | # rubocop:enable Metrics/AbcSize 70 | 71 | # rubocop:disable Metrics/MethodLength, Metrics/AbcSize 72 | def insert_record(record, validate = true, raise = false) 73 | super.tap do |super_result| 74 | next unless options.key?(:store) 75 | next unless super_result 76 | 77 | key = "#{record.model_name.singular}_ids" 78 | jsonb_column = options[:store] 79 | 80 | owner.class.where( 81 | owner.class.primary_key => owner[owner.class.primary_key] 82 | ).update_all(%( 83 | #{jsonb_column} = jsonb_set(#{jsonb_column}, '{#{key}}', 84 | coalesce(#{jsonb_column}->'#{key}', '[]'::jsonb) || 85 | '[#{record[klass.primary_key]}]'::jsonb) 86 | )) 87 | end 88 | end 89 | # rubocop:enable Metrics/MethodLength, Metrics/AbcSize 90 | 91 | def delete_records(records, method) 92 | return super unless options.key?(:store) 93 | super(records, :delete) 94 | end 95 | 96 | # rubocop:disable Metrics/AbcSize 97 | def delete_count(method, scope) 98 | store = reflection.options[:foreign_store] || 99 | reflection.options[:store] 100 | return super if method == :delete_all || !store 101 | 102 | if reflection.options.key?(:foreign_store) 103 | remove_jsonb_foreign_id_on_belongs_to(store, reflection.foreign_key) 104 | else 105 | remove_jsonb_foreign_id_on_habtm( 106 | store, reflection.foreign_key.pluralize, owner.id 107 | ) 108 | end 109 | end 110 | # rubocop:enable Metrics/AbcSize 111 | 112 | def remove_jsonb_foreign_id_on_belongs_to(store, foreign_key) 113 | scope.update_all("#{store} = #{store} #- '{#{foreign_key}}'") 114 | end 115 | 116 | def remove_jsonb_foreign_id_on_habtm(store, foreign_key, owner_id) 117 | # PostgreSQL can only delete jsonb array elements by text or index. 118 | # Therefore we have to convert the jsonb array to PostgreSQl array, 119 | # remove the element, and convert it back 120 | scope.update_all( 121 | %( 122 | #{store} = jsonb_set(#{store}, '{#{foreign_key}}', 123 | to_jsonb( 124 | array_remove( 125 | array(select * from jsonb_array_elements( 126 | (#{store}->'#{foreign_key}'))), 127 | '#{owner_id}'))) 128 | ) 129 | ) 130 | end 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/join_dependency/join_association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module JoinDependency 5 | module JoinAssociation #:nodoc: 6 | # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 7 | def build_constraint(klass, table, key, foreign_table, foreign_key) 8 | if reflection.options.key?(:foreign_store) 9 | build_eq_constraint( 10 | table, table[reflection.options[:foreign_store]], 11 | key, foreign_table, foreign_key 12 | ) 13 | elsif reflection.options.key?(:store) && reflection.belongs_to? 14 | build_eq_constraint( 15 | foreign_table, foreign_table[reflection.options[:store]], 16 | foreign_key, table, key 17 | ) 18 | elsif reflection.options.key?(:store) # && reflection.has_one? 19 | build_contains_constraint( 20 | table, table[reflection.options[:store]], 21 | key.pluralize, foreign_table, foreign_key 22 | ) 23 | else 24 | super 25 | end 26 | end 27 | # rubocop:enable Metrics/AbcSize, Metrics/MethodLength 28 | 29 | def build_eq_constraint( 30 | table, jsonb_column, key, foreign_table, foreign_key 31 | ) 32 | Arel::Nodes::JSONBDashDoubleArrow.new(table, jsonb_column, key).eq( 33 | ::Arel::Nodes::SqlLiteral.new( 34 | "CAST(#{foreign_table.name}.#{foreign_key} AS text)" 35 | ) 36 | ) 37 | end 38 | 39 | def build_contains_constraint( 40 | table, jsonb_column, key, foreign_table, foreign_key 41 | ) 42 | Arel::Nodes::JSONBHashArrow.new(table, jsonb_column, key).contains( 43 | ::Arel::Nodes::SqlLiteral.new( 44 | "jsonb_build_array(#{foreign_table.name}.#{foreign_key})" 45 | ) 46 | ) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/preloader/association.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Preloader 5 | module Association #:nodoc: 6 | def records_for(ids) 7 | return super unless reflection.options.key?(:foreign_store) 8 | 9 | scope.where( 10 | Arel::Nodes::JSONBHashArrow.new( 11 | table, 12 | table[reflection.options[:foreign_store]], 13 | association_key_name 14 | ).intersects_with(ids) 15 | ) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/preloader/has_many.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | module Preloader 5 | module HasMany #:nodoc: 6 | # rubocop:disable Metrics/AbcSize 7 | def records_for(ids) 8 | return super unless reflection.options.key?(:store) 9 | 10 | scope.where( 11 | Arel::Nodes::JSONBHashArrow.new( 12 | table, 13 | table[reflection.options[:store]], 14 | association_key_name.pluralize 15 | ).intersects_with(ids) 16 | ) 17 | end 18 | # rubocop:enable Metrics/AbcSize 19 | 20 | def association_key_name 21 | super_value = super 22 | return super_value unless reflection.options.key?(:store) 23 | super_value.pluralize 24 | end 25 | 26 | # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 27 | def associated_records_by_owner(preloader) 28 | return super unless reflection.options.key?(:store) 29 | 30 | records = load_records do |record| 31 | record[association_key_name].each do |owner_key| 32 | owner = owners_by_key[convert_key(owner_key)] 33 | association = owner.association(reflection.name) 34 | association.set_inverse_instance(record) 35 | end 36 | end 37 | 38 | owners.each_with_object({}) do |owner, result| 39 | result[owner] = records[convert_key(owner[owner_key_name])] || [] 40 | end 41 | end 42 | # rubocop:enable Metrics/AbcSize, Metrics/MethodLength 43 | 44 | # rubocop:disable Metrics/AbcSize 45 | def load_records(&block) 46 | return super unless reflection.options.key?(:store) 47 | 48 | return {} if owner_keys.empty? 49 | @preloaded_records = records_for(owner_keys).load(&block) 50 | @preloaded_records.each_with_object({}) do |record, result| 51 | record[association_key_name].each do |owner_key| 52 | result[convert_key(owner_key)] ||= [] 53 | result[convert_key(owner_key)] << record 54 | end 55 | end 56 | end 57 | # rubocop:enable Metrics/AbcSize 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/associations/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module Associations 4 | VERSION = '0.2.0'.freeze 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activerecord/jsonb/connection_adapters/reference_definition.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module JSONB 3 | module ConnectionAdapters 4 | module ReferenceDefinition #:nodoc: 5 | # rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists 6 | def initialize( 7 | name, 8 | polymorphic: false, 9 | index: true, 10 | foreign_key: false, 11 | type: :bigint, 12 | store: false, 13 | **options 14 | ) 15 | @store = store 16 | 17 | super( 18 | name, 19 | polymorphic: false, 20 | index: true, 21 | foreign_key: false, 22 | type: :bigint, 23 | **options 24 | ) 25 | end 26 | # rubocop:enable Lint/UnusedMethodArgument, Metrics/ParameterLists 27 | 28 | def add_to(table) 29 | return super unless store 30 | 31 | table.column(store, :jsonb, null: false, default: {}) 32 | 33 | return unless index 34 | 35 | column_names.each do |column_name| 36 | table.index( 37 | "(#{store}->>'#{column_name}')", 38 | using: :hash, 39 | name: "index_#{table.name}_on_#{store}_#{column_name}" 40 | ) 41 | end 42 | end 43 | 44 | protected 45 | 46 | attr_reader :store 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_at_arrow.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class JSONBAtArrow < JSONBOperator #:nodoc: 4 | def operator 5 | '@>' 6 | end 7 | 8 | def right_side 9 | return name if name.is_a?(::Arel::Nodes::BindParam) || 10 | name.is_a?(::Arel::Nodes::SqlLiteral) 11 | 12 | ::Arel::Nodes::SqlLiteral.new("'#{name.to_json}'") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_dash_double_arrow.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class JSONBDashDoubleArrow < JSONBOperator #:nodoc: 4 | def operator 5 | '->>' 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_double_pipe.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class JSONBDoublePipe < JSONBOperator #:nodoc: 4 | def operator 5 | '||' 6 | end 7 | 8 | def right_side 9 | return name if name.is_a?(::Arel::Nodes::BindParam) 10 | ::Arel::Nodes::SqlLiteral.new("'#{name.to_json}'") 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_hash_arrow.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class JSONBHashArrow < Arel::Nodes::JSONBOperator #:nodoc: 4 | def operator 5 | '#>' 6 | end 7 | 8 | def right_side 9 | ::Arel::Nodes::SqlLiteral.new("'{#{name}}'") 10 | end 11 | 12 | def contains(value) 13 | Arel::Nodes::JSONBAtArrow.new(relation, self, value) 14 | end 15 | 16 | def intersects_with(array) 17 | ::Arel::Nodes::InfixOperation.new( 18 | '>', 19 | ::Arel::Nodes::NamedFunction.new( 20 | 'jsonb_array_length', 21 | [Arel::Nodes::JSONBDoublePipe.new(relation, self, array)] 22 | ), 23 | 0 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_operator.rb: -------------------------------------------------------------------------------- 1 | module Arel 2 | module Nodes 3 | class JSONBOperator < ::Arel::Nodes::InfixOperation #:nodoc: 4 | attr_reader :relation 5 | attr_reader :name 6 | 7 | def initialize(relation, left_side, key) 8 | @relation = relation 9 | @name = key 10 | 11 | super(operator, left_side, right_side) 12 | end 13 | 14 | def right_side 15 | ::Arel::Nodes::SqlLiteral.new("'#{name}'") 16 | end 17 | 18 | def operator 19 | raise NotImplementedError, 20 | 'Subclasses must implement an #operator method' 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/arel/nodes/jsonb_operators.rb: -------------------------------------------------------------------------------- 1 | require 'arel/nodes/jsonb_operator' 2 | require 'arel/nodes/jsonb_dash_double_arrow' 3 | require 'arel/nodes/jsonb_at_arrow' 4 | require 'arel/nodes/jsonb_double_pipe' 5 | require 'arel/nodes/jsonb_hash_arrow' 6 | -------------------------------------------------------------------------------- /spec/config/database.yml.sample: -------------------------------------------------------------------------------- 1 | pg: 2 | username: "%USERNAME%" 3 | -------------------------------------------------------------------------------- /spec/integration/add_references_migration_spec.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | RSpec.describe ':add_references migration command' do 3 | let(:schema_cache) do 4 | ActiveRecord::Base.connection.schema_cache 5 | end 6 | 7 | let(:foo_column) do 8 | schema_cache.columns_hash(SocialProfile.table_name)['foo'] 9 | end 10 | 11 | let(:foo_index_on_user_id) do 12 | schema_cache.connection.indexes(SocialProfile.table_name).find do |index| 13 | index.name == 'index_social_profiles_on_foo_user_id' 14 | end 15 | end 16 | 17 | describe '#change' do 18 | before(:all) do 19 | class AddUsersReferenceToSocialProfiles < ActiveRecord::Migration[5.1] 20 | def change 21 | add_reference :social_profiles, :user, store: :foo, index: true 22 | end 23 | end 24 | 25 | AddUsersReferenceToSocialProfiles.new.change 26 | end 27 | 28 | it 'creates :foo column with :jsonb type' do 29 | expect(foo_column).to be_present 30 | expect(foo_column.type).to eq(:jsonb) 31 | end 32 | 33 | it 'creates index on foo->>user_id' do 34 | expect(foo_index_on_user_id).to be_present 35 | expect(foo_index_on_user_id.columns).to eq("((foo ->> 'user_id'::text))") 36 | end 37 | end 38 | 39 | describe 'index usage' do 40 | let(:parent) { create :user } 41 | let!(:children) { create_list :social_profile, 3, user: parent } 42 | let(:index_name) { 'index_social_profiles_on_extra_user_id' } 43 | 44 | it 'does index scan when getting associated models' do 45 | expect( 46 | parent.social_profiles.explain 47 | ).to include("Bitmap Index Scan on #{index_name}") 48 | end 49 | 50 | it 'does index scan on #eager_load' do 51 | expect( 52 | User.all.eager_load(:social_profiles).explain 53 | ).to include("Index Scan using #{index_name}") 54 | end 55 | end 56 | end 57 | # rubocop:enable Metrics/BlockLength 58 | -------------------------------------------------------------------------------- /spec/integration/belongs_to_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | RSpec.shared_examples ':belongs_to association' do |store_type: :regular| 3 | let(:parent_class) { parent_model.class } 4 | let(:child_class) { child_model.class } 5 | let(:parent_name) { parent_model.model_name.element } 6 | let(:child_name) { child_model.model_name.element } 7 | 8 | describe '#association' do 9 | before do 10 | if store_type == :jsonb 11 | child_model.update store => { foreign_key.to_s => parent_model.id } 12 | else 13 | child_model.update foreign_key => parent_model.id 14 | end 15 | end 16 | 17 | it 'properly loads association from parent model' do 18 | expect(child_model.reload.send(parent_name)).to eq(parent_model) 19 | end 20 | end 21 | 22 | describe '#association=' do 23 | before do 24 | child_model.update parent_name => parent_model 25 | end 26 | 27 | it 'sets and persists foreign key in jsonb store on child model', 28 | if: store_type.eql?(:jsonb) do 29 | expect( 30 | child_model.reload.send(store) 31 | ).to eq(foreign_key.to_s => parent_model.id) 32 | end 33 | 34 | it 'sets and persists regular foreign key on child model', 35 | if: store_type.eql?(:regular) do 36 | expect(child_model.reload.send(foreign_key)).to eq(parent_model.id) 37 | end 38 | end 39 | 40 | describe 'association_id', if: store_type.eql?(:jsonb) do 41 | before do 42 | child_model.update store => { foreign_key.to_s => parent_model.id } 43 | end 44 | 45 | it 'reads foreign id from specified :store column by foreign key' do 46 | expect(child_model.send(foreign_key)).to eq parent_model.id 47 | end 48 | end 49 | 50 | describe '#association_id=', if: store_type.eql?(:jsonb) do 51 | before do 52 | child_model.send "#{foreign_key}=", parent_model.id 53 | end 54 | 55 | it 'sets foreign id in specified :store column as hash item' do 56 | expect(child_model.send(store)[foreign_key.to_s]).to eq(parent_model.id) 57 | end 58 | end 59 | 60 | describe '#build_association' do 61 | let!(:built_association) do 62 | child_model.send("build_#{parent_name}") 63 | end 64 | 65 | it 'sets foreign key on child model in jsonb store on parent save', 66 | if: store_type.eql?(:jsonb) do 67 | built_association.save 68 | 69 | expect( 70 | child_model.send(store) 71 | ).to eq(foreign_key.to_s => built_association.id) 72 | end 73 | 74 | it 'sets foreign key on child model in jsonb store on parent save', 75 | if: store_type.eql?(:regular) do 76 | built_association.save 77 | 78 | expect(child_model.send(foreign_key)).to eq(built_association.reload.id) 79 | end 80 | end 81 | 82 | describe '#create_association' do 83 | let!(:created_association) do 84 | child_model.send "create_#{parent_name}" 85 | end 86 | 87 | it 'sets and persists foreign key on child model in jsonb store', 88 | if: store_type.eql?(:jsonb) do 89 | expect( 90 | child_model.send(store) 91 | ).to eq(foreign_key.to_s => created_association.id) 92 | end 93 | 94 | it 'sets and persists foreign key on child model', 95 | if: store_type.eql?(:regular) do 96 | expect( 97 | child_model.reload.send(foreign_key) 98 | ).to eq(created_association.id) 99 | end 100 | end 101 | 102 | describe '#reload_association' do 103 | before do 104 | parent_model.send "#{child_name}=", child_model 105 | end 106 | 107 | it 'reloads association' do 108 | expect(child_model.send("reload_#{parent_name}")).to eq(parent_model) 109 | end 110 | end 111 | 112 | describe '#preload / #includes' do 113 | before do 114 | parent_class.destroy_all 115 | create_list(child_name, 3, "with_#{parent_name}".to_sym) 116 | end 117 | 118 | it 'makes 2 queries' do 119 | expect(count_queries do 120 | child_class.all.preload(parent_name).map do |record| 121 | record.send(parent_name).id 122 | end 123 | end).to eq(2) 124 | end 125 | end 126 | 127 | describe '#eager_load / #joins' do 128 | before do 129 | parent_class.destroy_all 130 | create_list(child_name, 3, "with_#{parent_name}".to_sym) 131 | end 132 | 133 | it 'makes 1 query' do 134 | expect(count_queries do 135 | child_class.all.eager_load(parent_name).map do |record| 136 | record.send(parent_name).id 137 | end 138 | end).to eq(1) 139 | end 140 | end 141 | end 142 | 143 | RSpec.describe ':belongs_to' do 144 | context 'regular association' do 145 | include_examples ':belongs_to association' do 146 | let(:parent_model) { User.create } 147 | let(:child_model) { Profile.new } 148 | let(:foreign_key) { :user_id } 149 | end 150 | end 151 | 152 | context 'association with :store option set on child model' do 153 | context 'with default options' do 154 | include_examples ':belongs_to association', store_type: :jsonb do 155 | let(:parent_model) { User.create } 156 | let(:child_model) { Account.new } 157 | let(:store) { :extra } 158 | let(:foreign_key) { :user_id } 159 | end 160 | end 161 | 162 | context 'with non-default :options' do 163 | include_examples ':belongs_to association', store_type: :jsonb do 164 | let(:parent_model) { GoodsSupplier.create } 165 | let(:parent_name) { :supplier } 166 | let(:child_model) { Account.new } 167 | let(:store) { :extra } 168 | let(:foreign_key) { :supplier_id } 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/integration/habtm_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | RSpec.shared_examples( 3 | ':has_and_belongs_to_many association' 4 | ) do |store_type: :regular| 5 | let(:parent_factory_name) { parent_class.model_name.singular } 6 | let(:child_factory_name) { child_class.model_name.singular } 7 | let(:child_foreign_key) { "#{child_class.model_name.singular}_ids" } 8 | let(:parent_foreign_key) { "#{parent_class.model_name.singular}_ids" } 9 | let(:parent_association_name) { parent_class.model_name.plural } 10 | let(:child_association_name) { child_class.model_name.plural } 11 | 12 | let(:children) do 13 | build_list child_factory_name, 3 14 | end 15 | 16 | let!(:parents) do 17 | create_list parent_factory_name, 3, child_association_name => children 18 | end 19 | 20 | describe '#collection' do 21 | it 'returns associated parents on children' do 22 | expect( 23 | parents.map do |parent| 24 | parent.send(child_association_name).reload.sort 25 | end 26 | ).to all(eq(children)) 27 | end 28 | 29 | it 'returns associated children on parents' do 30 | expect( 31 | children.map do |child| 32 | child.send(parent_association_name).reload.sort 33 | end 34 | ).to all(eq(parents)) 35 | end 36 | end 37 | 38 | describe '#collection<<' do 39 | let(:new_child) { create child_factory_name } 40 | 41 | before do 42 | parents.each do |parent| 43 | parent.send(child_association_name) << new_child 44 | end 45 | end 46 | 47 | it 'adds child model to parent children' do 48 | expect( 49 | parents.map { |parent| parent.send(child_association_name).reload } 50 | ).to all(include(new_child)) 51 | end 52 | 53 | it 'adds parent model to child parents' do 54 | expect( 55 | new_child.send(parent_association_name).reload.sort 56 | ).to eq(parents) 57 | end 58 | end 59 | 60 | describe '#collection_ids' do 61 | it 'returns associated parent ids on child' do 62 | expect( 63 | parents.map { |child| child.reload.send(child_foreign_key).sort } 64 | ).to all(eq(children.map(&:id))) 65 | end 66 | 67 | it 'returns associated children ids on parent' do 68 | expect( 69 | children.map { |child| child.reload.send(parent_foreign_key).sort } 70 | ).to all(eq(parents.map(&:id))) 71 | end 72 | end 73 | 74 | describe '#collection_ids=' do 75 | let(:new_parent) { create parent_factory_name } 76 | 77 | before do 78 | new_parent.send "#{child_factory_name}_ids=", children.map(&:id) 79 | end 80 | 81 | it 'sets associated objects by ids' do 82 | expect( 83 | new_parent.send(child_association_name).reload.sort 84 | ).to eq(children) 85 | end 86 | end 87 | 88 | describe '#collection.destroy' do 89 | let!(:child_to_remove) { children.first } 90 | 91 | before do 92 | parents.each do |parent| 93 | parent.send(child_association_name).destroy(child_to_remove) 94 | end 95 | end 96 | 97 | it 'sets the foreign id to NULL on removed association' do 98 | parents.each do |parent| 99 | expect( 100 | parent.send(child_association_name).reload 101 | ).not_to include(child_to_remove) 102 | end 103 | end 104 | 105 | it 'does not destroy the associated model' do 106 | expect(child_class.find_by(id: child_to_remove.id)).not_to be_nil 107 | end 108 | end 109 | 110 | describe '#collection.delete' do 111 | let!(:child_to_remove) { children.first } 112 | 113 | before do 114 | parents.each do |parent| 115 | parent.send(child_association_name).delete(child_to_remove) 116 | end 117 | end 118 | 119 | it 'removes the foreign id JSONB array on removed association', 120 | if: store_type.eql?(:jsonb) do 121 | expect( 122 | child_to_remove.reload.send(store)[parent_foreign_key].sort 123 | ).not_to include(*parents.map(&:id)) 124 | end 125 | 126 | it 'removes object from collection' do 127 | parents.each do |parent| 128 | expect( 129 | parent.send(child_association_name).reload 130 | ).not_to include(child_to_remove) 131 | end 132 | end 133 | end 134 | 135 | describe '#collection.clear' do 136 | before do 137 | parents.each { |parent| parent.send(child_association_name).clear } 138 | end 139 | 140 | it 'removes all associated records' do 141 | expect( 142 | parents.map { |parent| parent.send(child_association_name).reload } 143 | ).to all(be_empty) 144 | end 145 | end 146 | 147 | describe '#collection.build' do 148 | let(:parent) { parents.first } 149 | let(:built_child) { parent.send(child_association_name).build } 150 | 151 | it 'adds foreign id to jsonb ids array', if: store_type.eql?(:jsonb) do 152 | expect( 153 | built_child.send(store)[parent_foreign_key.to_s] 154 | ).to eq([parent.id]) 155 | end 156 | end 157 | 158 | describe '#collection.create' do 159 | let(:parent) { parents.first } 160 | let(:created_child) { parent.send(child_association_name).create } 161 | 162 | it 'adds foreign id to jsonb ids array', if: store_type.eql?(:jsonb) do 163 | expect( 164 | created_child.send(store)[parent_foreign_key.to_s] 165 | ).to eq([parent.id]) 166 | end 167 | end 168 | 169 | describe '#preload / #includes' do 170 | let(:expected_queries_count) do 171 | store_type == :jsonb ? 2 : 3 172 | end 173 | 174 | subject(:records) { parent_class.all.preload(child_association_name) } 175 | 176 | it 'makes 2 queries' do 177 | expect(count_queries do 178 | records.map do |parent| 179 | parent.send(child_association_name).map(&:id) 180 | end 181 | end).to eq(expected_queries_count) 182 | end 183 | 184 | it 'preloads associated records attributes' do 185 | expect( 186 | records.map do |record| 187 | record.send(child_association_name).map(&:id).compact.count 188 | end 189 | ).to all(eq(3)) 190 | end 191 | end 192 | 193 | describe '#eager_load / #joins' do 194 | subject(:records) { parent_class.all.eager_load(child_association_name) } 195 | 196 | it 'makes 1 query' do 197 | expect(count_queries do 198 | records.map do |record| 199 | record.send(child_association_name).map(&:id) 200 | end 201 | end).to eq(1) 202 | end 203 | 204 | it 'preloads associated records attributes' do 205 | expect( 206 | records.map do |record| 207 | record.send(child_association_name).map(&:id).compact.count 208 | end 209 | ).to all(eq(3)) 210 | end 211 | end 212 | end 213 | 214 | RSpec.describe ':has_and_belongs_to_many' do 215 | context 'regular association' do 216 | include_examples ':has_and_belongs_to_many association' do 217 | let(:parent_class) { User } 218 | let(:child_class) { Group } 219 | end 220 | end 221 | 222 | context 'association with :store option set' do 223 | include_examples ':has_and_belongs_to_many association', 224 | store_type: :jsonb do 225 | let(:parent_class) { User } 226 | let(:child_class) { Label } 227 | let(:store) { :extra } 228 | end 229 | end 230 | end 231 | # rubocop:enable Metrics/BlockLength 232 | -------------------------------------------------------------------------------- /spec/integration/has_many_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | RSpec.shared_examples ':has_many association' do |store_type: :regular| 3 | let(:parent_association_name) { parent_class.model_name.singular } 4 | let(:child_association_name) { child_class.model_name.plural } 5 | let(:child_factory_name) { child_class.model_name.singular } 6 | let(:collection) { parent.send(child_association_name) } 7 | 8 | describe '#collection' do 9 | it 'returns associated objects' do 10 | children 11 | expect(collection.reload).to eq(children) 12 | end 13 | end 14 | 15 | describe '#collection<<' do 16 | let(:child) { build child_factory_name } 17 | 18 | before do 19 | parent.send(child_association_name) << child 20 | end 21 | 22 | it 'adds model to collection' do 23 | expect(collection.reload).to eq([child]) 24 | end 25 | end 26 | 27 | describe '#collection=' do 28 | before do 29 | parent.send "#{child_association_name}=", children 30 | end 31 | 32 | it 'returns associated objects' do 33 | expect(collection.reload).to eq(children) 34 | end 35 | end 36 | 37 | describe '#collection_ids' do 38 | it 'returns associated object ids' do 39 | children 40 | expect( 41 | parent.reload.send("#{child_class.model_name.singular}_ids") 42 | ).to eq(children.map(&:id)) 43 | end 44 | end 45 | 46 | describe '#collection_ids=' do 47 | before do 48 | parent.send "#{child_factory_name}_ids=", children.map(&:id) 49 | end 50 | 51 | it 'sets associated objects by ids' do 52 | expect(collection.reload).to eq(children) 53 | end 54 | end 55 | 56 | describe '#collection.destroy' do 57 | let!(:child_to_remove) { children.first } 58 | 59 | before do 60 | parent.send(child_association_name).destroy(child_to_remove) 61 | end 62 | 63 | it 'sets the foreign id to NULL on removed association' do 64 | expect(collection.reload).not_to include(child_to_remove) 65 | end 66 | 67 | it 'destroys the associated model' do 68 | expect(child_class.find_by(id: child_to_remove.id)).to be_nil 69 | end 70 | end 71 | 72 | describe '#collection.delete' do 73 | let!(:child_to_remove) { children.first } 74 | 75 | before do 76 | collection.delete(child_to_remove) 77 | end 78 | 79 | it 'sets the foreign id to NULL on removed association', 80 | if: store_type.eql?(:regular) do 81 | expect(child_to_remove.reload.send(foreign_key)).to be_nil 82 | end 83 | 84 | it 'sets the foreign id to NULL in JSONB column on removed association', 85 | if: store_type.eql?(:jsonb) do 86 | expect(child_to_remove.reload.send(store)[foreign_key]).to be_nil 87 | end 88 | 89 | it 'removes object from collection' do 90 | expect(collection.reload).not_to include(child_to_remove) 91 | end 92 | end 93 | 94 | describe '#collection.clear' do 95 | before do 96 | children 97 | collection.clear 98 | end 99 | 100 | it 'removes all associated records' do 101 | expect(collection.reload).to be_empty 102 | end 103 | end 104 | 105 | describe '#collection.build' do 106 | let(:built_child) { collection.build } 107 | 108 | it 'sets the foreign_key', if: store_type.eql?(:regular) do 109 | expect(built_child.send(foreign_key)).to eq(parent.id) 110 | end 111 | 112 | it 'sets the foreign id in store column', if: store_type.eql?(:jsonb) do 113 | expect(built_child.send(store)[foreign_key.to_s]).to eq(parent.id) 114 | end 115 | end 116 | 117 | describe '#collection.create' do 118 | let(:created_child) { collection.create } 119 | 120 | it 'sets the foreign_key', if: store_type.eql?(:regular) do 121 | expect(created_child.reload.send(foreign_key)).to eq(parent.id) 122 | end 123 | 124 | it 'sets the foreign id in store column', if: store_type.eql?(:jsonb) do 125 | expect( 126 | created_child.reload.send(store)[foreign_key.to_s] 127 | ).to eq(parent.id) 128 | end 129 | end 130 | 131 | describe '#preload / #includes' do 132 | let!(:children) do 133 | create_list(child_factory_name, 3, parent_association_name => parent) 134 | end 135 | 136 | subject(:records) { parent_class.all.preload(child_association_name) } 137 | 138 | it 'makes 2 queries' do 139 | expect(count_queries do 140 | records.map { |record| record.send(child_association_name).map(&:id) } 141 | end).to eq(2) 142 | end 143 | 144 | it 'preloads associated records attributes' do 145 | expect( 146 | records.map do |record| 147 | record.send(child_association_name).map(&:id).compact.count 148 | end 149 | ).to all(eq(3)) 150 | end 151 | end 152 | 153 | describe '#eager_load / #joins' do 154 | let!(:children) do 155 | create_list(child_factory_name, 3, parent_association_name => parent) 156 | end 157 | 158 | subject(:records) { parent_class.all.eager_load(child_association_name) } 159 | 160 | it 'makes 1 query' do 161 | expect(count_queries do 162 | records.map do |record| 163 | record.send(child_association_name).map(&:id) 164 | end 165 | end).to eq(1) 166 | end 167 | 168 | it 'preloads associated records attributes' do 169 | expect( 170 | records.map do |record| 171 | record.send(child_association_name).map(&:id).compact.count 172 | end 173 | ).to all(eq(3)) 174 | end 175 | end 176 | end 177 | 178 | RSpec.describe ':has_many' do 179 | context 'regular association' do 180 | include_examples ':has_many association' do 181 | let(:parent_class) { User } 182 | let(:child_class) { Photo } 183 | let(:store) { :extra } 184 | let(:foreign_key) { :user_id } 185 | let(:parent) { create :user } 186 | 187 | let(:children) do 188 | create_list child_factory_name, 3, foreign_key => parent.id 189 | end 190 | end 191 | end 192 | 193 | context 'association with :store option set on child models' do 194 | let(:children) do 195 | create_list child_factory_name, 3, 196 | store => { foreign_key => parent.id } 197 | end 198 | 199 | context 'with default options' do 200 | include_examples ':has_many association', store_type: :jsonb do 201 | let(:parent_class) { User } 202 | let(:child_class) { SocialProfile } 203 | let(:store) { :extra } 204 | let(:foreign_key) { :user_id } 205 | let(:parent) { create :user } 206 | end 207 | end 208 | 209 | context 'with non-default options' do 210 | include_examples ':has_many association', store_type: :jsonb do 211 | let(:parent_class) { GoodsSupplier } 212 | let(:child_class) { InvoicePhoto } 213 | let(:store) { :extra } 214 | let(:foreign_key) { :supplier_id } 215 | let(:parent_association_name) { :supplier } 216 | let(:parent) { create :goods_supplier } 217 | end 218 | end 219 | end 220 | end 221 | # rubocop:enable Metrics/BlockLength 222 | -------------------------------------------------------------------------------- /spec/integration/has_one_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | RSpec.shared_examples ':has_one association' do |store_type: :regular| 3 | let(:parent_class) { parent_model.class } 4 | let(:child_class) { child_model.class } 5 | let(:parent_name) { parent_model.model_name.element } 6 | let(:child_name) { child_model.model_name.element } 7 | 8 | describe '#association' do 9 | before do 10 | if store_type == :jsonb 11 | child_model.update store => { foreign_key.to_s => parent_model.id } 12 | else 13 | child_model.update foreign_key => parent_model.id 14 | end 15 | end 16 | 17 | it 'properly loads association from parent model' do 18 | expect(parent_model.reload.send(child_name)).to eq(child_model) 19 | end 20 | end 21 | 22 | describe '#association=' do 23 | before do 24 | parent_model.send "#{child_name}=", child_model 25 | end 26 | 27 | it 'sets and persists foreign key in jsonb store on child model', 28 | if: store_type.eql?(:jsonb) do 29 | expect( 30 | child_model.reload.send(store) 31 | ).to eq(foreign_key.to_s => parent_model.id) 32 | end 33 | 34 | it 'sets and persists regular foreign key on child model', 35 | if: store_type.eql?(:regular) do 36 | expect(child_model.reload.send(foreign_key)).to eq(parent_model.id) 37 | end 38 | end 39 | 40 | describe '#build_association' do 41 | let(:built_association) do 42 | parent_model.send "build_#{child_name}" 43 | end 44 | 45 | it 'sets foreign key on child model in jsonb store', 46 | if: store_type.eql?(:jsonb) do 47 | expect( 48 | built_association.send(store) 49 | ).to eq(foreign_key.to_s => parent_model.id) 50 | end 51 | 52 | it 'sets foreign key on child model', if: store_type.eql?(:regular) do 53 | expect(built_association.send(foreign_key)).to eq(parent_model.id) 54 | end 55 | end 56 | 57 | describe '#create_association' do 58 | let(:created_association) do 59 | parent_model.send "create_#{child_name}" 60 | end 61 | 62 | it 'sets and persists foreign key on child model in jsonb store', 63 | if: store_type.eql?(:jsonb) do 64 | expect( 65 | created_association.reload.send(store) 66 | ).to eq(foreign_key.to_s => parent_model.id) 67 | end 68 | 69 | it 'sets and persists foreign key on child model', 70 | if: store_type.eql?(:regular) do 71 | expect( 72 | created_association.reload.send(foreign_key) 73 | ).to eq(parent_model.id) 74 | end 75 | end 76 | 77 | describe '#reload_association' do 78 | before do 79 | parent_model.send "#{child_name}=", child_model 80 | end 81 | 82 | it 'reloads association' do 83 | expect(parent_model.send("reload_#{child_name}")).to eq(child_model) 84 | end 85 | end 86 | 87 | describe '#preload / #includes' do 88 | before do 89 | parent_class.destroy_all 90 | create_list(child_name, 3, "with_#{parent_name}".to_sym) 91 | end 92 | 93 | it 'makes 2 queries' do 94 | expect(count_queries do 95 | parent_class.all.preload(child_name).map do |record| 96 | record.send(child_name).id 97 | end 98 | end).to eq(2) 99 | end 100 | end 101 | 102 | describe '#eager_load / #joins' do 103 | before do 104 | parent_class.destroy_all 105 | create_list(child_name, 3, "with_#{parent_name}".to_sym) 106 | end 107 | 108 | it 'makes 1 query' do 109 | expect(count_queries do 110 | parent_class.all.eager_load(child_name).map do |record| 111 | record.send(child_name).id 112 | end 113 | end).to eq(1) 114 | end 115 | end 116 | end 117 | 118 | RSpec.describe ':has_one' do 119 | context 'regular association' do 120 | include_examples ':has_one association' do 121 | let(:parent_model) { User.create } 122 | let(:child_model) { Profile.new } 123 | let(:foreign_key) { :user_id } 124 | end 125 | end 126 | 127 | context 'association with :store option set on child model' do 128 | context 'with default options' do 129 | include_examples ':has_one association', store_type: :jsonb do 130 | let(:parent_model) { User.create } 131 | let(:child_model) { Account.new } 132 | let(:store) { :extra } 133 | let(:foreign_key) { :user_id } 134 | end 135 | end 136 | 137 | context 'with non-default :options' do 138 | include_examples ':has_one association', store_type: :jsonb do 139 | let(:parent_model) { GoodsSupplier.create } 140 | let(:child_model) { Account.new } 141 | let(:store) { :extra } 142 | let(:foreign_key) { :supplier_id } 143 | end 144 | end 145 | end 146 | 147 | context 'when 2 associations on one model have the same foreign_key' do 148 | it 'raises an error' do 149 | expect do 150 | class Foo < ActiveRecord::Base 151 | belongs_to :bar, store: :extra, foreign_key: :bar_id 152 | belongs_to :baz, store: :extra, foreign_key: :bar_id 153 | end 154 | end.to raise_error( 155 | ActiveRecord::JSONB::Associations::ConflictingAssociation, 156 | 'Association with foreign key :bar_id already exists on Foo' 157 | ) 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'database_cleaner' 3 | require 'factory_bot' 4 | require 'pry' 5 | 6 | require 'activerecord/jsonb/associations' 7 | 8 | require 'support/helpers/queries_counter' 9 | require 'support/schema' 10 | require 'support/models' 11 | require 'support/factories' 12 | 13 | RSpec.configure do |config| 14 | config.include FactoryBot::Syntax::Methods 15 | config.include Helpers::QueriesCounter 16 | 17 | config.before(:suite) do 18 | DatabaseCleaner.strategy = :transaction 19 | DatabaseCleaner.clean_with(:truncation) 20 | end 21 | 22 | config.around(:each) do |example| 23 | DatabaseCleaner.cleaning do 24 | example.run 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Metrics/BlockLength 2 | FactoryBot.define do 3 | factory :user do 4 | trait :with_groups do 5 | transient do 6 | groups_count { 3 } 7 | end 8 | 9 | after(:create) do |user, evaluator| 10 | create_list :group, evaluator.groups_count, users: [user] 11 | end 12 | end 13 | 14 | trait :with_labels do 15 | transient do 16 | labels_count { 3 } 17 | end 18 | 19 | after(:create) do |user, evaluator| 20 | create_list :label, evaluator.labels_count, users: [user] 21 | end 22 | end 23 | end 24 | 25 | factory :goods_supplier do 26 | factory :supplier do 27 | end 28 | end 29 | 30 | factory :account do 31 | trait :with_user do 32 | user 33 | end 34 | 35 | trait :with_goods_supplier do 36 | association :supplier, factory: :supplier 37 | end 38 | 39 | trait :with_supplier do 40 | with_goods_supplier 41 | end 42 | end 43 | 44 | factory :profile do 45 | trait :with_user do 46 | user 47 | end 48 | end 49 | 50 | factory :social_profile do 51 | end 52 | 53 | factory :photo do 54 | end 55 | 56 | factory :invoice_photo do 57 | end 58 | 59 | factory :label do 60 | trait :with_users do 61 | transient do 62 | users_count { 3 } 63 | end 64 | 65 | after(:create) do |label, evaluator| 66 | label.users = create_list(:user, evaluator.users_count) 67 | end 68 | end 69 | end 70 | 71 | factory :group do 72 | trait :with_users do 73 | transient do 74 | users_count { 3 } 75 | end 76 | 77 | after(:create) do |group, evaluator| 78 | group.users = create_list(:user, evaluator.users_count) 79 | end 80 | end 81 | end 82 | end 83 | # rubocop:enable Metrics/BlockLength 84 | -------------------------------------------------------------------------------- /spec/support/helpers/queries_counter.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module QueriesCounter 3 | # rubocop:disable Metrics/MethodLength 4 | def count_queries(&block) 5 | count = 0 6 | 7 | counter_f = lambda do |_name, _started, _finished, _unique_id, payload| 8 | next if %w[CACHE SCHEMA].include?(payload[:name]) 9 | count += 1 10 | end 11 | 12 | ActiveSupport::Notifications.subscribed( 13 | counter_f, 14 | 'sql.active_record', 15 | &block 16 | ) 17 | 18 | count 19 | end 20 | # rubocop:enable Metrics/MethodLength 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | # regular :has_one association 3 | has_one :profile 4 | 5 | # :has_one association with JSONB store 6 | has_one :account, foreign_store: :extra 7 | 8 | # regular :has_many_association 9 | has_many :photos 10 | 11 | # :has_many association with JSONB store 12 | has_many :social_profiles, foreign_store: :extra 13 | 14 | # :has_and_belongs_to_many association with JSONB store 15 | has_and_belongs_to_many :labels, store: :extra 16 | 17 | # regular :has_and_belongs_to_many association 18 | has_and_belongs_to_many :groups 19 | end 20 | 21 | class GoodsSupplier < ActiveRecord::Base 22 | # :has_one association with JSONB store 23 | # and non-default :foreign_key 24 | has_one :account, foreign_store: :extra, 25 | foreign_key: :supplier_id, 26 | inverse_of: :supplier 27 | 28 | # :has_many association with JSONB store 29 | # and non-default :foreign_key 30 | has_many :invoice_photos, foreign_store: :extra, 31 | foreign_key: :supplier_id, 32 | inverse_of: :supplier 33 | end 34 | 35 | class Profile < ActiveRecord::Base 36 | belongs_to :user 37 | end 38 | 39 | class Account < ActiveRecord::Base 40 | belongs_to :user, store: :extra 41 | belongs_to :supplier, store: :extra, 42 | inverse_of: :account, 43 | class_name: 'GoodsSupplier' 44 | end 45 | 46 | class Photo < ActiveRecord::Base 47 | belongs_to :user 48 | end 49 | 50 | class InvoicePhoto < ActiveRecord::Base 51 | belongs_to :supplier, store: :extra, class_name: 'GoodsSupplier' 52 | end 53 | 54 | class SocialProfile < ActiveRecord::Base 55 | belongs_to :user, store: :extra 56 | end 57 | 58 | class Label < ActiveRecord::Base 59 | has_and_belongs_to_many :users, store: :extra 60 | end 61 | 62 | class Group < ActiveRecord::Base 63 | has_and_belongs_to_many :users 64 | end 65 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'pg' 3 | 4 | db_config = YAML.load_file( 5 | File.expand_path('../../config/database.yml', __FILE__) 6 | ).fetch('pg') 7 | 8 | ActiveRecord::Base.establish_connection( 9 | adapter: 'postgresql', 10 | database: 'activerecord_jsonb_associations_test', 11 | username: db_config.fetch('username'), 12 | min_messages: 'warning' 13 | ) 14 | 15 | ActiveRecord::Migration.verbose = false 16 | 17 | # rubocop:disable Metrics/BlockLength 18 | ActiveRecord::Schema.define do 19 | create_table :users, force: true do |t| 20 | t.jsonb :extra, null: false, default: {} 21 | t.timestamps null: false 22 | end 23 | 24 | create_table :goods_suppliers, force: true do |t| 25 | t.timestamps null: false 26 | end 27 | 28 | create_table :profiles, force: true do |t| 29 | t.belongs_to :user, null: false 30 | t.timestamps null: false 31 | end 32 | 33 | create_table :accounts, force: true do |t| 34 | t.references :user, store: :extra, index: true 35 | t.references :supplier, store: :extra, index: true 36 | t.timestamps null: false 37 | end 38 | 39 | create_table :photos, force: true do |t| 40 | t.belongs_to :user 41 | t.timestamps null: false 42 | end 43 | 44 | create_table :invoice_photos, force: true do |t| 45 | t.references :supplier, store: :extra, index: true 46 | t.timestamps null: false 47 | end 48 | 49 | create_table :social_profiles, force: true do |t| 50 | t.references :user, store: :extra, index: true 51 | t.timestamps null: false 52 | end 53 | 54 | create_table :labels, force: true do |t| 55 | t.jsonb :extra, null: false, default: {} 56 | t.timestamps null: false 57 | end 58 | 59 | create_table :groups, force: true do |t| 60 | t.timestamps null: false 61 | end 62 | 63 | create_table :groups_users, force: true do |t| 64 | t.belongs_to :user 65 | t.belongs_to :group 66 | end 67 | end 68 | # rubocop:enable Metrics/BlockLength 69 | -------------------------------------------------------------------------------- /spec/unit/builder/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ::ActiveRecord::Associations::Builder::BelongsTo do 2 | describe '.valid_options' do 3 | it 'adds :store as a valid option for :belongs_to association' do 4 | expect(described_class.valid_options({})).to include(:store) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/unit/builder/has_many_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ::ActiveRecord::Associations::Builder::HasMany do 2 | describe '.valid_options' do 3 | it 'adds :store as a valid option for :belongs_to association' do 4 | expect(described_class.valid_options({})).to include(:store) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/unit/builder/has_one_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ::ActiveRecord::Associations::Builder::HasOne do 2 | describe '.valid_options' do 3 | it 'adds :foreign_store as a valid option for :belongs_to association' do 4 | expect(described_class.valid_options({})).to include(:foreign_store) 5 | end 6 | end 7 | end 8 | --------------------------------------------------------------------------------