Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/insert_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
def parameter_to_document(param):
return {'@type': 'Parameter', 'type': param.type, 'name': param.name, 'summary': "\n".join(param.desc)}

classes_to_scan = ['Client']#, 'WOQLQuery']
classes_to_scan = ['Client', 'WOQLQuery']
classes = []

for name, obj in inspect.getmembers(terminusdb_client):
Expand Down
2 changes: 1 addition & 1 deletion terminusdb_client/__version__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
__title__ = "terminusdb-client"
__description__ = "TerminusDB client library for accessing the Terminus DB API"
__url__ = ""
__version__ = "11.1.0"
__version__ = "12.0.5"
__build__ = 00
__author__ = "TerminusDB group"
__author_email__ = "team@terminusdb.com"
Expand Down
38 changes: 36 additions & 2 deletions terminusdb_client/client/Client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,8 @@ def insert_document(
last_data_version: Optional[str] = None,
compress: Union[str, int] = 1024,
raw_json: bool = False,
merge_repeats: bool = False,
overwrite: bool = False,
) -> None:
"""Inserts the specified document(s)

Expand All @@ -1499,6 +1501,10 @@ def insert_document(
If it is an integer, size of the data larger than this (in bytes) will be compress with gzip in the request (assume encoding as UTF-8, 0 = always compress). If it is `never` it will never compress the data.
raw_json : bool
Update as raw json
merge_repeats : bool
If True, duplicate document IDs in the same request are silently accepted instead of causing an error.
overwrite : bool
If True, existing documents with the same ID will be overwritten instead of causing an error. This enables upsert behavior.

Raises
------
Expand All @@ -1518,6 +1524,8 @@ def insert_document(
else:
params["full_replace"] = "false"
params["raw_json"] = "true" if raw_json else "false"
params["merge_repeats"] = "true" if merge_repeats else "false"
params["overwrite"] = "true" if overwrite else "false"

headers = self._default_headers.copy()
if last_data_version is not None:
Expand Down Expand Up @@ -1587,6 +1595,7 @@ def replace_document(
compress: Union[str, int] = 1024,
create: bool = False,
raw_json: bool = False,
merge_repeats: bool = False,
) -> dict:
"""Updates the specified document(s)

Expand All @@ -1606,6 +1615,8 @@ def replace_document(
Create the document if it does not yet exist.
raw_json : bool
Update as raw json
merge_repeats : bool
If True, duplicate document IDs in the same request are silently accepted instead of causing an error.

Raises
------
Expand All @@ -1617,6 +1628,7 @@ def replace_document(
params["graph_type"] = graph_type
params["create"] = "true" if create else "false"
params["raw_json"] = "true" if raw_json else "false"
params["merge_repeats"] = "true" if merge_repeats else "false"

headers = self._default_headers.copy()
if last_data_version is not None:
Expand Down Expand Up @@ -1667,6 +1679,7 @@ def update_document(
commit_msg: Optional[str] = None,
last_data_version: Optional[str] = None,
compress: Union[str, int] = 1024,
merge_repeats: bool = False,
) -> None:
"""Updates the specified document(s). Add the document if not existed.

Expand All @@ -1682,14 +1695,17 @@ def update_document(
Last version before the update, used to check if the document has been changed unknowingly
compress : str or int
If it is an integer, size of the data larger than this (in bytes) will be compress with gzip in the request (assume encoding as UTF-8, 0 = always compress). If it is `never` it will never compress the data.
merge_repeats : bool
If True, duplicate document IDs in the same request are silently accepted instead of causing an error.

Raises
------
InterfaceError
if the client does not connect to a database
"""
self.replace_document(
document, graph_type, commit_msg, last_data_version, compress, True
document, graph_type, commit_msg, last_data_version, compress, True,
merge_repeats=merge_repeats,
)

def delete_document(
Expand Down Expand Up @@ -2379,7 +2395,9 @@ def diff_object(self, before_object, after_object):
)
)

def diff_version(self, before_version, after_version):
def diff_version(
self, before_version, after_version, unfold: bool = True
):
"""Diff two different versions. Can either be a branch or a commit

Parameters
Expand All @@ -2388,6 +2406,10 @@ def diff_version(self, before_version, after_version):
Commit or branch of the before version to compare
after_version : string
Commit or branch of the after version to compare
unfold : bool, optional
Expand subdocuments inline when retrieving documents from the
specified versions. Defaults to ``True``; set to ``False`` to
compare folded documents by reference.
"""
self._check_connection(check_db=False)
return json.loads(
Expand All @@ -2398,6 +2420,7 @@ def diff_version(self, before_version, after_version):
json={
"before_data_version": before_version,
"after_data_version": after_version,
"unfold": unfold,
},
auth=self._auth(),
)
Expand All @@ -2423,13 +2446,21 @@ def diff(
List["DocumentTemplate"], # noqa:F821
],
document_id: Union[str, None] = None,
unfold: bool = True,
):
"""DEPRECATED

Perform diff on 2 set of document(s), result in a Patch object.

Do not connect when using public API.

Parameters
----------
unfold : bool, optional
Expand subdocuments inline when retrieving documents from commits
or branches. Only used when ``before`` or ``after`` is a version
reference. Defaults to ``True``.

Returns
-------
obj
Expand Down Expand Up @@ -2461,6 +2492,9 @@ def diff(
raise ValueError(
"`document_id` can only be used in conjusction with a data version or commit ID as `before`, not a document object"
)
if isinstance(before, str) or isinstance(after, str):
request_dict["unfold"] = unfold

if self._connected:
result = _finish_response(
self._session.post(
Expand Down
13 changes: 12 additions & 1 deletion terminusdb_client/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ class RandomKey(TerminusKey):
at_type = "Random"


class UuidV7Key(TerminusKey):
"""Generating ID with UUID v7 (RFC 9562); accepts any IRI as @id"""

at_type = "UuidV7"


def _check_cycling(class_obj: "TerminusClass"):
"""Helper function to check if the embedded subdocument is cycling"""
if hasattr(class_obj, "_subdocument"):
Expand Down Expand Up @@ -170,7 +176,7 @@ def __init__(cls, name, bases, nmspc):
if "_subdocument" in nmspc:
allow_custom_id = False
elif "_key" in nmspc:
if nmspc.get("_key").__class__ == RandomKey:
if nmspc.get("_key").__class__ in (RandomKey, UuidV7Key):
allow_custom_id = True
else:
allow_custom_id = False
Expand Down Expand Up @@ -306,6 +312,9 @@ def _id(self):
if hasattr(self, "_backend_id") and self._backend_id:
return self._backend_id
if hasattr(self, "_custom_id") and self._custom_id:
if self._key.__class__ == UuidV7Key:
# UuidV7 accepts any IRI as @id; do not prefix or quote it
return self._custom_id
return _check_and_fix_custom_id(str(self.__class__), self._custom_id)
else:
return None
Expand Down Expand Up @@ -595,6 +604,8 @@ def _construct_class(self, class_obj_dict):
key_type = value.get("@type")
if key_type and key_type == "Random":
attributedict["_key"] = RandomKey()
elif key_type and key_type == "UuidV7":
attributedict["_key"] = UuidV7Key()
elif key_type and key_type == "ValueHash":
attributedict["_key"] = ValueHashKey()
elif key_type and key_type == "Lexical":
Expand Down
17 changes: 17 additions & 0 deletions terminusdb_client/tests/test_schema_overall.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
LexicalKey,
ValueHashKey,
RandomKey,
UuidV7Key,
_check_cycling,
_check_mismatch_type,
_check_missing_prop,
Expand Down Expand Up @@ -53,6 +54,11 @@ def test_random_key_creation(self):
rk = RandomKey()
assert rk.at_type == "Random"

def test_uuid_v7_key_creation(self):
"""Test UuidV7Key creation"""
uk = UuidV7Key()
assert uk.at_type == "UuidV7"


class TestCheckCycling:
"""Test _check_cycling function"""
Expand Down Expand Up @@ -428,6 +434,17 @@ class Doc(DocumentTemplate):
with pytest.raises(ValueError, match="Customized id is not allowed"):
doc._id = "custom_id"

def test_id_setter_custom_id_allowed_uuid_v7(self):
"""Test _id setter allows custom id with UuidV7Key"""

class Doc(DocumentTemplate):
name: str
_key = UuidV7Key() # UuidV7 accepts arbitrary @id

doc = Doc(name="test")
doc._id = "https://linked.art/example/object/47"
assert doc._id == "https://linked.art/example/object/47"

def test_key_field_change_error(self):
"""Test ValueError when trying to change key field"""

Expand Down
131 changes: 131 additions & 0 deletions terminusdb_client/tests/test_woql_idgen_uuid_v7.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Unit tests for idgen_uuid_v7 WOQL method"""

from terminusdb_client.woqlquery.woql_query import WOQLQuery


class TestIdgenUuidV7:
"""Test idgen_uuid_v7 WOQL method"""

def test_idgen_uuid_v7_basic(self):
"""Test basic idgen_uuid_v7 functionality"""
woql = WOQLQuery().idgen_uuid_v7("Person/", "v:PersonID")
result = woql.to_dict()

assert result["@type"] == "UuidV7"
assert result["base"]["@type"] == "DataValue"
assert result["base"]["data"]["@type"] == "xsd:string"
assert result["base"]["data"]["@value"] == "Person/"
assert result["uri"]["@type"] == "NodeValue"
assert result["uri"]["variable"] == "PersonID"

def test_idgen_uuid_v7_with_prefix(self):
"""Test idgen_uuid_v7 with different prefix formats"""
woql = WOQLQuery().idgen_uuid_v7("http://example.org/Person/", "v:ID")
result = woql.to_dict()

assert result["base"]["data"]["@value"] == "http://example.org/Person/"
assert result["uri"]["variable"] == "ID"

def test_idgen_uuid_v7_chaining(self):
"""Test idgen_uuid_v7 can be chained with other operations"""
woql = (
WOQLQuery()
.triple("v:Person", "rdf:type", "@schema:Person")
.idgen_uuid_v7("Person/", "v:PersonID")
)

result = woql.to_dict()
assert result["@type"] == "And"
assert len(result["and"]) == 2
assert result["and"][0]["@type"] == "Triple"
assert result["and"][1]["@type"] == "UuidV7"

def test_idgen_uuid_v7_multiple_calls(self):
"""Test multiple idgen_uuid_v7 calls in same query"""
woql = (
WOQLQuery()
.idgen_uuid_v7("Person/", "v:PersonID")
.idgen_uuid_v7("Order/", "v:OrderID")
)

result = woql.to_dict()
assert result["@type"] == "And"
assert result["and"][0]["@type"] == "UuidV7"
assert result["and"][0]["base"]["data"]["@value"] == "Person/"
assert result["and"][1]["@type"] == "UuidV7"
assert result["and"][1]["base"]["data"]["@value"] == "Order/"

def test_idgen_uuid_v7_args_parameter(self):
"""Test idgen_uuid_v7 args parameter returns parameter list"""
result = WOQLQuery().idgen_uuid_v7("args", "v:ID")

assert result == ["base", "uri"]

def test_idgen_uuid_v7_empty_prefix(self):
"""Test idgen_uuid_v7 with empty prefix"""
woql = WOQLQuery().idgen_uuid_v7("", "v:ID")
result = woql.to_dict()

assert result["@type"] == "UuidV7"
assert result["base"]["data"]["@value"] == ""

def test_idgen_uuid_v7_variable_output(self):
"""Test idgen_uuid_v7 output variable format"""
woql = WOQLQuery().idgen_uuid_v7("Test/", "v:MyVar")
result = woql.to_dict()

assert result["uri"]["variable"] == "MyVar"

def test_idgen_uuid_v7_in_query_chain(self):
"""Test idgen_uuid_v7 in complex query chain"""
woql = (
WOQLQuery()
.triple("v:Person", "rdf:type", "@schema:Person")
.idgen_uuid_v7("Person/", "v:PersonID")
.triple("v:PersonID", "@schema:name", "v:Name")
)

result = woql.to_dict()
assert result["@type"] == "And"
# WOQLQuery chains create nested And structures
# Verify UuidV7 is present in the chain
has_uuid_v7 = False

def check_for_uuid_v7(obj):
nonlocal has_uuid_v7
if isinstance(obj, dict):
if obj.get("@type") == "UuidV7":
has_uuid_v7 = True
for value in obj.values():
check_for_uuid_v7(value)
elif isinstance(obj, list):
for item in obj:
check_for_uuid_v7(item)

check_for_uuid_v7(result)
assert has_uuid_v7, "UuidV7 should be present in query chain"

def test_idgen_uuid_v7_matches_expected_json(self):
"""Test idgen_uuid_v7 produces expected WOQL JSON structure"""
from terminusdb_client.tests.woqljson.woqlIdgenJson import (
WOQL_UUID_V7_KEY_JSON,
)

woql = WOQLQuery().idgen_uuid_v7("Person/", "v:Person_ID")
result = woql.to_dict()

assert result == WOQL_UUID_V7_KEY_JSON

def test_idgen_uuid_v7_with_special_characters_in_prefix(self):
"""Test idgen_uuid_v7 handles special characters in prefix"""
woql = WOQLQuery().idgen_uuid_v7("Test/2024-11/", "v:ID")
result = woql.to_dict()

assert result["base"]["data"]["@value"] == "Test/2024-11/"

def test_idgen_uuid_v7_preserves_cursor_state(self):
"""Test idgen_uuid_v7 returns self for method chaining"""
woql = WOQLQuery()
result = woql.idgen_uuid_v7("Test/", "v:ID")

assert result is woql # Should return same object for chaining
12 changes: 12 additions & 0 deletions terminusdb_client/tests/woqljson/woqlIdgenJson.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,15 @@
"variable": "Person_ID",
},
}

WOQL_UUID_V7_KEY_JSON = {
"@type": "UuidV7",
"base": {
"@type": "DataValue",
"data": {"@type": "xsd:string", "@value": "Person/"},
},
"uri": {
"@type": "NodeValue",
"variable": "Person_ID",
},
}
Loading
Loading