├── .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 | [](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 |
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 |
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 |
--------------------------------------------------------------------------------