Skip to content

Commit f2b4ecb

Browse files
committed
feat: add hash generation for Resource and ResourceIdentifier
1 parent 73096db commit f2b4ecb

File tree

7 files changed

+107
-2
lines changed

7 files changed

+107
-2
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@ style:
22
black .
33
isort .
44
flake8 .
5+
6+
test:
7+
pytest tests
8+

jsonapi_pydantic/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
def _generate_resource_hash(resource_type: str, resource_id: str) -> int:
2+
hash_ = ""
3+
for ch in resource_type:
4+
hash_ += str(ord(ch))
5+
for ch in resource_id:
6+
hash_ += str(ord(ch))
7+
return int(hash_)
8+
9+
10+
__all__ = ["_generate_resource_hash"]

jsonapi_pydantic/v1_0/resource/resource.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from collections.abc import Hashable
2+
from random import getrandbits
13
from typing import Any, Dict, Optional
24

35
from pydantic.config import ConfigDict
46
from pydantic.fields import Field
57
from pydantic.functional_validators import model_validator
68
from pydantic.main import BaseModel
79

10+
from jsonapi_pydantic.utils import _generate_resource_hash
811
from jsonapi_pydantic.v1_0.links import Links as LinksObject
912
from jsonapi_pydantic.v1_0.meta import Meta as MetaObject
1013
from jsonapi_pydantic.v1_0.resource.relationship import Relationship
@@ -16,7 +19,7 @@
1619
Meta = Optional[MetaObject]
1720

1821

19-
class Resource(BaseModel):
22+
class Resource(BaseModel, Hashable):
2023
type: str = Field(title="Type")
2124
id: Id = Field(None, title="Id")
2225
attributes: Attributes = Field(None, title="Attributes")
@@ -26,6 +29,21 @@ class Resource(BaseModel):
2629

2730
model_config = ConfigDict(frozen=True)
2831

32+
_hash: int
33+
34+
def __init__(self, **data):
35+
super().__init__(**data)
36+
if self.id:
37+
self._hash = _generate_resource_hash(resource_type=self.type, resource_id=self.id)
38+
else:
39+
self._hash = getrandbits(64)
40+
41+
def __eq__(self, __value: object) -> bool:
42+
return self._hash == getattr(__value, "_hash", None)
43+
44+
def __hash__(self) -> int:
45+
return self._hash
46+
2947
@model_validator(mode="after")
3048
def check_all_values(self) -> "Resource":
3149
if not self.attributes and not self.relationships:
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
1+
from collections.abc import Hashable
12
from typing import Optional
23

34
from pydantic.config import ConfigDict
45
from pydantic.fields import Field
56
from pydantic.main import BaseModel
67

8+
from jsonapi_pydantic.utils import _generate_resource_hash
79
from jsonapi_pydantic.v1_0.meta import Meta as MetaObject
810

911
Meta = Optional[MetaObject]
1012

1113

12-
class ResourceIdentifier(BaseModel):
14+
class ResourceIdentifier(BaseModel, Hashable):
1315
id: str = Field(title="Id")
1416
type: str = Field(title="Type")
1517
meta: Meta = Field(None, title="Meta")
1618

1719
model_config = ConfigDict(frozen=True)
1820

21+
_hash: int
22+
23+
def __init__(self, **data):
24+
super().__init__(**data)
25+
self._hash = _generate_resource_hash(resource_type=self.type, resource_id=self.id)
26+
27+
def __eq__(self, __value: object) -> bool:
28+
return self._hash == getattr(__value, "_hash", None)
29+
30+
def __hash__(self) -> int:
31+
return self._hash
32+
1933

2034
__all__ = ["ResourceIdentifier"]

jsonapi_pydantic/v1_0/toplevel.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,24 @@ def check_all_values(cls, data: dict) -> dict:
4040
# More about these restrictions: https://jsonapi.org/format/#document-top-level
4141
if "data" in data and "errors" in data:
4242
raise ValueError("The members data and errors MUST NOT coexist in the same document.")
43+
4344
if "included" in data and "data" not in data:
4445
raise ValueError(
4546
"If a document does not contain a top-level data key, the included member MUST NOT be present either."
4647
)
48+
4749
if "data" not in data and "errors" not in data and "meta" not in data:
4850
raise ValueError(
4951
"A document MUST contain at least one of the following top-level members: data, errors, meta."
5052
)
53+
54+
if isinstance(data.get("data"), list):
55+
hashes = {d._hash for d in data["data"]}
56+
if len(data["data"]) > len(hashes):
57+
raise ValueError(
58+
"A compound document MUST NOT include more than one resource object for each type and id pair."
59+
)
60+
5161
return data
5262

5363

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pydantic = "^2.1.1"
3636
black = "^23.12.0"
3737
isort = "^5.13.2"
3838
flake8 = "^6.1.0"
39+
pytest = "^8.1.1"
3940

4041
[tool.isort]
4142
line_length = 100

tests/v1_0/toplevel_test.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from jsonapi_pydantic.v1_0 import Resource, ResourceIdentifier, TopLevel
5+
6+
7+
def test_toplevel_unique_resource():
8+
with pytest.raises(
9+
ValidationError,
10+
match="A compound document MUST NOT include more than one resource object for each type and id pair.",
11+
):
12+
TopLevel(
13+
data=[
14+
Resource(
15+
type="articles",
16+
id="3",
17+
attributes={
18+
"title": "JSON:API paints my bikeshed!",
19+
"body": "The shortest article. Ever.",
20+
"created": "2015-05-22T14:56:29.000Z",
21+
"updated": "2015-05-22T14:56:28.000Z",
22+
},
23+
),
24+
Resource(
25+
type="articles",
26+
id="3",
27+
attributes={
28+
"title": "JSON:API paints my bikeshed!",
29+
"body": "The shortest article. Ever.",
30+
"created": "2015-05-22T14:56:29.000Z",
31+
"updated": "2015-05-22T14:56:28.000Z",
32+
},
33+
),
34+
]
35+
)
36+
37+
38+
def test_toplevel_unique_resource_identifier():
39+
with pytest.raises(
40+
ValidationError,
41+
match="A compound document MUST NOT include more than one resource object for each type and id pair.",
42+
):
43+
TopLevel(
44+
data=[
45+
ResourceIdentifier(type="articles", id="3"),
46+
ResourceIdentifier(type="articles", id="3"),
47+
]
48+
)

0 commit comments

Comments
 (0)