associate_jsonb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +19 -0
  5. data/lib/associate_jsonb.rb +74 -0
  6. data/lib/associate_jsonb/arel_node_extensions/binary.rb +12 -0
  7. data/lib/associate_jsonb/arel_nodes/jsonb/at_arrow.rb +14 -0
  8. data/lib/associate_jsonb/arel_nodes/jsonb/bindable_operator.rb +17 -0
  9. data/lib/associate_jsonb/arel_nodes/jsonb/dash_arrow.rb +14 -0
  10. data/lib/associate_jsonb/arel_nodes/jsonb/dash_double_arrow.rb +14 -0
  11. data/lib/associate_jsonb/arel_nodes/jsonb/double_pipe.rb +14 -0
  12. data/lib/associate_jsonb/arel_nodes/jsonb/hash_arrow.rb +33 -0
  13. data/lib/associate_jsonb/arel_nodes/jsonb/operator.rb +26 -0
  14. data/lib/associate_jsonb/arel_nodes/sql_casted_equality.rb +20 -0
  15. data/lib/associate_jsonb/associations/association.rb +39 -0
  16. data/lib/associate_jsonb/associations/association_scope.rb +100 -0
  17. data/lib/associate_jsonb/associations/belongs_to_association.rb +17 -0
  18. data/lib/associate_jsonb/associations/builder/belongs_to.rb +59 -0
  19. data/lib/associate_jsonb/associations/builder/has_many.rb +14 -0
  20. data/lib/associate_jsonb/associations/builder/has_one.rb +14 -0
  21. data/lib/associate_jsonb/associations/conflicting_association.rb +8 -0
  22. data/lib/associate_jsonb/associations/has_many_association.rb +17 -0
  23. data/lib/associate_jsonb/associations/preloader/association.rb +24 -0
  24. data/lib/associate_jsonb/connection_adapters.rb +7 -0
  25. data/lib/associate_jsonb/connection_adapters/reference_definition.rb +64 -0
  26. data/lib/associate_jsonb/reflection.rb +103 -0
  27. data/lib/associate_jsonb/relation/where_clause.rb +20 -0
  28. data/lib/associate_jsonb/version.rb +6 -0
  29. data/lib/associate_jsonb/with_store_attribute.rb +156 -0
  30. metadata +172 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad94cc18f8d5f87d25f2ebe9d0b75309fb43a3f22fc1438266e05ce39c2a3064
4
+ data.tar.gz: 016ce6443d9fdca1155d4b63fc18e6f6aaa18ef8887f73fe22312ee876d65f5d
5
+ SHA512:
6
+ metadata.gz: 7534ab4739036cff3691d4da6ac3d612cb6149c948088a3b7c24095e10bafce0d98e4bd96b7276c1ce84fd5e1017f039af48d59ed7d29f42827e90379a5e277d
7
+ data.tar.gz: 97e169f1942e48131f7de7be8dc99551c525fbdec7914a3de26abe3738614f1ae838bbd3edf2fb8828097c60ae32a47546638053f447510b4ad3792677dd639b
@@ -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,99 @@
1
+ # associate_jsonb
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/associate_jsonb.svg)](https://badge.fury.io/rb/associate_jsonb)
4
+
5
+ Basic ActiveRecord Associations using PostgreSQL JSONB columns, with built-in accessors and column indexes
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
+ Due to the ease of getting out-of-sync, and the complexity needed to build it, HABTM relation functionality has not been implemented through JSONB
49
+
50
+ #### Performance
51
+
52
+ Compared to regular associations, fetching models associated via JSONB column has no drops in performance.
53
+
54
+ Getting the count of connected records is ~35% faster with associations via JSONB (tested on associations with up to 10 000 connections).
55
+
56
+ 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:
57
+
58
+ <img src="https://github.com/lebedev-yury/associate_jsonb/blob/master/doc/images/adding-associations.png?raw=true | width=500" alt="JSONB HAMTB is slower on adding associations" width="600">
59
+
60
+ On the other hand, unassociating models from a big amount of associated models if faster with JSONB HABTM as the associations count grows:
61
+
62
+ <img src="https://github.com/lebedev-yury/associate_jsonb/blob/master/doc/images/deleting-associations.png?raw=true | width=500" alt="JSONB HAMTB is faster on removing associations" width="600">
63
+
64
+ ## Installation
65
+
66
+ Add this line to your application's Gemfile:
67
+
68
+ ```ruby
69
+ gem 'associate_jsonb'
70
+ ```
71
+
72
+ And then execute:
73
+
74
+ ```bash
75
+ $ bundle install
76
+ ```
77
+
78
+ ## Developing
79
+
80
+ To setup development environment, just run:
81
+
82
+ ```bash
83
+ $ bin/setup
84
+ ```
85
+
86
+ To run specs:
87
+
88
+ ```bash
89
+ $ bundle exec rspec
90
+ ```
91
+
92
+ To run benchmarks (that will take a while):
93
+
94
+ ```bash
95
+ $ bundle exec rake benchmarks:habtm
96
+ ``` -->
97
+
98
+ ## License
99
+ 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 = 'AssociateJsonb::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,74 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_record"
5
+ require "arel"
6
+ require "active_support/core_ext"
7
+ require "active_support/lazy_load_hooks"
8
+ require "active_support/concern"
9
+ require "pg"
10
+ require "active_record/connection_adapters/postgresql_adapter"
11
+ require "mutex_m"
12
+
13
+ require "zeitwerk"
14
+ loader = Zeitwerk::Loader.for_gem
15
+ loader.setup # ready!
16
+
17
+ module AssociateJsonb
18
+ end
19
+
20
+
21
+ # rubocop:disable Metrics/BlockLength
22
+ ActiveSupport.on_load :active_record do
23
+ loader.eager_load
24
+
25
+ ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
26
+ ActiveRecord::Base.include AssociateJsonb::Associations
27
+
28
+ Arel::Nodes.include AssociateJsonb::ArelNodes
29
+ Arel::Nodes::Binary.include AssociateJsonb::ArelNodeExtensions::Binary
30
+
31
+ ActiveRecord::Associations::Builder::BelongsTo.extend(
32
+ AssociateJsonb::Associations::Builder::BelongsTo
33
+ )
34
+
35
+ ActiveRecord::Associations::Builder::HasOne.extend(
36
+ AssociateJsonb::Associations::Builder::HasOne
37
+ )
38
+
39
+ ActiveRecord::Associations::Builder::HasMany.extend(
40
+ AssociateJsonb::Associations::Builder::HasMany
41
+ )
42
+
43
+ ActiveRecord::Associations::Association.prepend(
44
+ AssociateJsonb::Associations::Association
45
+ )
46
+
47
+ ActiveRecord::Associations::BelongsToAssociation.prepend(
48
+ AssociateJsonb::Associations::BelongsToAssociation
49
+ )
50
+
51
+ ActiveRecord::Associations::HasManyAssociation.prepend(
52
+ AssociateJsonb::Associations::HasManyAssociation
53
+ )
54
+
55
+ ActiveRecord::Associations::AssociationScope.prepend(
56
+ AssociateJsonb::Associations::AssociationScope
57
+ )
58
+
59
+ ActiveRecord::Associations::Preloader::Association.prepend(
60
+ AssociateJsonb::Associations::Preloader::Association
61
+ )
62
+
63
+ # ActiveRecord::Associations::Preloader::HasMany.prepend(
64
+ # AssociateJsonb::Associations::Preloader::HasMany
65
+ # )
66
+
67
+ ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
68
+ ActiveRecord::Relation::WhereClause.prepend AssociateJsonb::Relation::WhereClause
69
+
70
+ ActiveRecord::ConnectionAdapters::ReferenceDefinition.prepend(
71
+ AssociateJsonb::ConnectionAdapters::ReferenceDefinition
72
+ )
73
+ end
74
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,12 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodeExtensions
6
+ module Binary
7
+ def original_left
8
+ left
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class AtArrow < AssociateJsonb::ArelNodes::Jsonb::BindableOperator
8
+ def operator
9
+ '@>'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class BindableOperator < AssociateJsonb::ArelNodes::Jsonb::Operator
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.as_json}'")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class DashArrow < AssociateJsonb::ArelNodes::Jsonb::Operator #:nodoc:
8
+ def operator
9
+ '->'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class DashDoubleArrow < AssociateJsonb::ArelNodes::Jsonb::Operator #:nodoc:
8
+ def operator
9
+ '->>'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class DoublePipe < AssociateJsonb::ArelNodes::Jsonb::BindableOperator #:nodoc:
8
+ def operator
9
+ '||'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,33 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ module Jsonb
7
+ class HashArrow < AssociateJsonb::ArelNodes::Jsonb::Operator #:nodoc:
8
+ def operator
9
+ '#>'
10
+ end
11
+
12
+ def right_side
13
+ ::Arel::Nodes::SqlLiteral.new("'{#{name}}'")
14
+ end
15
+
16
+ def contains(value)
17
+ ArelNodes::Jsonb::AtArrow.new(relation, self, value)
18
+ end
19
+
20
+ def intersects_with(array)
21
+ ::Arel::Nodes::InfixOperation.new(
22
+ '>',
23
+ ::Arel::Nodes::NamedFunction.new(
24
+ 'jsonb_array_length',
25
+ [ ArelNodes::Jsonb::DoublePipe.new(relation, self, array) ]
26
+ ),
27
+ 0
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ module AssociateJsonb
2
+ module ArelNodes
3
+ module Jsonb
4
+ class Operator < ::Arel::Nodes::InfixOperation #:nodoc:
5
+ attr_reader :relation
6
+ attr_reader :name
7
+
8
+ def initialize(relation, left_side, key)
9
+ @relation = relation
10
+ @name = key
11
+
12
+ super(operator, left_side, right_side)
13
+ end
14
+
15
+ def right_side
16
+ ::Arel::Nodes::SqlLiteral.new("'#{name}'")
17
+ end
18
+
19
+ def operator
20
+ raise NotImplementedError,
21
+ 'Subclasses must implement an #operator method'
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ArelNodes
6
+ class SqlCastedEquality < ::Arel::Nodes::Equality
7
+ attr_reader :original_left
8
+ def initialize(left, cast_as, right)
9
+ @original_left = left
10
+ super(
11
+ ::Arel::Nodes::NamedFunction.new(
12
+ "CAST",
13
+ [ left.as(cast_as) ]
14
+ ),
15
+ right
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module Association #:nodoc:
7
+ def initialize_attributes(record, except_from_scope_attributes = nil) #:nodoc:
8
+ super unless reflection.foreign_store? && reflection.foreign_store_key?(reflection.foreign_key)
9
+
10
+ except_from_scope_attributes ||= {}
11
+ skip_assign = [reflection.foreign_key, reflection.type, reflection.foreign_store_key].compact
12
+ assigned_keys = record.changed_attribute_names_to_save
13
+ assigned_keys += except_from_scope_attributes.keys.map(&:to_s)
14
+ attributes = scope_for_create.except!(*(assigned_keys - skip_assign))
15
+ if attributes.key?(reflection.foreign_store_key.to_s)
16
+ v = attributes.delete(reflection.foreign_store_key.to_s)
17
+ attributes[reflection.foreign_key.to_s] = v
18
+ end
19
+ record.send(:_assign_attributes, attributes) if attributes.any?
20
+ set_inverse_instance(record)
21
+ end
22
+
23
+ private
24
+ def creation_attributes
25
+ return super if reflection.belongs_to?
26
+ return super unless reflection.foreign_store?
27
+
28
+ attributes = {}
29
+
30
+ jsonb_store = reflection.foreign_store_attr
31
+ attributes[jsonb_store] ||= {}
32
+ attributes[jsonb_store][reflection.foreign_store_key] =
33
+ owner[reflection.active_record_primary_key]
34
+
35
+ attributes
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,100 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module AssociationScope #:nodoc:
7
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
8
+ def last_chain_scope(scope, owner_reflection, owner)
9
+ reflection = owner_reflection.instance_variable_get(:@reflection)
10
+ return super unless reflection&.foreign_store?
11
+
12
+
13
+ join_keys = owner_reflection.join_keys
14
+ table = owner_reflection.aliased_table
15
+ key = reflection.foreign_store_key || join_keys.key
16
+ value = transform_value(owner[join_keys.foreign_key])
17
+
18
+ apply_jsonb_equality(
19
+ scope,
20
+ table,
21
+ reflection.foreign_store_attr,
22
+ key.to_s,
23
+ join_keys.foreign_key,
24
+ value,
25
+ reflection.active_record
26
+ )
27
+ end
28
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
29
+
30
+ def apply_jsonb_equality(scope, table, jsonb_column, store_key, foreign_key, value, foreign_klass)
31
+ sql_type = type = node_klass = nil
32
+ begin
33
+ type = foreign_klass.attribute_types[foreign_key.to_s]
34
+ raise "type not found" unless type.present?
35
+ sql_type = foreign_klass.columns_hash[foreign_key.to_s]
36
+ raise "not a column" unless sql_type.present?
37
+ sql_type = sql_type.sql_type
38
+ node_klass = Arel::Nodes::Jsonb::DashArrow
39
+ rescue
40
+ type = ActiveModel::Type::String.new
41
+ sql_type = "text"
42
+ node_klass = Arel::Nodes::Jsonb::DashDoubleArrow
43
+ end
44
+
45
+ # scope.where!(
46
+ # Arel::Nodes::HashableNamedFunction.new(
47
+ # "CAST",
48
+ # [
49
+ # node_klass.
50
+ # new(table, table[jsonb_column], store_key).
51
+ # as(sql_type)
52
+ # ]
53
+ # ).eq(
54
+ # Arel::Nodes::BindParam.new(
55
+ # ActiveRecord::Relation::QueryAttribute.new(
56
+ # store_key, value, type
57
+ # )
58
+ # )
59
+ # )
60
+ # )
61
+
62
+ scope.where!(
63
+ Arel::Nodes::SqlCastedEquality.new(
64
+ node_klass.new(table, table[jsonb_column], store_key),
65
+ sql_type,
66
+ Arel::Nodes::BindParam.new(
67
+ ActiveRecord::Relation::QueryAttribute.new(
68
+ store_key, value, type
69
+ )
70
+ )
71
+ )
72
+ )
73
+
74
+ # scope.where!(
75
+ # Arel::Nodes::Jsonb::DashDoubleArrow.
76
+ # new(table, table[jsonb_column], store_key).
77
+ # eq(
78
+ # Arel::Nodes::BindParam.new(
79
+ # ActiveRecord::Relation::QueryAttribute.new(
80
+ # store_key, value, ActiveModel::Type::String.new
81
+ # )
82
+ # )
83
+ # )
84
+ # )
85
+
86
+ # scope.where!(
87
+ # node_klass.new(
88
+ # table, table[jsonb_column], store_key
89
+ # ).eq(
90
+ # Arel::Nodes::BindParam.new(
91
+ # ActiveRecord::Relation::QueryAttribute.new(
92
+ # store_key, value, type
93
+ # )
94
+ # )
95
+ # )
96
+ # )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module BelongsToAssociation #:nodoc:
7
+ def replace_keys(record)
8
+ return super unless reflection.options.key?(:store)
9
+
10
+ owner[reflection.foreign_key] =
11
+ record._read_attribute(
12
+ reflection.association_primary_key(record.class)
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module Builder
7
+ module BelongsTo #:nodoc:
8
+ def valid_options(options)
9
+ super + %i[ store store_key ]
10
+ end
11
+
12
+ def define_accessors(mixin, reflection)
13
+ if reflection.options.key?(:store)
14
+ add_association_accessor_methods(mixin, reflection)
15
+ end
16
+
17
+ super
18
+ end
19
+
20
+ def add_association_accessor_methods(mixin, reflection)
21
+ foreign_key = reflection.foreign_key.to_s
22
+ key = (reflection.jsonb_store_key || foreign_key).to_s
23
+ store = reflection.jsonb_store_attr
24
+
25
+ mixin.instance_eval <<-CODE, __FILE__, __LINE__ + 1
26
+ if attribute_names.include?(foreign_key)
27
+ raise AssociateJsonb::Associations::
28
+ ConflictingAssociation,
29
+ "Association with foreign key :#{foreign_key} already "\
30
+ "exists on #{reflection.active_record.name}"
31
+ end
32
+ CODE
33
+
34
+ opts = {}
35
+ foreign_type = :integer
36
+ begin
37
+ primary_key = reflection.active_record_primary_key.to_s
38
+ primary_column = reflection.klass.columns.find {|col| col.name == primary_key }
39
+
40
+ if primary_column
41
+ foreign_type = primary_column.type
42
+ sql_data = primary_column.sql_type_metadata.as_json
43
+ %i[ limit precision scale ].each do |k|
44
+ opts[k] = sql_data[k.to_s] if sql_data[k.to_s]
45
+ end
46
+ end
47
+ rescue
48
+ opts = { limit: 8 }
49
+ foreign_type = :integer
50
+ end
51
+
52
+ mixin.instance_eval <<-CODE, __FILE__, __LINE__ + 1
53
+ store_column_attribute(:#{store}, :#{foreign_key}, :#{foreign_type}, key: "#{key}", **opts)
54
+ CODE
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module Builder
7
+ module HasMany #:nodoc:
8
+ def valid_options(options)
9
+ super + %i[ foreign_store foreign_store_key ]
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module Builder
7
+ module HasOne #:nodoc:
8
+ def valid_options(options)
9
+ super + %i[ foreign_store foreign_store_key ]
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ class ConflictingAssociation < StandardError; end
7
+ end
8
+ end
@@ -0,0 +1,17 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module HasManyAssociation #:nodoc:
7
+ # rubocop:disable Metrics/AbcSize
8
+ def delete_count(method, scope)
9
+ return super if method == :delete_all
10
+ return super unless store = reflection.foreign_store_attr
11
+
12
+ scope.update_all("#{store} = #{store} #- '{#{reflection.foreign_store_key}}'")
13
+ end
14
+ # rubocop:enable Metrics/AbcSize
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Associations
6
+ module Preloader
7
+ module Association #:nodoc:
8
+ def records_for(ids)
9
+ return super if reflection.belongs_to?
10
+ return super unless reflection.foreign_store?
11
+
12
+ table = reflection.klass.arel_table
13
+ scope.where(
14
+ Arel::Nodes::Jsonb::HashArrow.new(
15
+ table,
16
+ table[reflection.foreign_store_attr],
17
+ reflection.foreign_store_key
18
+ ).intersects_with(ids)
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module ConnectionAdapters
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AssociateJsonb
4
+ module ConnectionAdapters
5
+ module ReferenceDefinition #:nodoc:
6
+ # rubocop:disable Metrics/ParameterLists
7
+ def initialize(
8
+ name,
9
+ store: false,
10
+ **options
11
+ )
12
+ @store = store && store.to_sym
13
+
14
+ super(name, **options)
15
+ end
16
+ # rubocop:enable Metrics/ParameterLists
17
+
18
+ def add_to(table)
19
+ return super unless store
20
+
21
+ should_add_col = false
22
+ if table.respond_to? :column_exists?
23
+ should_add_col = !table.column_exists?(store)
24
+ elsif table.respond_to? :columns
25
+ should_add_col = table.columns.none? {|col| col.name.to_sym == store}
26
+ end
27
+
28
+ table.column(store, :jsonb, null: false, default: {}) if should_add_col
29
+
30
+ return unless index
31
+
32
+ # should_add_idx = false
33
+ # if table.respond_to? :index_exists?
34
+ # should_add_idx = !table.index_exists?([ store ], using: :gin)
35
+ # elsif table.respond_to? :indexes
36
+ # should_add_idx = table.indexes.none? do |idx, opts|
37
+ # (idx == [ store ]) \
38
+ # && (opts == { using: :gin })
39
+ # end
40
+ # end
41
+ #
42
+ # table.index([ store ], using: :gin) if should_add_idx
43
+
44
+ column_names.each do |column_name|
45
+ table.index(
46
+ "CAST (\"#{store}\"->'#{column_name}' AS #{@type || :bigint})",
47
+ using: :btree,
48
+ name: "index_#{table.name}_on_#{store}_#{column_name}"
49
+ )
50
+
51
+ table.index(
52
+ "(#{store}->>'#{column_name}')",
53
+ using: :btree,
54
+ name: "index_#{table.name}_on_#{store}_#{column_name}_text"
55
+ )
56
+ end
57
+ end
58
+
59
+ protected
60
+
61
+ attr_reader :store
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,103 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module Reflection
6
+ def jsonb_store?
7
+ options.key?(:store) && jsonb_store_attr.present?
8
+ end
9
+
10
+ def jsonb_store_key?(fk = nil)
11
+ options.key?(:store_key) \
12
+ && options[:store_key].present? \
13
+ && (!fk || (jsonb_store_key.to_s != fk.to_s))
14
+ end
15
+
16
+ def jsonb_store_attr
17
+ options[:store]
18
+ end
19
+
20
+ def jsonb_store_key
21
+ options[:store_key].presence || join_keys.foreign_key
22
+ end
23
+
24
+ def foreign_store?
25
+ options.key?(:foreign_store) && options[:foreign_store].present?
26
+ end
27
+
28
+ def foreign_store_key?(fk = nil)
29
+ options.key?(:foreign_store_key) \
30
+ && options[:foreign_store_key].present? \
31
+ && (!fk || (foreign_store_key.to_s != fk.to_s))
32
+ end
33
+
34
+ def foreign_store_attr
35
+ options[:foreign_store]
36
+ end
37
+
38
+ def foreign_store_key
39
+ options[:foreign_store_key].presence || join_keys.key
40
+ end
41
+
42
+ def join_scope(table, foreign_table, foreign_klass)
43
+ return super unless jsonb_store? || foreign_store?
44
+
45
+ predicate_builder = predicate_builder(table)
46
+ scope_chain_items = join_scopes(table, predicate_builder)
47
+ klass_scope = klass_join_scope(table, predicate_builder)
48
+
49
+ if type
50
+ klass_scope.where!(type => foreign_klass.polymorphic_name)
51
+ end
52
+
53
+ scope_chain_items.inject(klass_scope, &:merge!)
54
+
55
+ key = join_keys.key
56
+ foreign_key = join_keys.foreign_key
57
+
58
+ if foreign_store?
59
+ klass_scope.where!(
60
+ Arel::Nodes::NamedFunction.new(
61
+ "CAST",
62
+ [
63
+ Arel::Nodes::Jsonb::DashArrow.
64
+ new(table, table[foreign_store_attr], foreign_store_key || key).
65
+ as(foreign_klass.columns_hash[foreign_key.to_s].sql_type)
66
+ ]
67
+ ).eq(
68
+ ::Arel::Nodes::SqlLiteral.
69
+ new("#{foreign_table.name}.#{foreign_key}")
70
+ )
71
+ )
72
+
73
+ # klass_scope.where!(
74
+ # Arel::Nodes::Jsonb::DashDoubleArrow.
75
+ # new(table, table[foreign_store_attr], foreign_store_key || key).
76
+ # eq(
77
+ # ::Arel::Nodes::SqlLiteral.
78
+ # new("#{foreign_table.name}.#{foreign_key}::text")
79
+ # )
80
+ # )
81
+ elsif jsonb_store?
82
+ klass_scope.where!(
83
+ Arel::Nodes::NamedFunction.new(
84
+ "CAST",
85
+ [
86
+ Arel::Nodes::Jsonb::DashArrow.
87
+ new(foreign_table, foreign_table[jsonb_store_attr], jsonb_store_key || foreign_key).
88
+ as(klass.columns_hash[key.to_s].sql_type)
89
+ ]
90
+ ).eq(
91
+ ::Arel::Nodes::SqlLiteral.new("#{table.name}.#{key}")
92
+ )
93
+ )
94
+ end
95
+
96
+ if klass.finder_needs_type_condition?
97
+ klass_scope.where!(klass.send(:type_condition, table))
98
+ end
99
+
100
+ klass_scope
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,20 @@
1
+ module AssociateJsonb
2
+ module Relation
3
+ module WhereClause
4
+ def to_h(table_name = nil)
5
+ equalities = equalities(predicates)
6
+ if table_name
7
+ equalities = equalities.select do |node|
8
+ node.original_left.relation.name == table_name
9
+ end
10
+ end
11
+
12
+ equalities.map { |node|
13
+ name = node.original_left.name.to_s
14
+ value = extract_node_value(node.right)
15
+ [name, value]
16
+ }.to_h
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,6 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ VERSION = "0.0.1"
6
+ end
@@ -0,0 +1,156 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module AssociateJsonb
5
+ module WithStoreAttribute
6
+ extend ActiveSupport::Concern
7
+
8
+ class StoreColumnAttributeTracker < Module #:nodoc:
9
+ include Mutex_m
10
+
11
+ def names_list
12
+ @names_list ||= {}
13
+ end
14
+
15
+ def add_name(name, store, key)
16
+ @names_list ||= {}
17
+ @names_list[name.to_s.freeze] = { store: store, key: key }
18
+ end
19
+
20
+ def has_name?(name)
21
+ names_list.key? name.to_s
22
+ end
23
+ end
24
+
25
+ included do
26
+ instance_eval <<-CODE, __FILE__, __LINE__ + 1
27
+ initialize_store_column_attribute_tracker
28
+
29
+ after_initialize &set_store_column_attribute_values_on_init
30
+ after_commit &set_store_column_attribute_values_on_init
31
+ CODE
32
+ end
33
+
34
+ module ClassMethods
35
+ def inherited(child)
36
+ child.initialize_store_column_attribute_tracker
37
+ super
38
+ end
39
+
40
+ def initialize_store_column_attribute_tracker
41
+ @store_column_attribute_tracker = const_set(:StoreColumnAttributeTracker, StoreColumnAttributeTracker.new)
42
+ private_constant :StoreColumnAttributeTracker
43
+
44
+ store_column_attribute_tracker
45
+ end
46
+
47
+ def store_column_attribute_tracker
48
+ @store_column_attribute_tracker ||= initialize_store_column_attribute_tracker
49
+ end
50
+
51
+ def store_column_attribute_names
52
+ store_column_attribute_tracker.synchronize do
53
+ current_store_col_names = {}
54
+ current_store_col_names.merge!(super) if defined? super
55
+ current_store_col_names.merge!(store_column_attribute_tracker.names_list)
56
+ end
57
+ end
58
+
59
+ def add_store_column_attribute_name(name, store, key)
60
+ store_column_attribute_tracker.synchronize do
61
+ store_column_attribute_tracker.add_name(name, store, key)
62
+ end
63
+ end
64
+
65
+ def is_store_column_attribute?(name)
66
+ store_column_attribute_tracker.synchronize do
67
+ store_column_attribute_tracker.has_name?(name)
68
+ end
69
+ end
70
+
71
+ def set_store_column_attribute_values_on_init
72
+ lambda do
73
+ self.class.store_column_attribute_names.each do |attr, opts|
74
+ _write_attribute(attr, _read_attribute(opts[:store])[opts[:key]])
75
+ clear_attribute_change(attr) if persisted?
76
+ end
77
+ rescue
78
+ nil
79
+ end
80
+ end
81
+
82
+ def data_column_attribute(*args, **opts)
83
+ store_column_attribute :data, *args, **opts
84
+ end
85
+
86
+ def store_column_attribute(store, attr, *opts, key: nil, **attribute_opts)
87
+ store = store.to_sym
88
+ attr = attr.to_sym
89
+ key ||= attr
90
+ key = key.to_s
91
+ array = attribute_opts[:array]
92
+ attribute attr, *opts, **attribute_opts
93
+
94
+ instance_eval <<-CODE, __FILE__, __LINE__ + 1
95
+ add_store_column_attribute_name("#{attr}", :#{store}, "#{key}")
96
+ CODE
97
+
98
+ include WithStoreAttribute::InstanceMethodsOnActivation.new(self, store, attr, key, array)
99
+ end
100
+ end
101
+
102
+ class InstanceMethodsOnActivation < Module
103
+ def initialize(mixin, store, attribute, key, is_array)
104
+ is_array = !!(is_array && attribute.to_s =~ /_ids$/)
105
+ on_attr_change =
106
+ is_array \
107
+ ? "write_attribute(:#{attribute}, Array(given))" \
108
+ : "super(given)"
109
+ if is_array
110
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
111
+ def #{attribute}
112
+ _read_attribute(:#{attribute}) || []
113
+ end
114
+ CODE
115
+ end
116
+
117
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
118
+ def #{store}=(given)
119
+ given = super((given || {}).with_indifferent_access)
120
+ write_attribute(:#{attribute}, given["#{key}"])
121
+ given["#{key}"] = #{attribute} unless #{attribute}.nil?
122
+ super(given)
123
+ end
124
+
125
+ def #{attribute}=(given)
126
+ #{on_attr_change}
127
+ value = #{store}["#{key}"] = #{attribute}
128
+ #{store}.delete("#{key}") if value.nil?
129
+ _write_attribute(:#{store}, #{store})
130
+ value
131
+ end
132
+ CODE
133
+ end
134
+ end
135
+
136
+ def is_store_column_attribute?(name)
137
+ self.class.is_store_column_attribute?(name)
138
+ end
139
+
140
+ def [](k)
141
+ if is_store_column_attribute?(k)
142
+ self.public_send(k)
143
+ else
144
+ super
145
+ end
146
+ end
147
+
148
+ def []=(k, v)
149
+ if is_store_column_attribute?(k)
150
+ self.public_send(:"#{k}=", v)
151
+ else
152
+ super
153
+ end
154
+ end
155
+ end
156
+ end
metadata ADDED
@@ -0,0 +1,172 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: associate_jsonb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Sampson Crowley
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 6.0.3.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '6'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 6.0.3.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: pg
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 1.1.2
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '1.1'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 1.1.2
53
+ - !ruby/object:Gem::Dependency
54
+ name: zeitwerk
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 2.2.2
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '2'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 2.2.2
73
+ - !ruby/object:Gem::Dependency
74
+ name: mutex_m
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '0.1'
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 0.1.0
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.1'
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 0.1.0
93
+ - !ruby/object:Gem::Dependency
94
+ name: rspec
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: 3.7.0
100
+ type: :development
101
+ prerelease: false
102
+ version_requirements: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "~>"
105
+ - !ruby/object:Gem::Version
106
+ version: 3.7.0
107
+ description: |2
108
+ This gem extends ActiveRecord to let you use PostgreSQL JSONB data for associations
109
+
110
+ Inspired by activerecord-jsonb-associations, but for use in Rails 6+ and
111
+ ruby 2.7+ and with some unnecessary options and features (HABTM) removed
112
+
113
+ BONUS: extended `table#references` for easy migrations and indexes
114
+ (NOTE: real foreign key constraints are not possible with PostgreSQL JSONB)
115
+ email:
116
+ - sampsonsprojects@gmail.com
117
+ executables: []
118
+ extensions: []
119
+ extra_rdoc_files: []
120
+ files:
121
+ - MIT-LICENSE
122
+ - README.md
123
+ - Rakefile
124
+ - lib/associate_jsonb.rb
125
+ - lib/associate_jsonb/arel_node_extensions/binary.rb
126
+ - lib/associate_jsonb/arel_nodes/jsonb/at_arrow.rb
127
+ - lib/associate_jsonb/arel_nodes/jsonb/bindable_operator.rb
128
+ - lib/associate_jsonb/arel_nodes/jsonb/dash_arrow.rb
129
+ - lib/associate_jsonb/arel_nodes/jsonb/dash_double_arrow.rb
130
+ - lib/associate_jsonb/arel_nodes/jsonb/double_pipe.rb
131
+ - lib/associate_jsonb/arel_nodes/jsonb/hash_arrow.rb
132
+ - lib/associate_jsonb/arel_nodes/jsonb/operator.rb
133
+ - lib/associate_jsonb/arel_nodes/sql_casted_equality.rb
134
+ - lib/associate_jsonb/associations/association.rb
135
+ - lib/associate_jsonb/associations/association_scope.rb
136
+ - lib/associate_jsonb/associations/belongs_to_association.rb
137
+ - lib/associate_jsonb/associations/builder/belongs_to.rb
138
+ - lib/associate_jsonb/associations/builder/has_many.rb
139
+ - lib/associate_jsonb/associations/builder/has_one.rb
140
+ - lib/associate_jsonb/associations/conflicting_association.rb
141
+ - lib/associate_jsonb/associations/has_many_association.rb
142
+ - lib/associate_jsonb/associations/preloader/association.rb
143
+ - lib/associate_jsonb/connection_adapters.rb
144
+ - lib/associate_jsonb/connection_adapters/reference_definition.rb
145
+ - lib/associate_jsonb/reflection.rb
146
+ - lib/associate_jsonb/relation/where_clause.rb
147
+ - lib/associate_jsonb/version.rb
148
+ - lib/associate_jsonb/with_store_attribute.rb
149
+ homepage: https://github.com/SampsonCrowley/associate_jsonb
150
+ licenses:
151
+ - MIT
152
+ metadata: {}
153
+ post_install_message:
154
+ rdoc_options: []
155
+ require_paths:
156
+ - lib
157
+ required_ruby_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '2.7'
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ requirements: []
168
+ rubygems_version: 3.1.3
169
+ signing_key:
170
+ specification_version: 4
171
+ summary: Store database references in PostgreSQL Jsonb columns
172
+ test_files: []