activerecord-jsonb-associations 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +109 -0
- data/Rakefile +19 -0
- data/lib/activerecord/jsonb/associations.rb +75 -0
- data/lib/activerecord/jsonb/associations/association.rb +30 -0
- data/lib/activerecord/jsonb/associations/association_scope.rb +58 -0
- data/lib/activerecord/jsonb/associations/belongs_to_association.rb +16 -0
- data/lib/activerecord/jsonb/associations/builder/belongs_to.rb +53 -0
- data/lib/activerecord/jsonb/associations/builder/has_many.rb +35 -0
- data/lib/activerecord/jsonb/associations/builder/has_one.rb +13 -0
- data/lib/activerecord/jsonb/associations/class_methods.rb +14 -0
- data/lib/activerecord/jsonb/associations/has_many_association.rb +134 -0
- data/lib/activerecord/jsonb/associations/join_dependency/join_association.rb +47 -0
- data/lib/activerecord/jsonb/associations/preloader/association.rb +21 -0
- data/lib/activerecord/jsonb/associations/preloader/has_many.rb +62 -0
- data/lib/activerecord/jsonb/associations/version.rb +7 -0
- data/lib/activerecord/jsonb/connection_adapters/reference_definition.rb +50 -0
- data/lib/arel/nodes/jsonb_at_arrow.rb +16 -0
- data/lib/arel/nodes/jsonb_dash_double_arrow.rb +9 -0
- data/lib/arel/nodes/jsonb_double_pipe.rb +14 -0
- data/lib/arel/nodes/jsonb_hash_arrow.rb +28 -0
- data/lib/arel/nodes/jsonb_operator.rb +24 -0
- data/lib/arel/nodes/jsonb_operators.rb +5 -0
- metadata +96 -0
checksums.yaml
ADDED
@@ -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
|
data/MIT-LICENSE
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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).
|
data/Rakefile
ADDED
@@ -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,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,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,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
|
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: []
|