diff --git a/.github/insert_docs.py b/.github/insert_docs.py index 413e1f37..fcca7c55 100644 --- a/.github/insert_docs.py +++ b/.github/insert_docs.py @@ -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): diff --git a/terminusdb_client/__version__.py b/terminusdb_client/__version__.py index b9c4f68a..c42dda10 100644 --- a/terminusdb_client/__version__.py +++ b/terminusdb_client/__version__.py @@ -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" diff --git a/terminusdb_client/client/Client.py b/terminusdb_client/client/Client.py index 72c5d8fc..0281b072 100644 --- a/terminusdb_client/client/Client.py +++ b/terminusdb_client/client/Client.py @@ -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) @@ -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 ------ @@ -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: @@ -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) @@ -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 ------ @@ -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: @@ -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. @@ -1682,6 +1695,8 @@ 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 ------ @@ -1689,7 +1704,8 @@ def update_document( 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( @@ -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 @@ -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( @@ -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(), ) @@ -2423,6 +2446,7 @@ def diff( List["DocumentTemplate"], # noqa:F821 ], document_id: Union[str, None] = None, + unfold: bool = True, ): """DEPRECATED @@ -2430,6 +2454,13 @@ def diff( 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 @@ -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( diff --git a/terminusdb_client/schema/schema.py b/terminusdb_client/schema/schema.py index 78ec445b..6218b17e 100644 --- a/terminusdb_client/schema/schema.py +++ b/terminusdb_client/schema/schema.py @@ -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"): @@ -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 @@ -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 @@ -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": diff --git a/terminusdb_client/tests/test_schema_overall.py b/terminusdb_client/tests/test_schema_overall.py index b2db6303..5cfd901c 100644 --- a/terminusdb_client/tests/test_schema_overall.py +++ b/terminusdb_client/tests/test_schema_overall.py @@ -11,6 +11,7 @@ LexicalKey, ValueHashKey, RandomKey, + UuidV7Key, _check_cycling, _check_mismatch_type, _check_missing_prop, @@ -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""" @@ -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""" diff --git a/terminusdb_client/tests/test_woql_idgen_uuid_v7.py b/terminusdb_client/tests/test_woql_idgen_uuid_v7.py new file mode 100644 index 00000000..88ebceb9 --- /dev/null +++ b/terminusdb_client/tests/test_woql_idgen_uuid_v7.py @@ -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 diff --git a/terminusdb_client/tests/woqljson/woqlIdgenJson.py b/terminusdb_client/tests/woqljson/woqlIdgenJson.py index 5a09ecb8..81be7a8f 100644 --- a/terminusdb_client/tests/woqljson/woqlIdgenJson.py +++ b/terminusdb_client/tests/woqljson/woqlIdgenJson.py @@ -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", + }, +} diff --git a/terminusdb_client/woqlquery/woql_query.py b/terminusdb_client/woqlquery/woql_query.py index 8aed3e67..d9b93bc7 100644 --- a/terminusdb_client/woqlquery/woql_query.py +++ b/terminusdb_client/woqlquery/woql_query.py @@ -3167,6 +3167,40 @@ def random_idgen(self, prefix, uri): """ return self.idgen_random(prefix, uri) + def idgen_uuid_v7(self, prefix, uri): + """Generates a unique ID with a UUID v7 (RFC 9562) suffix. + + UUID v7 identifiers are time-ordered, which works well with the + front-loaded encoding of TerminusDB. Matches the server's + idgen_uuid_v7/2 predicate and maintains consistency with the + JavaScript client. + + Parameters + ---------- + prefix : str + A prefix for the IDs to be generated (e.g. "Person/") + uri : str + Variable name or output target for the generated ID + + Returns + ------- + WOQLQuery object + query object that can be chained and/or executed + + Examples + ------- + >>> WOQLQuery().idgen_uuid_v7("Person/", "v:person_id").execute(client) + {'@type': 'api:WoqlResponse', 'api:status': 'api:success', 'api:variable_names': ['person_id'], 'bindings': [{'person_id': 'Person/01a120f0-47a7-7f20-9295-b3034d7fc5d3'}], 'deletes': 0, 'inserts': 0, 'transaction_retry_count': 0} + """ + if prefix and prefix == "args": + return ["base", "uri"] + if self._cursor.get("@type"): + self._wrap_cursor_with_and() + self._cursor["@type"] = "UuidV7" + self._cursor["base"] = self._clean_data_value(prefix) + self._cursor["uri"] = self._clean_node_value(uri) + return self + def upper(self, left, right): """Changes a string to upper-case - input is in left, output in right