composite_primary_keys 14.0.4 → 14.0.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/History.rdoc +30 -0
- data/README.rdoc +2 -2
- data/lib/composite_primary_keys/associations/collection_association.rb +38 -31
- data/lib/composite_primary_keys/associations/preloader/association.rb +0 -16
- data/lib/composite_primary_keys/composite_arrays.rb +88 -86
- data/lib/composite_primary_keys/composite_predicates.rb +50 -0
- data/lib/composite_primary_keys/relation.rb +197 -197
- data/lib/composite_primary_keys/validations/uniqueness.rb +40 -32
- data/lib/composite_primary_keys/version.rb +1 -1
- data/test/fixtures/room_assignment.rb +18 -14
- data/test/test_associations.rb +23 -1
- data/test/test_composite_arrays.rb +44 -38
- data/test/test_create.rb +219 -218
- data/test/test_predicates.rb +130 -60
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 76a010358e1d2cacd9b05f6229c1fe4225f20f888fefb57b0bd6a9f62589a720
|
4
|
+
data.tar.gz: 1b7870c774bbed21d4556cf53518054860903cae4a0987f5978e02fc3883597f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 117d7be0b54619f7adcb05ddb200c3c97571cc74bf2fcf33c99034efaec08924cf6d56e998db1135c92bf71497697b30ca531a882d755329d5b6f9192d606013
|
7
|
+
data.tar.gz: a9f330398e5b1a9153a67f229a28e83d65c5bc02334e6e9f3456229c4c1e011a3bf03909fa45e37739d4ab47a37663a3ee6ef09c7d7194721e91849e520c0a01
|
data/History.rdoc
CHANGED
@@ -1,3 +1,19 @@
|
|
1
|
+
== 14.0.6 (2023-02-04)
|
2
|
+
* Port fix for #573 (Charlie Savage)
|
3
|
+
* Port fix for fix #577 (Charlie Savage)
|
4
|
+
|
5
|
+
== 14.0.5 (2023-02-04)
|
6
|
+
* Improve query generation for cpk_in_predicate. This reduces the length of
|
7
|
+
queries when loading many keys and enables Postgres to use index scans
|
8
|
+
more frequently. (Andrew Kiellor)
|
9
|
+
* Add Ruby 3.2 to CI and update checkout action versions (Peter Goldstein)
|
10
|
+
* Fix grammatical correction in README.rdoc (Sam Corl)
|
11
|
+
* Add an assertion for previously_new_record? (Akinori Musha)
|
12
|
+
* Fix validate_each (Benjamin Fleischer)
|
13
|
+
* Reduce ambiguity by avoiding #normalize (Mitsuhiro Shibuya)
|
14
|
+
* Accept strings in has_many ids assignment (Mitsuhiro Shibuya)
|
15
|
+
* Make CompositeKeys respond to #to_param consistently with ActiveRecord::Base (Mitsuhiro Shibuya)
|
16
|
+
|
1
17
|
== 14.0.4 (2022-02-13)
|
2
18
|
* Fix for changed method in Rails 7.0.2 (Yota)
|
3
19
|
|
@@ -15,6 +31,20 @@
|
|
15
31
|
== 14.0.0 (2022-01-9)
|
16
32
|
* Update to ActiveRecord 7.0 (Sammy Larbi)
|
17
33
|
|
34
|
+
== 13.0.7 (2023-02-04)
|
35
|
+
* Fix #573 (Charlie Savage)
|
36
|
+
|
37
|
+
== 13.0.6 (2023-02-04)
|
38
|
+
* Fix #577 (Charlie Savage)
|
39
|
+
|
40
|
+
== 13.0.5 (2023-02-04)
|
41
|
+
* Improve query generation for cpk_in_predicate. This reduces the length of
|
42
|
+
queries when loading many keys and enables Postgres to use index scans
|
43
|
+
more frequently. (Andrew Kiellor)
|
44
|
+
|
45
|
+
== 13.0.4 (2022-12-05)
|
46
|
+
* Fix previously_new_record? not being set to true after create (Akinori MUSHA)
|
47
|
+
|
18
48
|
== 13.0.3 (2022-01-09)
|
19
49
|
* Remove override on ActiveRecord::Base#to_param. That method has moved to Integration
|
20
50
|
so no longer works. #541. (Charlie Savage)
|
data/README.rdoc
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
== Summary
|
4
4
|
|
5
|
-
|
5
|
+
ActiveRecord infamously doesn't support composite primary keys.
|
6
6
|
This gem, composite_primary_keys, or CPK for short, extends ActiveRecord
|
7
7
|
to support composite keys.
|
8
8
|
|
@@ -72,7 +72,7 @@ But first, lets check out our primary keys.
|
|
72
72
|
Membership.primary_key # => [:user_id, :group_id] # composite keys
|
73
73
|
Membership.primary_key.to_s # => "user_id,group_id"
|
74
74
|
|
75
|
-
Now we want to be able to find instances using the same syntax we always use for ActiveRecords
|
75
|
+
Now we want to be able to find instances using the same syntax we always use for ActiveRecords.
|
76
76
|
|
77
77
|
MembershipStatus.find(1) # single id returns single instance
|
78
78
|
=> <MembershipStatus:0x392a8c8 @attributes={"id"=>"1", "status"=>"Active"}>
|
@@ -1,32 +1,39 @@
|
|
1
|
-
module CompositePrimaryKeys
|
2
|
-
module CollectionAssociation
|
3
|
-
def ids_writer(ids)
|
4
|
-
primary_key = reflection.association_primary_key
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
records = klass.where(
|
17
|
-
r.
|
18
|
-
end.values_at(*ids).compact
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
1
|
+
module CompositePrimaryKeys
|
2
|
+
module CollectionAssociation
|
3
|
+
def ids_writer(ids)
|
4
|
+
primary_key = reflection.association_primary_key
|
5
|
+
ids = Array(ids).reject(&:blank?)
|
6
|
+
|
7
|
+
# CPK-
|
8
|
+
if primary_key.is_a?(Array)
|
9
|
+
ids = ids.map { |id| CompositePrimaryKeys::CompositeKeys.parse(id) }
|
10
|
+
primary_key.each_with_index do |key, i|
|
11
|
+
pk_type = klass.type_for_attribute(key)
|
12
|
+
ids.each { |id| id[i] = pk_type.cast(id[i]) }
|
13
|
+
end
|
14
|
+
|
15
|
+
predicate = CompositePrimaryKeys::Predicates.cpk_in_predicate(klass.arel_table, reflection.association_primary_key, ids)
|
16
|
+
records = klass.where(predicate).index_by do |r|
|
17
|
+
reflection.association_primary_key.map{ |k| r.send(k) }
|
18
|
+
end.values_at(*ids).compact
|
19
|
+
else
|
20
|
+
pk_type = klass.type_for_attribute(primary_key)
|
21
|
+
ids.map! { |i| pk_type.cast(i) }
|
22
|
+
|
23
|
+
records = klass.where(primary_key => ids).index_by do |r|
|
24
|
+
r.public_send(primary_key)
|
25
|
+
end.values_at(*ids).compact
|
26
|
+
end
|
27
|
+
|
28
|
+
if records.size != ids.size
|
29
|
+
found_ids = records.map { |record| record.public_send(primary_key) }
|
30
|
+
not_found_ids = ids - found_ids
|
31
|
+
klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key, not_found_ids)
|
32
|
+
else
|
33
|
+
replace(records)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
32
39
|
ActiveRecord::Associations::CollectionAssociation.prepend CompositePrimaryKeys::CollectionAssociation
|
@@ -46,22 +46,6 @@ module ActiveRecord
|
|
46
46
|
(result[key] ||= []) << owner if key
|
47
47
|
end
|
48
48
|
end
|
49
|
-
|
50
|
-
# TODO: is records_by_owner needed anymore? Rails' implementation has changed significantly
|
51
|
-
def records_by_owner
|
52
|
-
@records_by_owner ||= preloaded_records.each_with_object({}) do |record, result|
|
53
|
-
key = if association_key_name.is_a?(Array)
|
54
|
-
Array(record[association_key_name]).map do |key|
|
55
|
-
convert_key(key)
|
56
|
-
end
|
57
|
-
else
|
58
|
-
convert_key(record[association_key_name])
|
59
|
-
end
|
60
|
-
owners_by_key[key].each do |owner|
|
61
|
-
(result[owner] ||= []) << record
|
62
|
-
end
|
63
|
-
end
|
64
|
-
end
|
65
49
|
end
|
66
50
|
end
|
67
51
|
end
|
@@ -1,86 +1,88 @@
|
|
1
|
-
module CompositePrimaryKeys
|
2
|
-
ID_SEP = ','
|
3
|
-
ID_SET_SEP = ';'
|
4
|
-
ESCAPE_CHAR = '^'
|
5
|
-
|
6
|
-
module ArrayExtension
|
7
|
-
def to_composite_keys
|
8
|
-
CompositeKeys.new(self)
|
9
|
-
end
|
10
|
-
end
|
11
|
-
|
12
|
-
# Convert mixed representation of CPKs (by strings or arrays) to normalized
|
13
|
-
# representation (just by arrays).
|
14
|
-
#
|
15
|
-
# `ids` is Array that may contain:
|
16
|
-
# 1. A CPK represented by an array or a string.
|
17
|
-
# 2. An array of CPKs represented by arrays or strings.
|
18
|
-
#
|
19
|
-
# There is an issue. Let `ids` contain an array with serveral strings. We can't distinguish case 1
|
20
|
-
# from case 2 there in general. E.g. the item can be an array containing appropriate number of strings,
|
21
|
-
# and each string can contain appropriate number of commas. We consider case 2 to win there.
|
22
|
-
def self.normalize(ids, cpk_size)
|
23
|
-
ids.map do |id|
|
24
|
-
if Utils.cpk_as_array?(id, cpk_size) && id.any? { |item| !Utils.cpk_as_string?(item, cpk_size) }
|
25
|
-
# CPK as an array - case 1
|
26
|
-
id
|
27
|
-
elsif id.is_a?(Array)
|
28
|
-
# An array of CPKs - case 2
|
29
|
-
normalize(id, cpk_size)
|
30
|
-
elsif id.is_a?(String)
|
31
|
-
# CPK as a string - case 1
|
32
|
-
CompositeKeys.parse(id)
|
33
|
-
else
|
34
|
-
id
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
class CompositeKeys < Array
|
40
|
-
|
41
|
-
def self.parse(value)
|
42
|
-
case value
|
43
|
-
when Array
|
44
|
-
value.to_composite_keys
|
45
|
-
when String
|
46
|
-
value.split(ID_SEP).map { |key| Utils.unescape_string_key(key) }.to_composite_keys
|
47
|
-
else
|
48
|
-
raise(ArgumentError, "Unsupported type: #{value}")
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
def to_s
|
53
|
-
# Doing this makes it easier to parse Base#[](attr_name)
|
54
|
-
map { |key| Utils.escape_string_key(key.to_s) }.join(ID_SEP)
|
55
|
-
end
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
end
|
85
|
-
|
86
|
-
|
1
|
+
module CompositePrimaryKeys
|
2
|
+
ID_SEP = ','
|
3
|
+
ID_SET_SEP = ';'
|
4
|
+
ESCAPE_CHAR = '^'
|
5
|
+
|
6
|
+
module ArrayExtension
|
7
|
+
def to_composite_keys
|
8
|
+
CompositeKeys.new(self)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Convert mixed representation of CPKs (by strings or arrays) to normalized
|
13
|
+
# representation (just by arrays).
|
14
|
+
#
|
15
|
+
# `ids` is Array that may contain:
|
16
|
+
# 1. A CPK represented by an array or a string.
|
17
|
+
# 2. An array of CPKs represented by arrays or strings.
|
18
|
+
#
|
19
|
+
# There is an issue. Let `ids` contain an array with serveral strings. We can't distinguish case 1
|
20
|
+
# from case 2 there in general. E.g. the item can be an array containing appropriate number of strings,
|
21
|
+
# and each string can contain appropriate number of commas. We consider case 2 to win there.
|
22
|
+
def self.normalize(ids, cpk_size)
|
23
|
+
ids.map do |id|
|
24
|
+
if Utils.cpk_as_array?(id, cpk_size) && id.any? { |item| !Utils.cpk_as_string?(item, cpk_size) }
|
25
|
+
# CPK as an array - case 1
|
26
|
+
id
|
27
|
+
elsif id.is_a?(Array)
|
28
|
+
# An array of CPKs - case 2
|
29
|
+
normalize(id, cpk_size)
|
30
|
+
elsif id.is_a?(String)
|
31
|
+
# CPK as a string - case 1
|
32
|
+
CompositeKeys.parse(id)
|
33
|
+
else
|
34
|
+
id
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class CompositeKeys < Array
|
40
|
+
|
41
|
+
def self.parse(value)
|
42
|
+
case value
|
43
|
+
when Array
|
44
|
+
value.to_composite_keys
|
45
|
+
when String
|
46
|
+
value.split(ID_SEP).map { |key| Utils.unescape_string_key(key) }.to_composite_keys
|
47
|
+
else
|
48
|
+
raise(ArgumentError, "Unsupported type: #{value}")
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def to_s
|
53
|
+
# Doing this makes it easier to parse Base#[](attr_name)
|
54
|
+
map { |key| Utils.escape_string_key(key.to_s) }.join(ID_SEP)
|
55
|
+
end
|
56
|
+
|
57
|
+
alias_method :to_param, :to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
module Utils
|
61
|
+
class << self
|
62
|
+
def escape_string_key(key)
|
63
|
+
key.gsub(Regexp.union(ESCAPE_CHAR, ID_SEP)) do |unsafe|
|
64
|
+
"#{ESCAPE_CHAR}#{unsafe.ord.to_s(16).upcase}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def unescape_string_key(key)
|
69
|
+
key.gsub(/#{Regexp.escape(ESCAPE_CHAR)}[0-9a-fA-F]{2}/) do |escaped|
|
70
|
+
char = escaped.slice(1, 2).hex.chr
|
71
|
+
(char == ESCAPE_CHAR || char == ID_SEP) ? char : escaped
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def cpk_as_array?(value, pk_size)
|
76
|
+
# We don't permit Array to be an element of CPK.
|
77
|
+
value.is_a?(Array) && value.size == pk_size && value.none? { |item| item.is_a?(Array) }
|
78
|
+
end
|
79
|
+
|
80
|
+
def cpk_as_string?(value, pk_size)
|
81
|
+
value.is_a?(String) && value.count(ID_SEP) == pk_size - 1
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
private_constant :Utils
|
86
|
+
end
|
87
|
+
|
88
|
+
Array.send(:include, CompositePrimaryKeys::ArrayExtension)
|
@@ -51,9 +51,59 @@ module CompositePrimaryKeys
|
|
51
51
|
end
|
52
52
|
|
53
53
|
def cpk_in_predicate(table, primary_keys, ids)
|
54
|
+
if primary_keys.length == 2
|
55
|
+
cpk_in_predicate_with_grouped_keys(table, primary_keys, ids)
|
56
|
+
else
|
57
|
+
cpk_in_predicate_with_non_grouped_keys(table, primary_keys, ids)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def cpk_in_predicate_with_non_grouped_keys(table, primary_keys, ids)
|
54
62
|
and_predicates = ids.map do |id|
|
55
63
|
cpk_id_predicate(table, primary_keys, id)
|
56
64
|
end
|
65
|
+
|
66
|
+
cpk_or_predicate(and_predicates)
|
67
|
+
end
|
68
|
+
|
69
|
+
def cpk_in_predicate_with_grouped_keys(table, primary_keys, ids)
|
70
|
+
keys_by_first_column_name = Hash.new { |hash, key| hash[key] = [] }
|
71
|
+
keys_by_second_column_name = Hash.new { |hash, key| hash[key] = [] }
|
72
|
+
|
73
|
+
ids.map.each do |first_key_part, second_key_part|
|
74
|
+
keys_by_first_column_name[first_key_part] << second_key_part
|
75
|
+
keys_by_second_column_name[second_key_part] << first_key_part
|
76
|
+
end
|
77
|
+
|
78
|
+
low_cardinality_column_name, high_cardinality_column_name, groups = \
|
79
|
+
if keys_by_first_column_name.size <= keys_by_second_column_name.size
|
80
|
+
[primary_keys.first, primary_keys.second, keys_by_first_column_name]
|
81
|
+
else
|
82
|
+
[primary_keys.second, primary_keys.first, keys_by_second_column_name]
|
83
|
+
end
|
84
|
+
|
85
|
+
and_predicates = groups.map do |low_cardinality_value, high_cardinality_values|
|
86
|
+
non_nil_high_cardinality_values = high_cardinality_values.compact
|
87
|
+
in_clause = table[high_cardinality_column_name].in(non_nil_high_cardinality_values)
|
88
|
+
inclusion_clauses = if non_nil_high_cardinality_values.size != high_cardinality_values.size
|
89
|
+
Arel::Nodes::Grouping.new(
|
90
|
+
Arel::Nodes::Or.new(
|
91
|
+
in_clause,
|
92
|
+
table[high_cardinality_column_name].eq(nil)
|
93
|
+
)
|
94
|
+
)
|
95
|
+
else
|
96
|
+
in_clause
|
97
|
+
end
|
98
|
+
|
99
|
+
Arel::Nodes::And.new(
|
100
|
+
[
|
101
|
+
table[low_cardinality_column_name].eq(low_cardinality_value),
|
102
|
+
inclusion_clauses
|
103
|
+
]
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
57
107
|
cpk_or_predicate(and_predicates)
|
58
108
|
end
|
59
109
|
end
|