associate_jsonb 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +79 -1
- data/lib/associate_jsonb.rb +79 -3
- data/lib/associate_jsonb/arel_extensions/visitors/postgresql.rb +72 -32
- data/lib/associate_jsonb/associations/builder/belongs_to.rb +2 -2
- data/lib/associate_jsonb/connection_adapters/schema_creation.rb +128 -29
- data/lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_foreign_key_function.rb +9 -0
- data/lib/associate_jsonb/connection_adapters/{table_definition.rb → schema_definitions/add_jsonb_nested_set_function.rb} +2 -2
- data/lib/associate_jsonb/connection_adapters/schema_definitions/alter_table.rb +40 -0
- data/lib/associate_jsonb/connection_adapters/schema_definitions/constraint_definition.rb +60 -0
- data/lib/associate_jsonb/connection_adapters/{reference_definition.rb → schema_definitions/reference_definition.rb} +17 -11
- data/lib/associate_jsonb/connection_adapters/schema_definitions/table.rb +12 -0
- data/lib/associate_jsonb/connection_adapters/schema_definitions/table_definition.rb +25 -0
- data/lib/associate_jsonb/connection_adapters/schema_statements.rb +108 -5
- data/lib/associate_jsonb/version.rb +1 -1
- data/lib/associate_jsonb/with_store_attribute.rb +30 -25
- metadata +19 -11
- data/lib/associate_jsonb/connection_adapters/schema_creation/alter_table.rb +0 -35
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f0832e64c43e22ba206a98d0603c0e231c1606b17315f715a4050e19a1fdb21b
|
4
|
+
data.tar.gz: ff37b2777adf131f20ad9a95076d8797ecf30dc2ddeaa86913de5a457cf9fed4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 00fbd32bc9e66fa8bd85fcf9cab51850bc65443a99f66dbc3bcff5ca5d05e855624c62ac9a0aa7bcbed330a2650dbe149806c49bc3bb40ecd3aa6cdf002fde19
|
7
|
+
data.tar.gz: b134266f93576482f3558be28840081b803982ce369bd75497af2d9d2d2db30e3c27364247485ef2a4712705fb01183d80b812f6fa28f18d8f7db37db555bb88
|
data/README.md
CHANGED
@@ -4,7 +4,85 @@
|
|
4
4
|
|
5
5
|
#### PostgreSQL JSONB extensions including:
|
6
6
|
- Basic ActiveRecord Associations using PostgreSQL JSONB columns, with built-in accessors and column indexes
|
7
|
-
- Thread-Safe JSONB updates (well, as safe as they can be) using `jsonb_set`
|
7
|
+
- Thread-Safe JSONB updates (well, as safe as they can be) using a custom nested version of `jsonb_set` (`jsonb_nested_set`)
|
8
|
+
|
9
|
+
**Requirements:**
|
10
|
+
|
11
|
+
- PostgreSQL (>= 12)
|
12
|
+
- Rails 6.0.3.2
|
13
|
+
|
14
|
+
## Usage
|
15
|
+
|
16
|
+
### Jsonb Associations
|
17
|
+
|
18
|
+
|
19
|
+
### jsonb_set based hash updates
|
20
|
+
|
21
|
+
When enabled, *only* keys present in the updated hash and with values changed in memory will be updated.
|
22
|
+
To completely delete a key, value pair from an enabled attribute, set the key's value to `nil`.
|
23
|
+
|
24
|
+
e.g.
|
25
|
+
```ruby
|
26
|
+
# given: instance#data == { "key_1"=>1,
|
27
|
+
# "key_2"=>2,
|
28
|
+
# "key_3"=> { "key_4"=>7,
|
29
|
+
# "key_5"=>8,
|
30
|
+
# "key_6"=>9 } }
|
31
|
+
|
32
|
+
instance.update({ key_1: "asdf", a: 1, key_2: nil, key_3: { key_5: nil }})
|
33
|
+
# instance#data => { "key_1"=>"asdf",
|
34
|
+
# "a"=>"asdf",
|
35
|
+
# "key_3"=> { "key_4"=>7,
|
36
|
+
# "key_6"=>9 } }
|
37
|
+
|
38
|
+
```
|
39
|
+
|
40
|
+
#### enabling/adding attribute types
|
41
|
+
first, create the sql function
|
42
|
+
```bash
|
43
|
+
rails g migration add_jsonb_nested_set_function
|
44
|
+
```
|
45
|
+
```ruby
|
46
|
+
class CreateState < ActiveRecord::Migration[6.0]
|
47
|
+
def up
|
48
|
+
add_jsonb_nested_set_function
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
then in an initializer, enable key based updates:
|
54
|
+
```ruby
|
55
|
+
# config/initializers/associate_jsonb.rb
|
56
|
+
AssociateJsonb.enable_jsonb_set
|
57
|
+
```
|
58
|
+
|
59
|
+
Key based updates rely on inheritance for allowed attribute types. Any attributes that respond true to `attr_type.is_a?(GivenClass)` for any enabled type classes will use `jsonb_nested_set`
|
60
|
+
|
61
|
+
To add classes to the enabled list, pass them as arguments to `AssociateJsonb.add_hash_type(*klasses)`. Any arguments passed to `AssociateJsonb.enable_jsonb_set` are forwarded to `AssociateJsonb.add_hash_type`
|
62
|
+
|
63
|
+
By default, calling `AssociateJsonb.enable_jsonb_set(*klasses)` without arguments, and no classes previously added, adds `ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb` to the allowed classes list
|
64
|
+
|
65
|
+
#### disabling/removing attribute types
|
66
|
+
by default `jsonb_nested_set` updates are disabled.
|
67
|
+
|
68
|
+
if you've enabled them and need to disable, use: `AssociateJsonb.disable_jsonb_set`
|
69
|
+
|
70
|
+
To remove a class from the allowed list while leaving nested set updates enabled, use `AssociateJsonb.remove_hash_type(*klasses)`.
|
71
|
+
Any arguments passed to `AssociateJsonb.disable_jsonb_set` are forwarded to `AssociateJsonb.remove_hash_type`
|
72
|
+
|
73
|
+
### Automatically delete nil value hash keys
|
74
|
+
|
75
|
+
When jsonb_set updates are disabled, jsonb columns are replaced with the current document (i.e. default rails behavior)
|
76
|
+
|
77
|
+
You are also given the option to automatically clear nil/null values from the hash automatically
|
78
|
+
|
79
|
+
in an initializer, enable stripping nil values:
|
80
|
+
```ruby
|
81
|
+
# config/initializers/associate_jsonb.rb
|
82
|
+
AssociateJsonb.jsonb_delete_nil = true
|
83
|
+
```
|
84
|
+
|
85
|
+
Rules for classes to with this applies are the same as for `jsonb_nested_set`; add and remove classes through `AssociateJsonb.(add|remove)_hash_type(*klasses)`
|
8
86
|
|
9
87
|
<!-- This gem was created as a solution to this [task](http://cultofmartians.com/tasks/active-record-jsonb-associations.html) from [EvilMartians](http://evilmartians.com).
|
10
88
|
|
data/lib/associate_jsonb.rb
CHANGED
@@ -16,18 +16,70 @@ loader.inflector.inflect(
|
|
16
16
|
"postgresql" => "PostgreSQL",
|
17
17
|
"supported_rails_version" => "SUPPORTED_RAILS_VERSION"
|
18
18
|
)
|
19
|
+
loader.collapse("#{__dir__}/associate_jsonb/connection_adapters/schema_definitions")
|
19
20
|
loader.setup # ready!
|
20
21
|
|
21
22
|
module AssociateJsonb
|
22
|
-
mattr_accessor :
|
23
|
-
|
24
|
-
|
23
|
+
mattr_accessor :jsonb_hash_types, default: []
|
24
|
+
mattr_accessor :jsonb_set_removed, default: []
|
25
|
+
mattr_accessor :jsonb_set_enabled, default: false
|
26
|
+
mattr_accessor :jsonb_delete_nil, default: false
|
27
|
+
private_class_method :jsonb_hash_types=
|
28
|
+
private_class_method :jsonb_set_enabled=
|
29
|
+
|
30
|
+
def self.enable_jsonb_set(klass = nil, *classes)
|
31
|
+
add_hash_type(*Array(klass), *classes) unless klass.nil?
|
32
|
+
self.jsonb_set_enabled = true
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.disable_jsonb_set(klass = nil, *classes)
|
36
|
+
remove_hash_type(*Array(klass), *classes) unless klass.nil?
|
37
|
+
self.jsonb_set_enabled = false
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.add_hash_type(*classes)
|
41
|
+
self.jsonb_hash_types |= classes.flatten
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.remove_hash_type(*classes)
|
45
|
+
self.jsonb_set_removed |= classes.flatten
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.merge_hash?(v)
|
49
|
+
return false unless jsonb_set_enabled && v
|
50
|
+
self.jsonb_hash_types.any? { |type| v.is_a?(type) }
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.is_hash?(v)
|
54
|
+
self.jsonb_hash_types.any? { |type| v.is_a?(type) }
|
55
|
+
end
|
25
56
|
end
|
26
57
|
|
27
58
|
|
28
59
|
# rubocop:disable Metrics/BlockLength
|
29
60
|
ActiveSupport.on_load :active_record do
|
30
61
|
loader.eager_load
|
62
|
+
AssociateJsonb.class_eval do
|
63
|
+
def self.enable_jsonb_set(klass = nil, *classes)
|
64
|
+
if klass.nil?
|
65
|
+
add_hash_type ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb if jsonb_hash_types.empty?
|
66
|
+
else
|
67
|
+
add_hash_type |= [*Array(klass), *classes].flatten
|
68
|
+
end
|
69
|
+
self.jsonb_set_enabled = true
|
70
|
+
end
|
71
|
+
|
72
|
+
self.enable_jsonb_set if jsonb_set_enabled
|
73
|
+
|
74
|
+
def self.remove_hash_type(*classes)
|
75
|
+
self.jsonb_hash_types -= classes.flatten
|
76
|
+
end
|
77
|
+
removed = jsonb_set_removed
|
78
|
+
self.remove_hash_type removed
|
79
|
+
self.send :remove_method, :jsonb_set_removed
|
80
|
+
self.send :remove_method, :jsonb_set_removed=
|
81
|
+
end
|
82
|
+
|
31
83
|
|
32
84
|
ActiveRecord::Base.include AssociateJsonb::WithStoreAttribute
|
33
85
|
ActiveRecord::Base.include AssociateJsonb::Associations
|
@@ -96,6 +148,30 @@ ActiveSupport.on_load :active_record do
|
|
96
148
|
# ActiveRecord::Associations::Preloader::HasMany.prepend(
|
97
149
|
# AssociateJsonb::Associations::Preloader::HasMany
|
98
150
|
# )
|
151
|
+
%i[
|
152
|
+
AlterTable
|
153
|
+
ConstraintDefinition
|
154
|
+
ReferenceDefinition
|
155
|
+
SchemaCreation
|
156
|
+
Table
|
157
|
+
TableDefinition
|
158
|
+
].each do |m|
|
159
|
+
includable = AssociateJsonb::ConnectionAdapters.const_get(m)
|
160
|
+
including =
|
161
|
+
begin
|
162
|
+
ActiveRecord::ConnectionAdapters::PostgreSQL.const_get(m)
|
163
|
+
rescue NameError
|
164
|
+
ActiveRecord::ConnectionAdapters.const_get(m)
|
165
|
+
end
|
166
|
+
including.prepend includable
|
167
|
+
rescue NameError
|
168
|
+
ActiveRecord::ConnectionAdapters::PostgreSQL.const_set(m, includable)
|
169
|
+
end
|
170
|
+
|
171
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(
|
172
|
+
AssociateJsonb::ConnectionAdapters::SchemaStatements
|
173
|
+
)
|
174
|
+
|
99
175
|
|
100
176
|
ActiveRecord::Reflection::AbstractReflection.prepend AssociateJsonb::Reflection
|
101
177
|
ActiveRecord::PredicateBuilder.prepend AssociateJsonb::PredicateBuilder
|
@@ -13,58 +13,98 @@ module AssociateJsonb
|
|
13
13
|
deleted = []
|
14
14
|
finished = {}
|
15
15
|
keys.each do |k|
|
16
|
-
if
|
17
|
-
finished[k], a, d = collect_hash_changes(original[k], updated[k], nesting ? "#{nesting},#{k}" : k)
|
16
|
+
if updated[k].is_a?(Hash)
|
17
|
+
finished[k], a, d = collect_hash_changes(original[k].is_a?(Hash) ? original[k] : {}, updated[k], nesting ? "#{nesting},#{k}" : k)
|
18
|
+
a = [[(nesting ? "{#{nesting},#{k}}" : "{#{k}}"), {}]] if original[k].nil? && a.blank?
|
18
19
|
added |= a
|
19
20
|
deleted |= d
|
20
|
-
|
21
|
-
if updated[k].nil?
|
21
|
+
elsif updated[k].nil?
|
22
22
|
deleted << (nesting ? "{#{nesting},#{k}}" : "{#{k}}") if updated_keys.include?(k)
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
end
|
23
|
+
elsif original[k] != updated[k]
|
24
|
+
finished[k] = updated[k]
|
25
|
+
added << [(nesting ? "{#{nesting},#{k}}" : "{#{k}}"), updated[k]]
|
27
26
|
end
|
28
27
|
end
|
29
28
|
[ finished, added, deleted ]
|
30
29
|
end
|
31
30
|
|
32
|
-
def
|
31
|
+
def is_hash?(type)
|
32
|
+
AssociateJsonb.is_hash? type
|
33
|
+
end
|
34
|
+
|
35
|
+
def is_update?(collector)
|
33
36
|
collector &&
|
34
|
-
Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /UPDATE/) }
|
35
|
-
AssociateJsonb.safe_hash_classes.any? {|t| o.value.type.is_a?(t) }
|
37
|
+
Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /UPDATE/) }
|
36
38
|
rescue
|
37
39
|
false
|
38
40
|
end
|
39
41
|
|
40
|
-
def
|
41
|
-
|
42
|
-
value
|
42
|
+
def is_insert?(collector)
|
43
|
+
collector &&
|
44
|
+
Array(collector.value).any? {|v| v.is_a?(String) && (v =~ /INSERT INTO/) }
|
45
|
+
rescue
|
46
|
+
false
|
47
|
+
end
|
43
48
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
+
def visit_BindHashChanges(t, collector)
|
50
|
+
changes, additions, deletions =
|
51
|
+
collect_hash_changes(
|
52
|
+
t.original_value.presence || {},
|
53
|
+
t.value.presence || {}
|
54
|
+
)
|
49
55
|
|
50
|
-
|
56
|
+
base_json = +"COALESCE(#{quote_column_name(t.name)}, '{}'::jsonb)"
|
57
|
+
json = base_json
|
51
58
|
|
52
|
-
|
53
|
-
|
54
|
-
|
59
|
+
deletions.each do |del|
|
60
|
+
json = +"(#{json} #- '#{del}')"
|
61
|
+
end
|
55
62
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
63
|
+
coalesced_paths = []
|
64
|
+
additions.sort.each do |add, value|
|
65
|
+
collector.add_bind(t.with_value_from_user(value)) do |i|
|
66
|
+
json = +"jsonb_nested_set(#{json},'#{add}', COALESCE($#{i}, '{}'::jsonb))"
|
67
|
+
''
|
61
68
|
end
|
69
|
+
end
|
62
70
|
|
63
|
-
|
71
|
+
collector << json
|
72
|
+
end
|
64
73
|
|
65
|
-
|
66
|
-
|
67
|
-
|
74
|
+
def visit_Arel_Nodes_BindParam(o, collector)
|
75
|
+
catch(:nodes_bound) do
|
76
|
+
if AssociateJsonb.jsonb_set_enabled
|
77
|
+
catch(:not_hashable) do
|
78
|
+
if is_hash?(o.value.type)
|
79
|
+
if is_update?(collector)
|
80
|
+
visit_BindHashChanges(o.value, collector)
|
81
|
+
|
82
|
+
throw :nodes_bound, collector
|
83
|
+
elsif is_insert?(collector)
|
84
|
+
value = o.value
|
85
|
+
|
86
|
+
value, _, _ =
|
87
|
+
collect_hash_changes(
|
88
|
+
{},
|
89
|
+
value.value.presence || {}
|
90
|
+
)
|
91
|
+
throw :nodes_bound, collector.add_bind(o.value.with_cast_value(value)) { |i| "$#{i}"}
|
92
|
+
else
|
93
|
+
throw :not_hashable
|
94
|
+
end
|
95
|
+
else
|
96
|
+
throw :not_hashable
|
97
|
+
end
|
98
|
+
end
|
99
|
+
elsif AssociateJsonb.jsonb_delete_nil && is_hash?(o.value.type)
|
100
|
+
value, _, _ =
|
101
|
+
collect_hash_changes(
|
102
|
+
{},
|
103
|
+
o.value.value.presence || {}
|
104
|
+
)
|
105
|
+
throw :nodes_bound, collector.add_bind(o.value.with_cast_value(value)) { |i| "$#{i}" }
|
106
|
+
end
|
107
|
+
throw :nodes_bound, collector.add_bind(o.value) { |i| "$#{i}" }
|
68
108
|
end
|
69
109
|
end
|
70
110
|
end
|
@@ -22,7 +22,7 @@ module AssociateJsonb
|
|
22
22
|
key = (reflection.jsonb_store_key || foreign_key).to_s
|
23
23
|
store = reflection.jsonb_store_attr
|
24
24
|
|
25
|
-
mixin.instance_eval
|
25
|
+
mixin.instance_eval <<~CODE, __FILE__, __LINE__ + 1
|
26
26
|
if attribute_names.include?(foreign_key)
|
27
27
|
raise AssociateJsonb::Associations::
|
28
28
|
ConflictingAssociation,
|
@@ -51,7 +51,7 @@ module AssociateJsonb
|
|
51
51
|
foreign_type = :integer
|
52
52
|
end
|
53
53
|
|
54
|
-
mixin.instance_eval
|
54
|
+
mixin.instance_eval <<~CODE, __FILE__, __LINE__ + 1
|
55
55
|
store_column_attribute(:#{store}, :#{foreign_key}, foreign_type, sql_type: sql_type, key: "#{key}", **opts)
|
56
56
|
CODE
|
57
57
|
end
|
@@ -1,3 +1,4 @@
|
|
1
|
+
# encoding: utf-8
|
1
2
|
# frozen_string_literal: true
|
2
3
|
|
3
4
|
module AssociateJsonb
|
@@ -8,55 +9,153 @@ module AssociateJsonb
|
|
8
9
|
sql = super
|
9
10
|
sql << o.constraint_adds.map {|ct| visit_AddConstraint ct }.join(" ")
|
10
11
|
sql << o.constraint_drops.map {|ct| visit_DropConstraint ct }.join(" ")
|
11
|
-
|
12
|
+
sql
|
12
13
|
end
|
13
14
|
|
14
|
-
def visit_TableDefinition(
|
15
|
-
|
15
|
+
def visit_TableDefinition(o)
|
16
|
+
create_sql = +"CREATE#{table_modifier_in_create(o)} TABLE "
|
17
|
+
create_sql << "IF NOT EXISTS " if o.if_not_exists
|
18
|
+
create_sql << "#{quote_table_name(o.name)} "
|
19
|
+
|
20
|
+
statements = o.columns.map { |c| accept c }
|
21
|
+
statements << accept(o.primary_keys) if o.primary_keys
|
22
|
+
|
23
|
+
if supports_indexes_in_create?
|
24
|
+
statements.concat(o.indexes.map { |column_name, options| index_in_create(o.name, column_name, options) })
|
25
|
+
end
|
26
|
+
|
27
|
+
if supports_foreign_keys?
|
28
|
+
statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) })
|
29
|
+
# statements.concat(o.constraints.map { |ct| visit_ConstraintDefinition(ct) })
|
30
|
+
end
|
31
|
+
|
32
|
+
create_sql << "(#{statements.join(', ')})" if statements.present?
|
33
|
+
add_table_options!(create_sql, table_options(o))
|
34
|
+
create_sql << " AS #{to_sql(o.as)}" if o.as
|
35
|
+
create_sql
|
16
36
|
end
|
17
37
|
|
18
|
-
def
|
19
|
-
|
20
|
-
|
21
|
-
|
38
|
+
def visit_ConstraintDeferral(o)
|
39
|
+
return "" unless o.deferrable_default?
|
40
|
+
return "NOT DEFERRABLE" unless o.deferrable?
|
41
|
+
initial =
|
42
|
+
case o.deferrable
|
43
|
+
when :immediate
|
44
|
+
"IMMEDIATE"
|
45
|
+
else
|
46
|
+
"DEFERRED"
|
47
|
+
end
|
48
|
+
"DEFERRABLE INITIALLY #{initial}"
|
22
49
|
end
|
23
50
|
|
24
51
|
def visit_ConstraintDefinition(o)
|
25
52
|
+<<-SQL.squish
|
26
|
-
CONSTRAINT #{o.name}
|
53
|
+
CONSTRAINT #{quote_column_name(o.name)}
|
54
|
+
CHECK (#{o.value})
|
55
|
+
#{visit_ConstraintDeferral(o)}
|
56
|
+
#{o.not_valid? ? "NOT VALID" : ''}
|
27
57
|
SQL
|
28
58
|
end
|
29
59
|
|
30
60
|
def visit_AddConstraint(o)
|
31
|
-
|
61
|
+
sql = +""
|
62
|
+
if o.force?
|
63
|
+
sql << visit_DropConstraint(o)
|
64
|
+
sql << " "
|
65
|
+
end
|
66
|
+
sql << "ADD #{accept(o)}"
|
32
67
|
end
|
33
68
|
|
34
|
-
def visit_DropConstraint(o)
|
35
|
-
|
69
|
+
def visit_DropConstraint(o, if_exists: false)
|
70
|
+
+<<-SQL.squish
|
71
|
+
DROP CONSTRAINT #{quote_column_name(o.name)}
|
72
|
+
#{o.force? ? "IF EXISTS" : ""}
|
73
|
+
SQL
|
36
74
|
end
|
37
75
|
|
38
|
-
def
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
76
|
+
def visit_AddJsonbForeignKeyFunction(*)
|
77
|
+
<<~SQL
|
78
|
+
CREATE OR REPLACE FUNCTION jsonb_foreign_key
|
79
|
+
(
|
80
|
+
table_name text,
|
81
|
+
foreign_key text,
|
82
|
+
store jsonb,
|
83
|
+
key text,
|
84
|
+
type text default 'numeric',
|
85
|
+
nullable boolean default TRUE
|
86
|
+
)
|
87
|
+
RETURNS BOOLEAN AS
|
88
|
+
$BODY$
|
89
|
+
DECLARE
|
90
|
+
does_exist BOOLEAN;
|
91
|
+
BEGIN
|
92
|
+
IF store->key IS NULL
|
93
|
+
THEN
|
94
|
+
return nullable;
|
95
|
+
END IF;
|
50
96
|
|
51
|
-
|
97
|
+
EXECUTE FORMAT('SELECT EXISTS (SELECT 1 FROM %1$I WHERE %1$I.%2$I = CAST($1 AS ' || type || '))', table_name, foreign_key)
|
98
|
+
INTO does_exist
|
99
|
+
USING store->>key;
|
100
|
+
|
101
|
+
RETURN does_exist;
|
102
|
+
END;
|
103
|
+
$BODY$
|
104
|
+
LANGUAGE plpgsql;
|
105
|
+
|
106
|
+
SQL
|
52
107
|
end
|
53
108
|
|
54
|
-
def
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
109
|
+
def visit_AddJsonbNestedSetFunction(*)
|
110
|
+
<<~SQL
|
111
|
+
CREATE OR REPLACE FUNCTION jsonb_nested_set
|
112
|
+
(
|
113
|
+
target jsonb,
|
114
|
+
path text[],
|
115
|
+
new_value jsonb
|
116
|
+
)
|
117
|
+
RETURNS jsonb AS
|
118
|
+
$BODY$
|
119
|
+
DECLARE
|
120
|
+
new_json jsonb := '{}'::jsonb;
|
121
|
+
does_exist BOOLEAN;
|
122
|
+
current_path text[];
|
123
|
+
key text;
|
124
|
+
BEGIN
|
125
|
+
IF target #> path IS NOT NULL
|
126
|
+
THEN
|
127
|
+
return jsonb_set(target, path, new_value);
|
128
|
+
ELSE
|
129
|
+
new_json := target;
|
130
|
+
|
131
|
+
IF array_length(path, 1) > 1
|
132
|
+
THEN
|
133
|
+
FOREACH key IN ARRAY path[:(array_length(path, 1) - 1)]
|
134
|
+
LOOP
|
135
|
+
current_path := array_append(current_path, key);
|
136
|
+
IF new_json #> current_path IS NULL
|
137
|
+
THEN
|
138
|
+
new_json := jsonb_set(new_json, current_path, '{}'::jsonb, TRUE);
|
139
|
+
END IF;
|
140
|
+
END LOOP;
|
141
|
+
END IF;
|
142
|
+
|
143
|
+
return jsonb_set(new_json, path, new_value, TRUE);
|
144
|
+
END IF;
|
145
|
+
END;
|
146
|
+
$BODY$
|
147
|
+
LANGUAGE plpgsql;
|
148
|
+
SQL
|
149
|
+
end
|
150
|
+
|
151
|
+
def add_column_options!(sql, opts)
|
152
|
+
super
|
153
|
+
|
154
|
+
if opts[:constraint]
|
155
|
+
sql << " #{accept(ConstraintDefinition.new(**opts[:constraint]))}"
|
59
156
|
end
|
157
|
+
|
158
|
+
sql
|
60
159
|
end
|
61
160
|
end
|
62
161
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module AssociateJsonb
|
5
|
+
module ConnectionAdapters
|
6
|
+
module AlterTable # :nodoc:
|
7
|
+
attr_reader :constraint_adds, :constraint_drops
|
8
|
+
def initialize(td)
|
9
|
+
super
|
10
|
+
@constraint_adds = []
|
11
|
+
@constraint_drops = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def add_constraint(name = nil, **opts)
|
15
|
+
unless opts[:value].present?
|
16
|
+
raise ArgumentError.new("Invalid Add Constraint Options")
|
17
|
+
end
|
18
|
+
|
19
|
+
@constraint_adds << ConstraintDefinition.new(
|
20
|
+
**opts.reverse_merge(name: name)
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def alter_constraint(name = nil, **opts)
|
25
|
+
opts[:force] = true
|
26
|
+
add_constraint(name, **opts)
|
27
|
+
end
|
28
|
+
|
29
|
+
def drop_constraint(name = nil, **opts)
|
30
|
+
opts = opts.reverse_merge(force: true, name: name, value: nil)
|
31
|
+
|
32
|
+
unless opts[:name].present? || opts[:value].present?
|
33
|
+
raise ArgumentError.new("Invalid Drop Constraint Options")
|
34
|
+
end
|
35
|
+
|
36
|
+
@constraint_drops << ConstraintDefinition.new(**opts)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module AssociateJsonb
|
5
|
+
module ConnectionAdapters
|
6
|
+
class ConstraintDefinition
|
7
|
+
# rubocop:disable Metrics/ParameterLists
|
8
|
+
attr_reader :name, :value, :not_valid, :deferrable, :force
|
9
|
+
def initialize(value:, name: nil, not_valid: false, force: false, deferrable: true, **)
|
10
|
+
@name = name.presence
|
11
|
+
@value = value
|
12
|
+
@not_valid = not_valid
|
13
|
+
@deferrable = deferrable
|
14
|
+
@force = force
|
15
|
+
|
16
|
+
@name ||=
|
17
|
+
"rails_constraint_" \
|
18
|
+
"#{@value.hash}" \
|
19
|
+
"_#{not_valid ? "nv" : "v"}" \
|
20
|
+
"_#{deferrable ? "d" : "nd"}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def deferrable_default?
|
24
|
+
deferrable.nil?
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
def name?
|
29
|
+
!!name
|
30
|
+
end
|
31
|
+
|
32
|
+
def value?
|
33
|
+
!!value
|
34
|
+
end
|
35
|
+
|
36
|
+
def not_valid?
|
37
|
+
!!not_valid
|
38
|
+
end
|
39
|
+
|
40
|
+
def deferrable?
|
41
|
+
!!deferrable
|
42
|
+
end
|
43
|
+
|
44
|
+
def force?
|
45
|
+
!!force
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_h
|
49
|
+
{
|
50
|
+
name: name,
|
51
|
+
value: value,
|
52
|
+
not_valid: not_valid,
|
53
|
+
deferrable: deferrable,
|
54
|
+
force: force
|
55
|
+
}
|
56
|
+
end
|
57
|
+
alias :to_hash :to_h
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -1,8 +1,9 @@
|
|
1
|
+
# encoding: utf-8
|
1
2
|
# frozen_string_literal: true
|
2
3
|
|
3
4
|
module AssociateJsonb
|
4
5
|
module ConnectionAdapters
|
5
|
-
module ReferenceDefinition
|
6
|
+
module ReferenceDefinition
|
6
7
|
ForeignKeyDefinition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition
|
7
8
|
# rubocop:disable Metrics/ParameterLists
|
8
9
|
def initialize(
|
@@ -13,6 +14,7 @@ module AssociateJsonb
|
|
13
14
|
)
|
14
15
|
@store = store && store.to_sym
|
15
16
|
@store_key = store_key && store_key.to_s unless options[:polymorphic]
|
17
|
+
@nullable = options[:null] != false
|
16
18
|
|
17
19
|
super(name, **options)
|
18
20
|
end
|
@@ -39,20 +41,24 @@ module AssociateJsonb
|
|
39
41
|
|
40
42
|
if foreign_key && column_names.length == 1
|
41
43
|
fk = ForeignKeyDefinition.new(table.name, foreign_table_name, foreign_key_options)
|
42
|
-
columns.each do |col_name, type,
|
44
|
+
columns.each do |col_name, type, options|
|
45
|
+
options ||= {}
|
43
46
|
value = <<-SQL.squish
|
44
47
|
jsonb_foreign_key(
|
45
|
-
'#{fk.to_table}',
|
46
|
-
'#{fk.
|
47
|
-
#{store},
|
48
|
-
'#{col_name}',
|
49
|
-
'#{type}'
|
48
|
+
'#{fk.to_table}'::text,
|
49
|
+
'#{fk.primary_key}'::text,
|
50
|
+
#{store}::jsonb,
|
51
|
+
'#{col_name}'::text,
|
52
|
+
'#{type}'::text,
|
53
|
+
#{nullable}
|
50
54
|
)
|
51
55
|
SQL
|
52
|
-
table.constraint(
|
56
|
+
table.constraint(
|
53
57
|
name: "#{table.name}_#{col_name}_foreign_key",
|
54
|
-
value: value
|
55
|
-
|
58
|
+
value: value,
|
59
|
+
not_valid: true,
|
60
|
+
deferrable: true
|
61
|
+
)
|
56
62
|
end
|
57
63
|
end
|
58
64
|
|
@@ -76,7 +82,7 @@ module AssociateJsonb
|
|
76
82
|
|
77
83
|
protected
|
78
84
|
|
79
|
-
attr_reader :store, :store_key
|
85
|
+
attr_reader :store, :store_key, :nullable
|
80
86
|
end
|
81
87
|
end
|
82
88
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module AssociateJsonb
|
5
|
+
module ConnectionAdapters
|
6
|
+
module TableDefinition
|
7
|
+
attr_reader :constraints
|
8
|
+
|
9
|
+
def initialize(*,**)
|
10
|
+
super
|
11
|
+
@constraints = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def constraint(name = nil, **opts)
|
15
|
+
unless opts[:value].present?
|
16
|
+
raise ArgumentError.new("Invalid Drop Constraint Options")
|
17
|
+
end
|
18
|
+
|
19
|
+
@constraints << ConstraintDefinition.new(
|
20
|
+
**opts.reverse_merge(name: name)
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -1,13 +1,116 @@
|
|
1
|
+
# encoding: utf-8
|
1
2
|
# frozen_string_literal: true
|
2
3
|
|
3
|
-
require "active_record/migration/join_table"
|
4
|
-
require "active_support/core_ext/string/access"
|
5
|
-
require "active_support/deprecation"
|
6
|
-
require "digest/sha2"
|
7
|
-
|
8
4
|
module AssociateJsonb
|
9
5
|
module ConnectionAdapters
|
10
6
|
module SchemaStatements
|
7
|
+
def add_jsonb_nested_set_function
|
8
|
+
execute schema_creation.accept(AddJsonbNestedSetFunction.new)
|
9
|
+
end
|
10
|
+
|
11
|
+
def add_jsonb_foreign_key_function
|
12
|
+
execute schema_creation.accept(AddJsonbForeignKeyFunction.new)
|
13
|
+
end
|
14
|
+
|
15
|
+
def create_table(table_name, **options)
|
16
|
+
td = create_table_definition(table_name, **options)
|
17
|
+
|
18
|
+
if options[:id] != false && !options[:as]
|
19
|
+
pk = options.fetch(:primary_key) do
|
20
|
+
ActiveRecord::Base.get_primary_key table_name.to_s.singularize
|
21
|
+
end
|
22
|
+
|
23
|
+
if pk.is_a?(Array)
|
24
|
+
td.primary_keys pk
|
25
|
+
else
|
26
|
+
td.primary_key pk, options.fetch(:id, :primary_key), **options.except(:comment)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
yield td if block_given?
|
31
|
+
|
32
|
+
if options[:force]
|
33
|
+
drop_table(table_name, **options, if_exists: true)
|
34
|
+
end
|
35
|
+
|
36
|
+
result = execute schema_creation.accept td
|
37
|
+
|
38
|
+
td.indexes.each do |column_name, index_options|
|
39
|
+
add_index(table_name, column_name, index_options)
|
40
|
+
end
|
41
|
+
|
42
|
+
td.constraints.each do |ct|
|
43
|
+
add_constraint(table_name, **ct)
|
44
|
+
end
|
45
|
+
|
46
|
+
if table_comment = options[:comment].presence
|
47
|
+
change_table_comment(table_name, table_comment)
|
48
|
+
end
|
49
|
+
|
50
|
+
td.columns.each do |column|
|
51
|
+
change_column_comment(table_name, column.name, column.comment) if column.comment.present?
|
52
|
+
end
|
53
|
+
|
54
|
+
result
|
55
|
+
end
|
56
|
+
|
57
|
+
def add_constraint(table_name, **options)
|
58
|
+
at = create_alter_table table_name
|
59
|
+
at.add_constraint(**options)
|
60
|
+
execute schema_creation.accept at
|
61
|
+
end
|
62
|
+
|
63
|
+
def constraints(table_name) # :nodoc:
|
64
|
+
scope = quoted_scope(table_name)
|
65
|
+
|
66
|
+
result = query(<<~SQL, "SCHEMA")
|
67
|
+
SELECT
|
68
|
+
con.oid,
|
69
|
+
con.conname,
|
70
|
+
con.connamespace,
|
71
|
+
con.contype,
|
72
|
+
con.condeferrable,
|
73
|
+
con.condeferred,
|
74
|
+
con.convalidated,
|
75
|
+
pg_get_constraintdef(con.oid) as consrc
|
76
|
+
FROM pg_catalog.pg_constraint con
|
77
|
+
INNER JOIN pg_catalog.pg_class rel
|
78
|
+
ON rel.oid = con.conrelid
|
79
|
+
INNER JOIN pg_catalog.pg_namespace nsp
|
80
|
+
ON nsp.oid = connamespace
|
81
|
+
WHERE nsp.nspname = #{scope[:schema]}
|
82
|
+
AND rel.relname = #{scope[:name]}
|
83
|
+
ORDER BY rel.relname
|
84
|
+
SQL
|
85
|
+
|
86
|
+
result.map do |row|
|
87
|
+
{
|
88
|
+
oid: row[0],
|
89
|
+
name: row[1],
|
90
|
+
deferrable: row[4],
|
91
|
+
deferred: row[5],
|
92
|
+
validated: row[6],
|
93
|
+
definition: row[7],
|
94
|
+
type:
|
95
|
+
case row[3].to_s.downcase
|
96
|
+
when "c"
|
97
|
+
"CHECK"
|
98
|
+
when "f"
|
99
|
+
"FOREIGN KEY"
|
100
|
+
when "p"
|
101
|
+
"PRIMARY KEY"
|
102
|
+
when "u"
|
103
|
+
"UNIQUE"
|
104
|
+
when "t"
|
105
|
+
"TRIGGER"
|
106
|
+
when "x"
|
107
|
+
"EXCLUDE"
|
108
|
+
else
|
109
|
+
"UNKNOWN"
|
110
|
+
end
|
111
|
+
}
|
112
|
+
end
|
113
|
+
end
|
11
114
|
end
|
12
115
|
end
|
13
116
|
end
|
@@ -34,7 +34,7 @@ module AssociateJsonb
|
|
34
34
|
end
|
35
35
|
|
36
36
|
included do
|
37
|
-
instance_eval
|
37
|
+
instance_eval <<~CODE, __FILE__, __LINE__ + 1
|
38
38
|
initialize_store_column_attribute_tracker
|
39
39
|
|
40
40
|
after_initialize &set_store_column_attribute_values_on_init
|
@@ -106,7 +106,7 @@ module AssociateJsonb
|
|
106
106
|
array = attribute_opts[:array]
|
107
107
|
attribute attr, cast_type, **attribute_opts
|
108
108
|
|
109
|
-
instance_eval
|
109
|
+
instance_eval <<~CODE, __FILE__, __LINE__ + 1
|
110
110
|
add_store_column_attribute_name("#{attr}", :#{store}, "#{key}", { sql_type: sql_type, type: cast_type, opts: attribute_opts })
|
111
111
|
CODE
|
112
112
|
|
@@ -117,47 +117,52 @@ module AssociateJsonb
|
|
117
117
|
class InstanceMethodsOnActivation < Module
|
118
118
|
def initialize(mixin, store, attribute, key, is_array)
|
119
119
|
is_array = !!(is_array && attribute.to_s =~ /_ids$/)
|
120
|
-
on_attr_change =
|
121
|
-
is_array \
|
122
|
-
? "write_attribute(:#{attribute}, Array(given))" \
|
123
|
-
: "super(given)"
|
124
|
-
on_store_change = ->(var) {
|
125
|
-
"write_attribute(:#{attribute}, #{
|
126
|
-
is_array \
|
127
|
-
? "Array(#{var})" \
|
128
|
-
: var
|
129
|
-
})"
|
130
|
-
}
|
131
120
|
|
121
|
+
array_or_attr = ->(value) {
|
122
|
+
is_array \
|
123
|
+
? %Q(Array(#{value})) \
|
124
|
+
: %Q(#{value})
|
125
|
+
}
|
126
|
+
|
127
|
+
on_store_change = "_write_attribute(:#{attribute}, #{array_or_attr.call %Q(#{store}["#{key}"])})"
|
128
|
+
on_attr_change = "super(#{array_or_attr.call %Q(given)})"
|
132
129
|
|
133
130
|
if is_array
|
134
|
-
mixin.class_eval
|
131
|
+
mixin.class_eval <<~CODE, __FILE__, __LINE__ + 1
|
135
132
|
def #{attribute}
|
136
133
|
_read_attribute(:#{attribute}) || []
|
137
134
|
end
|
138
135
|
CODE
|
139
136
|
end
|
140
137
|
|
141
|
-
mixin.class_eval
|
138
|
+
mixin.class_eval <<~CODE, __FILE__, __LINE__ + 1
|
142
139
|
def #{store}=(given)
|
143
|
-
if
|
144
|
-
given =
|
145
|
-
#{store}.keys.each do |k|
|
146
|
-
given[k] = nil
|
147
|
-
end
|
140
|
+
if given.is_a?(::String)
|
141
|
+
given = ActiveSupport::JSON.decode(given) rescue nil
|
148
142
|
end
|
149
|
-
|
150
|
-
if
|
151
|
-
|
152
|
-
|
143
|
+
|
144
|
+
if AssociateJsonb.merge_hash?(self.class.attribute_types["#{store}"])
|
145
|
+
if !given
|
146
|
+
given = {}
|
147
|
+
#{store}.keys.each do |k|
|
148
|
+
given[k] = nil
|
149
|
+
end
|
150
|
+
end
|
151
|
+
super(#{store}.deep_merge(given.deep_stringify_keys))
|
152
|
+
|
153
|
+
self.#{attribute}= #{store}["#{key}"] if #{store}.key?("#{key}")
|
154
|
+
else
|
155
|
+
super given || {}
|
156
|
+
self.#{attribute}= #{store}["#{key}"]
|
153
157
|
end
|
158
|
+
|
154
159
|
#{store}
|
155
160
|
end
|
156
161
|
|
157
162
|
def #{attribute}=(given)
|
158
163
|
#{on_attr_change}
|
159
164
|
value = #{store}["#{key}"] = #{attribute}.presence
|
160
|
-
|
165
|
+
#{store}.delete("#{key}") unless !value.nil? || AssociateJsonb.merge_hash?(self.class.attribute_types["#{store}"])
|
161
166
|
value
|
162
167
|
end
|
163
168
|
CODE
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: associate_jsonb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sampson Crowley
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-08-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -104,14 +104,18 @@ dependencies:
|
|
104
104
|
- - "~>"
|
105
105
|
- !ruby/object:Gem::Version
|
106
106
|
version: 3.7.0
|
107
|
-
description: |
|
108
|
-
|
107
|
+
description: |
|
108
|
+
This gem extends ActiveRecord to add additional functionality to JSONB
|
109
109
|
|
110
|
-
|
111
|
-
|
110
|
+
- use PostgreSQL JSONB data for associations
|
111
|
+
- thread-safe single-key updates to JSONB columns using `jsonb_set`
|
112
|
+
- extended `table#references` for easy migrations and indexes
|
113
|
+
- virtual JSONB foreign keys using check constraints
|
114
|
+
(NOTE: real foreign key constraints are not possible with PostgreSQL JSONB)
|
112
115
|
|
113
|
-
|
114
|
-
|
116
|
+
Inspired by activerecord-jsonb-associations, but for use in Rails 6+ and
|
117
|
+
ruby 2.7+ and with some unnecessary options and features (HABTM) removed
|
118
|
+
and some additional features added
|
115
119
|
email:
|
116
120
|
- sampsonsprojects@gmail.com
|
117
121
|
executables: []
|
@@ -151,11 +155,15 @@ files:
|
|
151
155
|
- lib/associate_jsonb/attribute_methods.rb
|
152
156
|
- lib/associate_jsonb/attribute_methods/read.rb
|
153
157
|
- lib/associate_jsonb/connection_adapters.rb
|
154
|
-
- lib/associate_jsonb/connection_adapters/reference_definition.rb
|
155
158
|
- lib/associate_jsonb/connection_adapters/schema_creation.rb
|
156
|
-
- lib/associate_jsonb/connection_adapters/
|
159
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_foreign_key_function.rb
|
160
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/add_jsonb_nested_set_function.rb
|
161
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/alter_table.rb
|
162
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/constraint_definition.rb
|
163
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/reference_definition.rb
|
164
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/table.rb
|
165
|
+
- lib/associate_jsonb/connection_adapters/schema_definitions/table_definition.rb
|
157
166
|
- lib/associate_jsonb/connection_adapters/schema_statements.rb
|
158
|
-
- lib/associate_jsonb/connection_adapters/table_definition.rb
|
159
167
|
- lib/associate_jsonb/persistence.rb
|
160
168
|
- lib/associate_jsonb/predicate_builder.rb
|
161
169
|
- lib/associate_jsonb/reflection.rb
|
@@ -1,35 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module AssociateJsonb
|
4
|
-
module ConnectionAdapters
|
5
|
-
module SchemaCreation
|
6
|
-
class AlterTable # :nodoc:
|
7
|
-
attr_reader :constraint_adds
|
8
|
-
attr_reader :constraint_drops
|
9
|
-
|
10
|
-
def initialize(td)
|
11
|
-
super
|
12
|
-
@constraint_adds = []
|
13
|
-
@constraint_drops = []
|
14
|
-
end
|
15
|
-
|
16
|
-
|
17
|
-
def add_foreign_key(to_table, options)
|
18
|
-
@foreign_key_adds << ForeignKeyDefinition.new(name, to_table, options)
|
19
|
-
end
|
20
|
-
|
21
|
-
def drop_foreign_key(name)
|
22
|
-
@foreign_key_drops << name
|
23
|
-
end
|
24
|
-
|
25
|
-
def add_constraint(options)
|
26
|
-
@foreign_key_adds << ConstraintDefinition.new(name, options)
|
27
|
-
end
|
28
|
-
|
29
|
-
def drop_constraint(options)
|
30
|
-
@constraint_drops << ConstraintDefinition.new(name, options)
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|
34
|
-
end
|
35
|
-
end
|