Allow for composite identifiers delimited by /#163
Conversation
rafaelfranca
left a comment
There was a problem hiding this comment.
I feel like this is doing way much than it should. A lot of validations and I don't know what we are trying to protect against.
Do we really need those validations?
lib/global_id/global_id.rb
Outdated
|
|
||
| def initialize(gid, options = {}) | ||
| @uri = gid.is_a?(URI::GID) ? gid : URI::GID.parse(gid) | ||
| validate_model_id |
There was a problem hiding this comment.
Why we need this validation? What are we trying to protect?
There was a problem hiding this comment.
We are trying to prevent someone manually crafting a GID like gid://app/Person/1/2/3/4/5.... which will be parsed as [1,2,3,4,5] and passed to find() which may lead to more than one record being loaded.
As an alternative we can move the check to the locator but then custom locators won't be protected. Which may not be a bad thing
Let me know if you'd like to completely loosen the restriction or locator would be a better place to ensure we only target single object when calling find
There was a problem hiding this comment.
GlobalID is primarily used as a serialization format. Someone will not manually be creating GIDs. If people are using GlobalID not a serialization format, then they will be responsible for checking if their input is correct.
I think this is better handled at the locator level.
There was a problem hiding this comment.
Done! Moved the validation to the locator level. Instead of raising it now returns nil as this seems to be locator's interface when it comes to locating records by a malformed gid
test/cases/global_id_test.rb
Outdated
| assert_nil GlobalID.parse('gid://app/Person/1/2') | ||
| assert_nil GlobalID.parse('gid://app/CompositePrimaryKeyModel/tenant-key-value/id-value/something_else') | ||
| assert_nil GlobalID.parse('gid://app/CompositePrimaryKeyModel/tenant-key-value/') | ||
| assert_nil GlobalID.parse('gid://app/CompositePrimaryKeyModel/tenant-key-value') |
There was a problem hiding this comment.
I think we are protecting too much. All those glogal ids should be valid. If they can't be located, isn't a job of the parser to find out.
There was a problem hiding this comment.
isn't a job of the parser to find out.
I asked in the comment above but would you prefer Locator to be the one to check that identifier size matches model's primary key to avoid loading more records than necessary?
63a5ab4 to
81fbe0d
Compare
|
What do you think about moving Global ID to use The negative point is that Global ID was really built to work with Active Record rather than Active Model (which defines the I guess the class also needed to implement Test script showing current API# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "activemodel"
gem "globalid"
end
require "active_model"
require "global_id"
require "minitest/autorun"
GlobalID.app = "my-app"
class Model
include ActiveModel::Model
attr_accessor :key
include GlobalID::Identification
alias_method :id, :key
@models = {}
def self.create(key:)
@models[key] = new(key:)
end
def self.clear
@models.clear
end
def self.find(key)
if key.is_a?(Array)
key.map { |key| find(key) }
else
@models.fetch(key)
end
end
def self.where(id:)
if id.is_a?(Array)
id.flat_map { |key| where(id: key) }
else
Array(@models[id])
end
end
end
class ModelGIDTest < Minitest::Test
def test_to_global_id
assert_equal "gid://my-app/Model/foo", Model.new(key: "foo").to_global_id.to_s
end
def test_locate
assert_raises { GlobalID::Locator.locate("gid://my-app/Model/foo") }
model = Model.create(key: "foo")
assert_equal model, GlobalID::Locator.locate("gid://my-app/Model/foo")
ensure
Model.clear
end
def test_locate_many
assert_raises do
GlobalID::Locator.locate_many(["gid://my-app/Model/foo", "gid://my-app/Model/bar"])
end
foo = Model.create(key: "foo")
assert_raises do
GlobalID::Locator.locate_many(["gid://my-app/Model/foo", "gid://my-app/Model/bar"])
end
assert_equal [foo], GlobalID::Locator.locate_many(["gid://my-app/Model/foo", "gid://my-app/Model/bar"], ignore_missing: true)
bar = Model.create(key: "bar")
assert_equal [foo, bar], GlobalID::Locator.locate_many(["gid://my-app/Model/foo", "gid://my-app/Model/bar"])
ensure
Model.clear
end
endDiff for the test script to support this branch's APIdiff --git i/test_gid.rb w/test_gid.rb
index 91730c79cc..0db53a1f42 100644
--- i/test_gid.rb
+++ w/test_gid.rb
@@ -6,7 +6,7 @@
source "https://rubygems.org"
gem "activemodel"
- gem "globalid"
+ gem "globalid", github: "https://github.com/rails/globalid/pull/163"
end
require "active_model"
@@ -39,13 +39,17 @@ def self.find(key)
end
end
- def self.where(id:)
- if id.is_a?(Array)
- id.flat_map { |key| where(id: key) }
+ def self.where(key:)
+ if key.is_a?(Array)
+ key.flat_map { |key| where(key: key) }
else
- Array(@models[id])
+ Array(@models[key])
end
end
+
+ def self.primary_key
+ :key
+ end
end
class ModelGIDTest < Minitest::Test |
5cf6818 to
7acd2a4
Compare
|
Hey @etiennebarrie , great question! IIRC @nvasilevski and I discussed using That said, it's somewhat of a moot point given that, as you mentioned, Active Records inherently define EDIT: Okay, so a blocker to using globalid/lib/global_id/locator.rb Lines 168 to 173 in 7acd2a4 Since we don't have an instance here, we can't use Probably best to stick with |
This commit extends global id to allow representing models with composite identifiers. The value will be joined by `/`. For example: Given a `TravelRoute` model with `origin` and `destination` as the compsoite primary key, the global id will be represented as: ``` TravelRoute.new(origin: "Ottawa", destination: "New York").to_global_id => gid://app/TravelRoute/Ottawa/New%20York ``` Co-authored-by: Adrianna Chang <adrianna.chang@shopify.com> Co-authored-by: Nikita Vasilevsky <nikita.vasilevsky@shopify.com>
7acd2a4 to
5ce154c
Compare
|
cc @byroot @rafaelfranca could I get y'all to take another look at this? Thanks! |
| end | ||
| end | ||
|
|
||
| private |
| primary_key = Array(gid.model_class.primary_key) | ||
| primary_key_size = primary_key.size | ||
|
|
||
| Array(gid.model_id).size == primary_key_size |
There was a problem hiding this comment.
| primary_key = Array(gid.model_class.primary_key) | |
| primary_key_size = primary_key.size | |
| Array(gid.model_id).size == primary_key_size | |
| Array(gid.model_id).size == Array(gid.model_class.primary_key).size |
| ids_by_model.each do |model, ids| | ||
|
|
There was a problem hiding this comment.
| ids_by_model.each do |model, ids| | |
| ids_by_model.each do |model, ids| |
|
hmm, those are small thins I can fix myself. Merging. |
|
FYI #168 |
Broken CI is due to rails/globalid#163 in globalid 1.2.0. https://buildkite.com/rails/rails/builds/99329#018a5f01-a966-4424-9596-0a7f1deeb1ff/1178-1190
I won't debate its fairness, but that expectation isn't conveyed well. The README says "Mix GlobalID::Identification into any model with a #find(id) class method. Support is automatically included in Active Record." We didn't interpret that to mean that we needed to define
That's probably true, but please don't forget about the few models that aren't coming from ActiveRecord. Documentation about the API that is required and treating that required API spec as part of the public API for globalid would be very much appreciated. |
This commit extends global id to allow representing models with composite identifiers. The value will be joined by
/. For example:Given a
TravelRoutemodel withoriginanddestinationas the compsoite primary key, the global id will be represented as:Context
Next version of Rails will support models with a composite primary key and globalid need to provide capabilities to present such models. One of the major use-cases is the Active Record objects serialization in Active Job jobs
Major changes
primary_keyclass methodChoosing the delimiter
We end up choosing
/as a delimiter since it has the least change of conflicting with one of the values of the composite primary key. However it leads to slightly changed semantic when it comes to splitting the segments of the gidFor example a broken URI like
'gid://app/alsoapp/Person/12'used to considered invalid due to malformed app name. But now the same URI will be parsed as:Basically this is the only concern with the
/being used as a delimiterAs an alternative we were considering
_to serve as a delimiter but since the delimiter value is going to be hardcoded we decided against it as it has a higher chance of conflicting with the actual value of the composite keyWhat reviewers should be focusing on
a global id like
'gid://app/Person/1/2'used to be considered invalid. After the change it will be possible to parse and initialize anURI::GIDinstance and1/2will be parsed as['1','2']composite key however it won't be possible to look up records usingGlobalIDinstance created from suchURI::GIDTo prevent malicious actors from crafting gids with unrealistically long composite keys we have limited the maximum size of the composite key by
20This has been done to prevent issues like GHSA-23c2-gwp5-pxw9
Integration test
Here is a script that tests changes in integration with Active Job
Expand