composite_primary_keys 14.0.4 → 14.0.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7f4289f2367e4fc8315bd9055e077e7daa2f3ca842bbb1c40e5755241379416
4
- data.tar.gz: 5f247da837ba1da9f64ba23efaa9cbe9aade3f12f2f9c879736106b9c14570ff
3
+ metadata.gz: 76a010358e1d2cacd9b05f6229c1fe4225f20f888fefb57b0bd6a9f62589a720
4
+ data.tar.gz: 1b7870c774bbed21d4556cf53518054860903cae4a0987f5978e02fc3883597f
5
5
  SHA512:
6
- metadata.gz: 01fc103302338a90c5b914e3da13fab9bfa4ca1a8d37a5f182de3755e3893321094fc018dbda672351a43cda77a0c73ef00cb7a63adeafe5b8688335f26fed64
7
- data.tar.gz: fb900706022a8d2ccfb4169e18292c6e412c9d20b1ccea6e3f25c83ddfbea993546605e34ea8addf152cd1a3f13133186a480253a3542e697e7324879bcc2662
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
- ActiveRecords infamously doesn't support composite primary keys.
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
- pk_type = klass.type_for_attribute(primary_key)
6
- ids = Array(ids).reject(&:blank?)
7
- ids.map! { |i| pk_type.cast(i) }
8
-
9
- # CPK-
10
- if primary_key.is_a?(Array)
11
- predicate = CompositePrimaryKeys::Predicates.cpk_in_predicate(klass.arel_table, reflection.association_primary_key, ids)
12
- records = klass.where(predicate).index_by do |r|
13
- reflection.association_primary_key.map{ |k| r.send(k) }
14
- end.values_at(*ids)
15
- else
16
- records = klass.where(primary_key => ids).index_by do |r|
17
- r.public_send(primary_key)
18
- end.values_at(*ids).compact
19
- end
20
-
21
- if records.size != ids.size
22
- found_ids = records.map { |record| record.public_send(primary_key) }
23
- not_found_ids = ids - found_ids
24
- klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key, not_found_ids)
25
- else
26
- replace(records)
27
- end
28
- end
29
- end
30
- end
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
- end
57
-
58
- module Utils
59
- class << self
60
- def escape_string_key(key)
61
- key.gsub(Regexp.union(ESCAPE_CHAR, ID_SEP)) do |unsafe|
62
- "#{ESCAPE_CHAR}#{unsafe.ord.to_s(16).upcase}"
63
- end
64
- end
65
-
66
- def unescape_string_key(key)
67
- key.gsub(/#{Regexp.escape(ESCAPE_CHAR)}[0-9a-fA-F]{2}/) do |escaped|
68
- char = escaped.slice(1, 2).hex.chr
69
- (char == ESCAPE_CHAR || char == ID_SEP) ? char : escaped
70
- end
71
- end
72
-
73
- def cpk_as_array?(value, pk_size)
74
- # We don't permit Array to be an element of CPK.
75
- value.is_a?(Array) && value.size == pk_size && value.none? { |item| item.is_a?(Array) }
76
- end
77
-
78
- def cpk_as_string?(value, pk_size)
79
- value.is_a?(String) && value.count(ID_SEP) == pk_size - 1
80
- end
81
- end
82
- end
83
- private_constant :Utils
84
- end
85
-
86
- Array.send(:include, CompositePrimaryKeys::ArrayExtension)
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