activerecord-jsonb-associations 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: c81885988f07c3229320ba64f85c6f2cb31f9467
4
+ data.tar.gz: 697fdde245ae31c5f568a7d7ee130c297c418fcc
5
+ SHA512:
6
+ metadata.gz: e790b5b644fa87e1628377fed0a45fd10f8201d2e65b7396e5274a822f3da222e26343bc353c38896e56e68280f5fd7aa39b64ef032d44cd26bca16980ac15af
7
+ data.tar.gz: 62d99c36a88e8dad04ac17984b2755da343676577078c4b831ee3cbf84b5167c15792c24fe7baf50860481bda69cef760ed6f3c926bf75cb74227d137f926a7a
@@ -0,0 +1,20 @@
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.
@@ -0,0 +1,109 @@
1
+ # activerecord-json-associations
2
+
3
+ Use PostgreSQL JSONB fields to store association information of your models.
4
+
5
+ This gem was created as a solution to this [task](http://cultofmartians.com/tasks/active-record-jsonb-associations.html) from [EvilMartians](http://evilmartians.com).
6
+
7
+ **Requirements:**
8
+
9
+ - PostgreSQL (>= 9.6)
10
+
11
+ ## Usage
12
+
13
+ ### One-to-one and One-to-many associations
14
+
15
+ You can store all foreign keys of your model in one JSONB column, without having to create multiple columns:
16
+
17
+ ```ruby
18
+ class Profile < ActiveRecord::Base
19
+ # Setting additional :store option on :belongs_to association
20
+ # enables saving of foreign ids in :extra JSONB column
21
+ belongs_to :user, store: :extra
22
+ end
23
+
24
+ class SocialProfile < ActiveRecord::Base
25
+ belongs_to :user, store: :extra
26
+ end
27
+
28
+ class User < ActiveRecord::Base
29
+ # Parent model association needs to specify :foreign_store
30
+ # for associations with JSONB storage
31
+ has_one :profile, foreign_store: :extra
32
+ has_many :social_profiles, foreign_store: :extra
33
+ end
34
+ ```
35
+
36
+ Foreign keys for association on one model have to be unique, even if they use different store column.
37
+
38
+ You can also use `add_references` in your migration to add JSONB column and index for it (if `index: true` option is set):
39
+
40
+ ```ruby
41
+ add_reference :profiles, :users, store: :extra, index: true
42
+ ```
43
+
44
+ ### Many-to-many associations
45
+
46
+ You can also use JSONB columns on 2 sides of a HABTM association. This way you won't have to create a join table.
47
+
48
+ ```ruby
49
+ class Label < ActiveRecord::Base
50
+ # extra['user_ids'] will store associated user ids
51
+ has_and_belongs_to_many :users, store: :extra
52
+ end
53
+
54
+ class User < ActiveRecord::Base
55
+ # extra['label_ids'] will store associated label ids
56
+ has_and_belongs_to_many :labels, store: :extra
57
+ end
58
+ ```
59
+
60
+ #### Performance
61
+
62
+ Compared to regular associations, fetching models associated via JSONB column has no drops in performance.
63
+
64
+ Getting the count of connected records is ~35% faster with associations via JSONB (tested on associations with up to 10 000 connections).
65
+
66
+ 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:
67
+
68
+ <img src="https://github.com/lebedev-yury/activerecord-jsonb-associations/blob/master/doc/images/adding-associations.png?raw=true | width=500" alt="JSONB HAMTB is slower on adding associations" width="600">
69
+
70
+ On the other hand, unassociating models from a big amount of associated models if faster with JSONB HABTM as the associations count grows:
71
+
72
+ <img src="https://github.com/lebedev-yury/activerecord-jsonb-associations/blob/master/doc/images/deleting-associations.png?raw=true | width=500" alt="JSONB HAMTB is faster on removing associations" width="600">
73
+
74
+ ## Installation
75
+
76
+ Add this line to your application's Gemfile:
77
+
78
+ ```ruby
79
+ gem 'activerecord-jsonb-associations'
80
+ ```
81
+
82
+ And then execute:
83
+
84
+ ```bash
85
+ $ bundle install
86
+ ```
87
+
88
+ ## Developing
89
+
90
+ To setup development environment, just run:
91
+
92
+ ```bash
93
+ $ bin/setup
94
+ ```
95
+
96
+ To run specs:
97
+
98
+ ```bash
99
+ $ bundle exec rspec
100
+ ```
101
+
102
+ To run benchmarks (that will take a while):
103
+
104
+ ```bash
105
+ $ bundle exec rake benchmarks:habtm
106
+ ```
107
+
108
+ ## License
109
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,19 @@
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'
@@ -0,0 +1,75 @@
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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,58 @@
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[reflection.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
+ pluralized_key = key.pluralize
22
+
23
+ apply_jsonb_scope(
24
+ scope,
25
+ jsonb_containment(
26
+ table, reflection.options[:store], pluralized_key
27
+ ),
28
+ pluralized_key, value
29
+ )
30
+ else
31
+ super
32
+ end
33
+ end
34
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
35
+
36
+ def apply_jsonb_scope(scope, predicate, key, value)
37
+ scope.where!(predicate).tap do |arel_scope|
38
+ arel_scope.where_clause.binds << Relation::QueryAttribute.new(
39
+ key.to_s, value, ActiveModel::Type::String.new
40
+ )
41
+ end
42
+ end
43
+
44
+ def jsonb_equality(table, jsonb_column, key)
45
+ Arel::Nodes::JSONBDashDoubleArrow.new(
46
+ table, table[jsonb_column], key
47
+ ).eq(Arel::Nodes::BindParam.new)
48
+ end
49
+
50
+ def jsonb_containment(table, jsonb_column, key)
51
+ Arel::Nodes::JSONBHashArrow.new(
52
+ table, table[jsonb_column], key
53
+ ).contains(Arel::Nodes::BindParam.new)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,53 @@
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 [](key)
39
+ key = key.to_s
40
+ if key.ends_with?('_id') &&
41
+ #{reflection.options[:store]}.keys.include?(key)
42
+ #{reflection.options[:store]}[key]
43
+ else
44
+ super
45
+ end
46
+ end
47
+ CODE
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,134 @@
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
@@ -0,0 +1,47 @@
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)
14
+ build_contains_constraint(
15
+ table, table[reflection.options[:store]],
16
+ key.pluralize, foreign_table, foreign_key
17
+ )
18
+ else
19
+ super
20
+ end
21
+ end
22
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
23
+
24
+ def build_eq_constraint(
25
+ table, jsonb_column, key, foreign_table, foreign_key
26
+ )
27
+ Arel::Nodes::JSONBDashDoubleArrow.new(table, jsonb_column, key).eq(
28
+ ::Arel::Nodes::SqlLiteral.new(
29
+ "CAST(#{foreign_table.name}.#{foreign_key} AS text)"
30
+ )
31
+ )
32
+ end
33
+
34
+ def build_contains_constraint(
35
+ table, jsonb_column, key, foreign_table, foreign_key
36
+ )
37
+ Arel::Nodes::JSONBHashArrow.new(table, jsonb_column, key).contains(
38
+ ::Arel::Nodes::SqlLiteral.new(
39
+ "jsonb_build_array(#{foreign_table.name}.#{foreign_key})"
40
+ )
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,7 @@
1
+ module ActiveRecord
2
+ module JSONB
3
+ module Associations
4
+ VERSION = '0.1.0'.freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,9 @@
1
+ module Arel
2
+ module Nodes
3
+ class JSONBDashDoubleArrow < JSONBOperator #:nodoc:
4
+ def operator
5
+ '->>'
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,5 @@
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'
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-jsonb-associations
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yury Lebedev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 5.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 3.7.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 3.7.0
41
+ description: Use PostgreSQL JSONB fields to store association information of your
42
+ models
43
+ email:
44
+ - lebedev.yurii@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - MIT-LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - lib/activerecord/jsonb/associations.rb
53
+ - lib/activerecord/jsonb/associations/association.rb
54
+ - lib/activerecord/jsonb/associations/association_scope.rb
55
+ - lib/activerecord/jsonb/associations/belongs_to_association.rb
56
+ - lib/activerecord/jsonb/associations/builder/belongs_to.rb
57
+ - lib/activerecord/jsonb/associations/builder/has_many.rb
58
+ - lib/activerecord/jsonb/associations/builder/has_one.rb
59
+ - lib/activerecord/jsonb/associations/class_methods.rb
60
+ - lib/activerecord/jsonb/associations/has_many_association.rb
61
+ - lib/activerecord/jsonb/associations/join_dependency/join_association.rb
62
+ - lib/activerecord/jsonb/associations/preloader/association.rb
63
+ - lib/activerecord/jsonb/associations/preloader/has_many.rb
64
+ - lib/activerecord/jsonb/associations/version.rb
65
+ - lib/activerecord/jsonb/connection_adapters/reference_definition.rb
66
+ - lib/arel/nodes/jsonb_at_arrow.rb
67
+ - lib/arel/nodes/jsonb_dash_double_arrow.rb
68
+ - lib/arel/nodes/jsonb_double_pipe.rb
69
+ - lib/arel/nodes/jsonb_hash_arrow.rb
70
+ - lib/arel/nodes/jsonb_operator.rb
71
+ - lib/arel/nodes/jsonb_operators.rb
72
+ homepage: https://github.com/lebedev-yury/activerecord-jsonb-associations
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - "~>"
83
+ - !ruby/object:Gem::Version
84
+ version: '2.0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubyforge_project:
92
+ rubygems_version: 2.6.11
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Gem for storing association information using PostgreSQL JSONB columns
96
+ test_files: []