Skip to content

Commit 224e127

Browse files
authored
Merge pull request #163 from Shopify/ac-gid-cpk
Allow for composite identifiers delimited by `/`
2 parents 5c68d15 + 5ce154c commit 224e127

10 files changed

Lines changed: 234 additions & 22 deletions

File tree

lib/global_id/global_id.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ def find(options = {})
5050
end
5151

5252
def model_class
53-
model = model_name.constantize
53+
@model_class ||= begin
54+
model = model_name.constantize
5455

55-
unless model <= GlobalID
56+
if model <= GlobalID
57+
raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
58+
end
5659
model
57-
else
58-
raise ArgumentError, "GlobalID and SignedGlobalID cannot be used as model_class."
5960
end
6061
end
6162

lib/global_id/locator.rb

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
class GlobalID
44
module Locator
5+
class InvalidModelIdError < StandardError; end
6+
57
class << self
68
# Takes either a GlobalID or a string that can be turned into a GlobalID
79
#
@@ -126,27 +128,49 @@ def normalize_app(app)
126128

127129
class BaseLocator
128130
def locate(gid)
131+
return unless model_id_is_valid?(gid)
129132
gid.model_class.find gid.model_id
130133
end
131134

132135
def locate_many(gids, options = {})
133-
models_and_ids = gids.collect { |gid| [ gid.model_class, gid.model_id ] }
134-
ids_by_model = models_and_ids.group_by(&:first)
135-
loaded_by_model = Hash[ids_by_model.map { |model, ids|
136-
[ model, find_records(model, ids.map(&:last), ignore_missing: options[:ignore_missing]).index_by { |record| record.id.to_s } ]
137-
}]
136+
ids_by_model = Hash.new { |hash, key| hash[key] = [] }
137+
138+
gids.each do |gid|
139+
next unless model_id_is_valid?(gid)
140+
ids_by_model[gid.model_class] << gid.model_id
141+
end
142+
143+
records_by_model_name_and_id = {}
144+
ids_by_model.each do |model, ids|
145+
146+
records = find_records(model, ids, ignore_missing: options[:ignore_missing])
147+
148+
records_by_id = records.index_by do |record|
149+
record.id.is_a?(Array) ? record.id.map(&:to_s) : record.id.to_s
150+
end
151+
152+
records_by_model_name_and_id[model.name] = records_by_id
153+
end
138154

139-
models_and_ids.collect { |(model, id)| loaded_by_model[model][id] }.compact
155+
gids.filter_map { |gid| records_by_model_name_and_id[gid.model_name][gid.model_id] }
140156
end
141157

142158
private
143159
def find_records(model_class, ids, options)
144160
if options[:ignore_missing]
145-
model_class.where(id: ids)
161+
model_class.where(model_class.primary_key => ids)
146162
else
147163
model_class.find(ids)
148164
end
149165
end
166+
167+
private
168+
def model_id_is_valid?(gid)
169+
primary_key = Array(gid.model_class.primary_key)
170+
primary_key_size = primary_key.size
171+
172+
Array(gid.model_id).size == primary_key_size
173+
end
150174
end
151175

152176
class UnscopedLocator < BaseLocator

lib/global_id/uri/gid.rb

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ class GID < Generic
3030

3131
# Raised when creating a Global ID for a model without an id
3232
class MissingModelIdError < URI::InvalidComponentError; end
33+
class InvalidModelIdError < URI::InvalidComponentError; end
34+
35+
# Maximum size of a model id segment
36+
COMPOSITE_MODEL_ID_MAX_SIZE = 20
37+
COMPOSITE_MODEL_ID_DELIMITER = "/"
3338

3439
class << self
3540
# Validates +app+'s as URI hostnames containing only alphanumeric characters
@@ -83,7 +88,8 @@ def create(app, model, params = nil)
8388
def build(args)
8489
parts = Util.make_components_hash(self, args)
8590
parts[:host] = parts[:app]
86-
parts[:path] = "/#{parts[:model_name]}/#{CGI.escape(parts[:model_id].to_s)}"
91+
model_id_segment = Array(parts[:model_id]).map { |p| CGI.escape(p.to_s) }.join(COMPOSITE_MODEL_ID_DELIMITER)
92+
parts[:path] = "/#{parts[:model_name]}/#{model_id_segment}"
8793

8894
if parts[:params] && !parts[:params].empty?
8995
parts[:query] = URI.encode_www_form(parts[:params])
@@ -147,12 +153,22 @@ def check_scheme(scheme)
147153

148154
def set_model_components(path, validate = false)
149155
_, model_name, model_id = path.split('/', 3)
150-
validate_component(model_name) && validate_model_id(model_id, model_name) if validate
151-
152-
model_id = CGI.unescape(model_id) if model_id
153156

157+
validate_component(model_name) && validate_model_id_section(model_id, model_name) if validate
154158
@model_name = model_name
155-
@model_id = model_id
159+
160+
if model_id
161+
model_id_parts = model_id
162+
.split(COMPOSITE_MODEL_ID_DELIMITER, COMPOSITE_MODEL_ID_MAX_SIZE)
163+
.reject(&:blank?)
164+
165+
model_id_parts.map! do |id|
166+
validate_model_id(id)
167+
CGI.unescape(id)
168+
end
169+
170+
@model_id = model_id_parts.length == 1 ? model_id_parts.first : model_id_parts
171+
end
156172
end
157173

158174
def validate_component(component)
@@ -162,13 +178,20 @@ def validate_component(component)
162178
"Expected a URI like gid://app/Person/1234: #{inspect}"
163179
end
164180

165-
def validate_model_id(model_id, model_name)
166-
return model_id unless model_id.blank? || model_id.include?('/')
181+
def validate_model_id_section(model_id, model_name)
182+
return model_id unless model_id.blank?
167183

168184
raise MissingModelIdError, "Unable to create a Global ID for " \
169185
"#{model_name} without a model id."
170186
end
171187

188+
def validate_model_id(model_id_part)
189+
return unless model_id_part.include?('/')
190+
191+
raise InvalidModelIdError, "Unable to create a Global ID for " \
192+
"#{model_name} with a malformed model id."
193+
end
194+
172195
def parse_query_params(query)
173196
Hash[URI.decode_www_form(query)].with_indifferent_access if query
174197
end

test/cases/global_id_test.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
class GlobalIDTest < ActiveSupport::TestCase
44
test 'value equality' do
5-
assert_equal GlobalID.new('gid://app/model/id'), GlobalID.new('gid://app/model/id')
5+
assert_equal GlobalID.new('gid://app/Person/5'), GlobalID.new('gid://app/Person/5')
66
end
77

88
test 'invalid app name' do
@@ -44,26 +44,30 @@ class GlobalIDCreationTest < ActiveSupport::TestCase
4444
@person_uuid_gid = GlobalID.create(Person.new(@uuid))
4545
@person_namespaced_gid = GlobalID.create(Person::Child.new(4))
4646
@person_model_gid = GlobalID.create(PersonModel.new(id: 1))
47+
@cpk_model_gid = GlobalID.create(CompositePrimaryKeyModel.new(id: ["tenant-key-value", "id-value"]))
4748
end
4849

4950
test 'find' do
5051
assert_equal Person.find(@person_gid.model_id), @person_gid.find
5152
assert_equal Person.find(@person_uuid_gid.model_id), @person_uuid_gid.find
5253
assert_equal Person::Child.find(@person_namespaced_gid.model_id), @person_namespaced_gid.find
5354
assert_equal PersonModel.find(@person_model_gid.model_id), @person_model_gid.find
55+
assert_equal CompositePrimaryKeyModel.find(@cpk_model_gid.model_id), @cpk_model_gid.find
5456
end
5557

5658
test 'find with class' do
5759
assert_equal Person.find(@person_gid.model_id), @person_gid.find(only: Person)
5860
assert_equal Person.find(@person_uuid_gid.model_id), @person_uuid_gid.find(only: Person)
5961
assert_equal PersonModel.find(@person_model_gid.model_id), @person_model_gid.find(only: PersonModel)
62+
assert_equal CompositePrimaryKeyModel.find(@cpk_model_gid.model_id), @cpk_model_gid.find(only: CompositePrimaryKeyModel)
6063
end
6164

6265
test 'find with class no match' do
6366
assert_nil @person_gid.find(only: Hash)
6467
assert_nil @person_uuid_gid.find(only: Array)
6568
assert_nil @person_namespaced_gid.find(only: String)
6669
assert_nil @person_model_gid.find(only: Float)
70+
assert_nil @cpk_model_gid.find(only: Hash)
6771
end
6872

6973
test 'find with subclass' do
@@ -135,6 +139,7 @@ class GlobalIDCreationTest < ActiveSupport::TestCase
135139
assert_equal "gid://bcx/Person/#{@uuid}", @person_uuid_gid.to_s
136140
assert_equal 'gid://bcx/Person::Child/4', @person_namespaced_gid.to_s
137141
assert_equal 'gid://bcx/PersonModel/1', @person_model_gid.to_s
142+
assert_equal 'gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value', @cpk_model_gid.to_s
138143
end
139144

140145
test 'as param' do
@@ -149,13 +154,18 @@ class GlobalIDCreationTest < ActiveSupport::TestCase
149154

150155
assert_equal 'Z2lkOi8vYmN4L1BlcnNvbk1vZGVsLzE', @person_model_gid.to_param
151156
assert_equal @person_model_gid, GlobalID.parse('Z2lkOi8vYmN4L1BlcnNvbk1vZGVsLzE')
157+
158+
expected_encoded = 'Z2lkOi8vYmN4L0NvbXBvc2l0ZVByaW1hcnlLZXlNb2RlbC90ZW5hbnQta2V5LXZhbHVlL2lkLXZhbHVl'
159+
assert_equal expected_encoded, @cpk_model_gid.to_param
160+
assert_equal @cpk_model_gid, GlobalID.parse(expected_encoded)
152161
end
153162

154163
test 'as URI' do
155164
assert_equal URI('gid://bcx/Person/5'), @person_gid.uri
156165
assert_equal URI("gid://bcx/Person/#{@uuid}"), @person_uuid_gid.uri
157166
assert_equal URI('gid://bcx/Person::Child/4'), @person_namespaced_gid.uri
158167
assert_equal URI('gid://bcx/PersonModel/1'), @person_model_gid.uri
168+
assert_equal URI('gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value'), @cpk_model_gid.uri
159169
end
160170

161171
test 'as JSON' do
@@ -170,27 +180,33 @@ class GlobalIDCreationTest < ActiveSupport::TestCase
170180

171181
assert_equal 'gid://bcx/PersonModel/1', @person_model_gid.as_json
172182
assert_equal '"gid://bcx/PersonModel/1"', @person_model_gid.to_json
183+
184+
assert_equal 'gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value', @cpk_model_gid.as_json
185+
assert_equal '"gid://bcx/CompositePrimaryKeyModel/tenant-key-value/id-value"', @cpk_model_gid.to_json
173186
end
174187

175188
test 'model id' do
176189
assert_equal '5', @person_gid.model_id
177190
assert_equal @uuid, @person_uuid_gid.model_id
178191
assert_equal '4', @person_namespaced_gid.model_id
179192
assert_equal '1', @person_model_gid.model_id
193+
assert_equal ['tenant-key-value', 'id-value'], @cpk_model_gid.model_id
180194
end
181195

182196
test 'model name' do
183197
assert_equal 'Person', @person_gid.model_name
184198
assert_equal 'Person', @person_uuid_gid.model_name
185199
assert_equal 'Person::Child', @person_namespaced_gid.model_name
186200
assert_equal 'PersonModel', @person_model_gid.model_name
201+
assert_equal 'CompositePrimaryKeyModel', @cpk_model_gid.model_name
187202
end
188203

189204
test 'model class' do
190205
assert_equal Person, @person_gid.model_class
191206
assert_equal Person, @person_uuid_gid.model_class
192207
assert_equal Person::Child, @person_namespaced_gid.model_class
193208
assert_equal PersonModel, @person_model_gid.model_class
209+
assert_equal CompositePrimaryKeyModel, @cpk_model_gid.model_class
194210
assert_raise ArgumentError do
195211
GlobalID.find 'gid://bcx/SignedGlobalID/5'
196212
end

test/cases/global_locator_test.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ class GlobalLocatorTest < ActiveSupport::TestCase
55
model = Person.new('id')
66
@gid = model.to_gid
77
@sgid = model.to_sgid
8+
@cpk_model = CompositePrimaryKeyModel.new(id: ["tenant-key-value", "id-value"])
9+
@cpk_gid = @cpk_model.to_gid
10+
@cpk_sgid = @cpk_model.to_sgid
811
end
912

1013
test 'by GID' do
@@ -13,6 +16,12 @@ class GlobalLocatorTest < ActiveSupport::TestCase
1316
assert_equal @gid.model_id, found.id
1417
end
1518

19+
test 'composite primary key model by GID' do
20+
found = GlobalID::Locator.locate(@cpk_gid)
21+
assert_kind_of @cpk_gid.model_class, found
22+
assert_equal ["tenant-key-value", "id-value"], found.id
23+
end
24+
1625
test 'by GID with only: restriction with match' do
1726
found = GlobalID::Locator.locate(@gid, only: Person)
1827
assert_kind_of @gid.model_class, found
@@ -60,6 +69,18 @@ class GlobalLocatorTest < ActiveSupport::TestCase
6069
GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person.new('2').to_gid ])
6170
end
6271

72+
test '#locate_many by composite primary key GIDs of the same class' do
73+
records = [ @cpk_model, CompositePrimaryKeyModel.new(id: ["tenant-key-value2", "id-value2"]) ]
74+
located = GlobalID::Locator.locate_many(records.map(&:to_gid))
75+
assert_equal records, located
76+
end
77+
78+
test '#locate_many by composite primary key GIDs of different classes' do
79+
records = [ @cpk_model, Person.new('1') ]
80+
located = GlobalID::Locator.locate_many(records.map(&:to_gid))
81+
assert_equal records, located
82+
end
83+
6384
test 'by many GIDs of mixed classes' do
6485
assert_equal [ Person.new('1'), Person::Child.new('1'), Person.new('2') ],
6586
GlobalID::Locator.locate_many([ Person.new('1').to_gid, Person::Child.new('1').to_gid, Person.new('2').to_gid ])
@@ -77,6 +98,12 @@ class GlobalLocatorTest < ActiveSupport::TestCase
7798
assert_equal @sgid.model_id, found.id
7899
end
79100

101+
test 'by SGID of a composite primary key model' do
102+
found = GlobalID::Locator.locate_signed(@cpk_sgid)
103+
assert_kind_of @cpk_sgid.model_class, found
104+
assert_equal @cpk_sgid.model_id, found.id
105+
end
106+
80107
test 'by SGID with only: restriction with match' do
81108
found = GlobalID::Locator.locate_signed(@sgid, only: Person)
82109
assert_kind_of @sgid.model_class, found
@@ -124,11 +151,23 @@ class GlobalLocatorTest < ActiveSupport::TestCase
124151
GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person.new('2').to_sgid ])
125152
end
126153

154+
test 'by many SGIDs of the same composite primary key class' do
155+
records = [ @cpk_model, CompositePrimaryKeyModel.new(id: ["tenant-key-value2", "id-value2"]) ]
156+
located = GlobalID::Locator.locate_many_signed(records.map(&:to_sgid))
157+
assert_equal records, located
158+
end
159+
127160
test 'by many SGIDs of mixed classes' do
128161
assert_equal [ Person.new('1'), Person::Child.new('1'), Person.new('2') ],
129162
GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person::Child.new('1').to_sgid, Person.new('2').to_sgid ])
130163
end
131164

165+
test 'by many SGIDs of composite primary key model mixed with other models' do
166+
records = [ @cpk_model, Person.new('1') ]
167+
located = GlobalID::Locator.locate_many_signed(records.map(&:to_sgid))
168+
assert_equal records, located
169+
end
170+
132171
test 'by many SGIDs with only: restriction to match subclass' do
133172
assert_equal [ Person::Child.new('1') ],
134173
GlobalID::Locator.locate_many_signed([ Person.new('1').to_sgid, Person::Child.new('1').to_sgid, Person.new('2').to_sgid ], only: Person::Child)
@@ -157,6 +196,12 @@ class GlobalLocatorTest < ActiveSupport::TestCase
157196
assert_equal @gid.model_id, found.id
158197
end
159198

199+
test 'by to_param encoding for a composite primary key model' do
200+
found = GlobalID::Locator.locate(@cpk_gid.to_param)
201+
assert_kind_of @cpk_gid.model_class, found
202+
assert_equal @cpk_gid.model_id, found.id
203+
end
204+
160205
test 'by non-GID returns nil' do
161206
assert_nil GlobalID::Locator.locate 'This is not a GID'
162207
end
@@ -172,6 +217,13 @@ class GlobalLocatorTest < ActiveSupport::TestCase
172217
assert_nil GlobalID::Locator.locate 'gid://app/Person/1/2'
173218
end
174219

220+
test 'locating by a GID URI with a mismatching model_id returns nil' do
221+
assert_nil GlobalID::Locator.locate 'gid://app/Person/1/2'
222+
assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value/id-value/something_else'
223+
assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value/'
224+
assert_nil GlobalID::Locator.locate 'gid://app/CompositePrimaryKeyModel/tenant-key-value'
225+
end
226+
175227
test 'use locator with block' do
176228
GlobalID::Locator.use :foo do |gid|
177229
:foo

0 commit comments

Comments
 (0)