diff --git a/README.md b/README.md index 9b378b1..e7f4859 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Key dataclasses live in `libs/envector/config.py`: - Client-side filtering requires the JSON envelope to include an object under `metadata`. ## Limitations -- Item IDs are issued by the server (positive integers, returned as strings). Pass them back — as those strings or as ints — to `get_by_ids`, `delete`, `update_documents`, `upsert_documents`, or as `ids` to `add_documents` to update in place. Other IDs, such as UUIDs, cannot be created; such rows get server-issued IDs and a `UserWarning`, and `get_by_ids` never finds them. +- Item IDs are issued by the server (positive integers, returned as strings). Pass them back in a list — as those strings or as ints, e.g. `[doc.id]` — to `get_by_ids`, `delete`, `update_documents`, `upsert_documents`, or as `ids` to `add_documents` to update in place. Other IDs, such as UUIDs, cannot be created; such rows get server-issued IDs and a `UserWarning`, and `get_by_ids` never finds them. +- Only a positive integer, or its plain decimal string, is an item ID. Anything else — a float such as `3.0`, a bool, `"+3"`, `"3_000"`, non-ASCII digits — names no item: `get_by_ids` skips it and `delete` / `update_*` / `upsert_documents` raise `ValueError`. In `add_documents`, a string of your own is inserted as a new row with a `UserWarning`, while a float or bool raises rather than inserting a duplicate. A bare string instead of a list raises `TypeError`. - LangChain's `indexing` API is unsupported, since it depends on its own IDs. - Item IDs are unique within a partition only; pass `partition_name` to `get_by_ids` for documents added to a named partition. - `get_by_ids` needs a pyenvector release that provides `Index.get_by_ids`; with an earlier pyenvector it raises `NotImplementedError`. @@ -189,7 +190,7 @@ docs = store.get_by_ids(ids, partition_name="tenant_a") # rows in a named parti ### Delete ```python -store.delete(ids) # accepts ints or numeric strings, e.g. doc.id +store.delete([doc.id]) # a list of item IDs, as ints or the strings add_documents returned ``` Deletion is asynchronous server-side; by default `delete` waits for the SDK's diff --git a/libs/envector/langchain_envector/vectorstore.py b/libs/envector/langchain_envector/vectorstore.py index 0019d11..a9011da 100644 --- a/libs/envector/langchain_envector/vectorstore.py +++ b/libs/envector/langchain_envector/vectorstore.py @@ -1,5 +1,6 @@ from __future__ import annotations +import numbers import warnings from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple @@ -17,26 +18,114 @@ SDK_HAS_GET_BY_IDS = hasattr(_SdkIndex, "get_by_ids") +# Item IDs travel as proto int64; a larger value cannot name a row and the +# SDK would fail to encode it. 19 digits is the length of 2**63 - 1, so a +# longer digit string is refused before int() sees it (CPython refuses to +# convert more than 4300 digits anyway, with its own error). +_MAX_ITEM_ID = 2**63 - 1 +_MAX_ITEM_ID_DIGITS = 19 + + +def _parse_integer(value: Any) -> Optional[int]: + """``value`` as the integer it spells, or ``None`` when it spells none. + + Accepts any ``numbers.Integral`` except ``bool`` (NumPy integers count), + and a string of at most 19 ASCII digits with an optional leading ``-``. + ``bool``, ``float``, ``"+3"``, ``"3_000"``, non-ASCII digits such as + ``"٣"`` or ``"3"``, and anything else spell no integer here: coercing them + with ``int()`` would address a document the caller did not name. + """ + if isinstance(value, bool): + return None + if isinstance(value, numbers.Integral): + return int(value) + if isinstance(value, str): + text = value.strip() + digits = text[1:] if text[:1] == "-" else text + if ( + 0 < len(digits) <= _MAX_ITEM_ID_DIGITS + and digits.isascii() + and digits.isdigit() + ): + return int(text) + return None + + +def _readable_item_id(value: Any) -> Optional[int]: + """The item ID ``value`` names exactly, or ``None`` when it names none. + + The one ID check every method shares: an integer per `_parse_integer` + that is positive and within int64, since the server issues item IDs as + ``int64``. + """ + item_id = _parse_integer(value) + if item_id is None or not 0 < item_id <= _MAX_ITEM_ID: + return None + return item_id + + +def _wrong_type_for_id(value: Any) -> bool: + """True when ``value`` cannot be an ID of any kind: a ``bool``, a ``float`` + or other non-integral number, bytes, or an ``__index__`` object such as a + 0-d NumPy array. + + LangChain IDs are strings, so a string that is not one of our item IDs + (a UUID, a 0-based chunk number) is a foreign ID and `add_texts` inserts + it as a new row with a warning. A ``float`` such as ``3.0`` is no ID at + all — it is almost always a wrongly-typed item ID, as when a pandas + integer column turns float64 on its first NaN — and a silent new row + would duplicate the document on every re-load, so `add_texts` raises. + """ + if isinstance(value, (bool, bytes, bytearray)): + return True + if isinstance(value, numbers.Integral): + return False + if isinstance(value, numbers.Number) or hasattr(value, "__index__"): + return True + return False + + +def _ids_list(ids: Any, label: str) -> List[Any]: + """``ids`` as a list, refusing a bare string or bytes. + + A ``str`` is itself a sequence of one-character strings, so + ``delete("13")`` would otherwise delete items 1 and 3, and ``bytes`` + iterate as ints — ``delete(b"13")`` would delete items 49 and 51. + """ + if isinstance(ids, (str, bytes, bytearray)): + raise TypeError( + f"Envector.{label}: ids must be a list of item IDs, e.g. [doc.id]; " + f"got a bare {type(ids).__name__}, which would be read one character " + "at a time." + ) + return list(ids) + + def _mutation_items( item_ids: List[Any], label: str, *, dedupe: bool = False ) -> List[int]: - """Coerce caller-supplied IDs to the ``int`` item_ids the SDK addresses. + """Turn caller-supplied IDs into the ``int`` item_ids the SDK addresses. - The SDK rejects non-positive and repeated ids with its own message; both - are caught here and named after the calling method. ``dedupe=True`` - (delete) drops repeats instead, since deleting a row twice is deleting it. + Used by the methods that change rows (delete, update, upsert), so a value + that names no item raises instead of being coerced onto another item — see + `_readable_item_id`. Non-positive and repeated ids are named after the + calling method. ``dedupe=True`` (delete) drops repeats instead, since + deleting a row twice is deleting it. """ - try: - ints = [int(x) for x in item_ids] - except (TypeError, ValueError) as e: - raise ValueError( - f"Envector.{label} expects integer item IDs (or numeric strings) " - "as returned by add_texts/add_documents." - ) from e - if any(i <= 0 for i in ints): - raise ValueError( - f"Envector.{label}: item IDs are positive integers (got {min(ints)})." - ) + ints: List[int] = [] + for x in item_ids: + parsed = _parse_integer(x) + if parsed is None: + raise ValueError( + f"Envector.{label} expects integer item IDs (a positive int or its " + f"decimal string) as returned by add_texts/add_documents; got {x!r}." + ) + if not 0 < parsed <= _MAX_ITEM_ID: + raise ValueError( + f"Envector.{label}: item IDs are positive integers up to " + f"{_MAX_ITEM_ID} (got {x!r})." + ) + ints.append(parsed) if dedupe: return list(dict.fromkeys(ints)) if len(set(ints)) != len(ints): @@ -48,9 +137,11 @@ def _split_caller_ids(ids: List[Any]) -> Tuple[List[Optional[int]], List[Any]]: """Sort caller-supplied IDs into enVector item IDs and everything else. Returns ``(item_ids, foreign)``: ``item_ids`` is positional against ``ids`` - with ``None`` wherever the entry was ``None`` or not an integer, and - ``foreign`` lists the non-integer values so the caller can be told they - were not honoured. + with ``None`` wherever the entry was ``None`` or names no item (see + `_readable_item_id`), and ``foreign`` lists those other values so the + caller can be told they were not honoured. A value of a type that can be + no ID at all (see `_wrong_type_for_id`) raises instead: it is a + wrongly-typed item ID, not a foreign ID, and must not become a new row. """ item_ids: List[Optional[int]] = [] foreign: List[Any] = [] @@ -58,46 +149,20 @@ def _split_caller_ids(ids: List[Any]) -> Tuple[List[Optional[int]], List[Any]]: if x is None: item_ids.append(None) continue - try: - value = int(x) - except (TypeError, ValueError): - value = 0 - if value <= 0: # the server issues positive ints only - item_ids.append(None) + value = _readable_item_id(x) + if value is None and _wrong_type_for_id(x): + raise ValueError( + f"Envector.add_texts: {x!r} is not an item ID. Item IDs are the " + "positive integers add_texts/add_documents return (as int or " + "decimal str); pass one of those to update in place, a string " + "of your own to insert under a server-issued ID, or None." + ) + item_ids.append(value) + if value is None: foreign.append(x) - else: - item_ids.append(value) return item_ids, foreign -# Item IDs travel as proto int64; a larger value cannot name a row and the -# SDK would fail to encode it. -_MAX_ITEM_ID = 2**63 - 1 - - -def _readable_item_id(value: Any) -> Optional[int]: - """The item ID ``value`` names exactly, or ``None`` when it names none. - - For `get_by_ids`, which must never read an item the caller did not name: - only a positive ``int`` within int64 (the server issues item IDs as - ``int64``), or an ASCII decimal string of one, counts. ``bool`` and - ``float`` are not item IDs — ``int(True)`` is 1 and ``int(3.9)`` is 3, so - coercing them would return a different document — and neither are - non-ASCII digits such as ``"٣"`` or ``"3"``, which ``str.isdecimal`` - accepts. - """ - if isinstance(value, bool): - return None - if isinstance(value, int): - return value if 0 < value <= _MAX_ITEM_ID else None - if isinstance(value, str): - text = value.strip() - if text.isascii() and text.isdigit(): - item_id = int(text) - return item_id if 0 < item_id <= _MAX_ITEM_ID else None - return None - - def _one_embedding_arg(embedding: Any, embeddings: Any) -> Any: """Resolve the standard positional ``embedding`` and our older ``embeddings=`` keyword into one value, rejecting conflicting pairs.""" @@ -279,11 +344,15 @@ def add_texts( not affected. ``ids`` follows LangChain's add-or-update contract as far as enVector - allows: an entry that is an item ID (int or numeric str, such as the - ``Document.id`` search results carry) updates that item in place; an ID - with no live row, or a non-integer ID, cannot be created, so that row is - inserted with a server-issued ID and a ``UserWarning``. ``None`` entries - insert. The returned list holds the IDs actually in the index, as + allows: an entry that is an item ID (a positive int or its decimal str, + such as the ``Document.id`` search results carry) updates that item in + place; an ID with no live row, or an ID of another kind (a UUID, a + slug), cannot be created, so that row is inserted with a server-issued + ID and a ``UserWarning``. ``None`` entries insert. A value that can be + no ID at all — a ``float`` such as ``3.0``, a ``bool``, bytes — is a + wrongly-typed item ID, not a foreign one, and raises ``ValueError`` + instead of becoming a new row. ``ids`` must be a list; a bare string + raises ``TypeError``. The returned list holds the IDs actually in the index, as strings like LangChain's ``Document.id``; every method here accepts them back in that form. """ @@ -306,9 +375,10 @@ def add_texts( poll_interval_s = kwargs.pop("poll_interval_s", None) if ids is not None: + ids = _ids_list(ids, "add_texts") if len(ids) != len(texts): raise ValueError("texts and ids must have equal length") - item_ids, foreign = _split_caller_ids(list(ids)) + item_ids, foreign = _split_caller_ids(ids) if foreign: warnings.warn( f"Envector cannot insert under caller-chosen IDs; {len(foreign)} " @@ -431,8 +501,9 @@ def delete( """Delete items from the encrypted index by item ID. Accepts the ``item_id`` values returned from ``add_texts`` / - ``add_documents``. Both ``int`` and ``str`` (numeric) IDs are accepted - and coerced to ``int`` before being passed to the SDK. + ``add_documents``, as ``str`` or ``int``. Any other value — ``bool`` and + ``float`` included — raises ``ValueError`` rather than being coerced onto + another item (``3.9`` would otherwise delete item 3). Deletion is asynchronous server-side; by default this waits until the affected shards are rebuilt and the remaining data is searchable again @@ -443,6 +514,8 @@ def delete( applies the delete when the row lands (late-binding through InsertShardMapList), so deleting an unmerged row is safe. """ + if ids is not None: + ids = _ids_list(ids, "delete") if not ids: return False item_ids = _mutation_items(ids, "delete", dedupe=True) @@ -494,7 +567,9 @@ def get_by_ids( ) item_ids = list( dict.fromkeys( - i for i in (_readable_item_id(x) for x in ids) if i is not None + i + for i in (_readable_item_id(x) for x in _ids_list(ids, "get_by_ids")) + if i is not None ) ) if not item_ids: @@ -536,6 +611,8 @@ def update_metadata( matched no live row (missing or already deleted) — those are reported, not raised. """ + if ids is not None: # None, like an empty list, updates nothing + ids = _ids_list(ids, "update_metadata") if not ids: return {"request_id": [], "not_found_item_ids": []} if len(texts) != len(ids): @@ -544,7 +621,7 @@ def update_metadata( metadatas = [{} for _ in texts] if len(metadatas) != len(texts): raise ValueError("texts and metadatas must have equal length") - item_ids = _mutation_items(list(ids), "update_metadata") + item_ids = _mutation_items(ids, "update_metadata") packed = [pack_metadata(t, m) for t, m in zip(texts, metadatas)] return self._update_items( @@ -580,6 +657,8 @@ def update_documents( texts = [getattr(d, "page_content", "") for d in documents] metadatas = [getattr(d, "metadata", {}) for d in documents] + if ids is not None: # None, like an empty list, updates nothing + ids = _ids_list(ids, "update_documents") if not update_vectors: return self.update_metadata( ids, @@ -606,7 +685,7 @@ def update_documents( if len(vectors) != len(ids): raise ValueError("ids and vectors must have equal length") - item_ids = _mutation_items(list(ids), "update_documents") + item_ids = _mutation_items(ids, "update_documents") packed = [pack_metadata(t, m) for t, m in zip(texts, metadatas)] return self._update_items( [ @@ -651,6 +730,8 @@ def upsert_documents( "inserted_item_ids": [], "not_found_item_ids": [], } + if ids is not None: + ids = _ids_list(ids, "upsert_documents") if ids is not None and len(ids) != len(documents): raise ValueError("ids and documents must have equal length") diff --git a/tests/test_vectorstore.py b/tests/test_vectorstore.py index 8cbd299..b454a74 100644 --- a/tests/test_vectorstore.py +++ b/tests/test_vectorstore.py @@ -1490,3 +1490,249 @@ def test_search_skips_a_hit_with_empty_text_and_metadata(): ] store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) assert [d.id for d in store.similarity_search("q", k=2)] == ["2"] + + +# Values that look like item IDs to int(...) but name none: True -> 1, +# 3.9 / 3.0 -> 3, "٣" (Arabic-Indic three) -> 3. None may address an item. +_COERCIBLE_NON_IDS = [True, 3.9, 3.0, "٣"] + + +@pytest.mark.parametrize("not_an_id", _COERCIBLE_NON_IDS) +def test_delete_refuses_values_that_name_no_item(not_an_id): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete([not_an_id]) + assert client.index.deleted == [] + + +@pytest.mark.parametrize("not_an_id", _COERCIBLE_NON_IDS) +def test_update_and_upsert_refuse_values_that_name_no_item(not_an_id): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + doc = LC_Document(page_content="new") + + with pytest.raises(ValueError, match="expects integer item IDs"): + store.update_metadata([not_an_id], ["new"]) + with pytest.raises(ValueError, match="expects integer item IDs"): + store.update_documents([not_an_id], [doc]) + with pytest.raises(ValueError, match="expects integer item IDs"): + store.upsert_documents([doc], ids=[not_an_id]) + assert client.index.updates == [] + assert client.index.upserts == [] + + +@pytest.mark.parametrize("not_an_id", [True, 3.9, 3.0]) +def test_add_texts_raises_on_a_value_that_can_be_no_id(not_an_id): + # A float or bool is a wrongly-typed item ID, not a foreign ID: raising + # beats inserting a duplicate row with a warning. Item 3 is untouched. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + + with pytest.raises(ValueError, match="is not an item ID"): + store.add_texts(["x"], ids=[not_an_id]) + assert client.index.upserts == [] + assert len(client.index.inserted) == 1 + assert client.index.stored[(None, 3)] == '{"text": "c", "metadata": {}}' + + +@pytest.mark.parametrize( + "foreign", + [ + "550e8400-e29b-41d4-a716-446655440000", + "doc-7", + "0", + "-3", + "+3", + "3.0", + "3_000", + "\u0663", + ], +) +def test_add_texts_inserts_a_string_that_is_not_an_item_id_with_a_warning(foreign): + # LangChain IDs are strings, so any string that is not one of ours is a + # foreign ID: a new row under a server-issued ID, plus a warning. This + # keeps 0-based chunk numbers working and never touches item 3. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + with pytest.warns(UserWarning, match="not enVector item IDs"): + assert store.add_texts(["x"], ids=[foreign]) == ["4"] + assert client.index.upserts == [] + assert client.index.stored[(None, 3)] == '{"text": "c", "metadata": {}}' + + +def test_mutations_still_take_ints_and_decimal_strings(): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c", "d"]) + + store.delete(["1", " 2 ", 3]) + assert client.index.deleted[-1]["item_ids"] == [1, 2, 3] + store.update_metadata([4], ["d2"]) + assert [it.item_id for it in client.index.updates[-1]["items"]] == [4] + + +def test_non_positive_ids_keep_their_own_message(): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + for bad in (0, -3, "-3", "0"): + with pytest.raises(ValueError, match="positive integers"): + store.delete([bad]) + + +def test_get_by_ids_ignores_non_ascii_digits(): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + assert store.get_by_ids(["٣"]) == [] + assert client.index.fetched == [] + + +def test_numpy_integers_are_item_ids_and_numpy_bool_float_are_not(): + # IDs often come out of NumPy arrays or pandas columns. A NumPy integer is + # an integer; np.bool_ and np.float64 are not item IDs any more than their + # Python counterparts. + np = pytest.importorskip("numpy") + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c", "d"]) + + for good in (np.int64(3), np.int32(3), np.uint64(3)): + assert [d.page_content for d in store.get_by_ids([good])] == ["c"] + store.delete([np.int64(2)]) + assert client.index.deleted[-1]["item_ids"] == [2] + assert type(client.index.deleted[-1]["item_ids"][0]) is int + + for bad in (np.bool_(True), np.float64(3.9), np.float64(3.0)): + assert store.get_by_ids([bad]) == [] + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete([bad]) + with pytest.raises(ValueError, match="positive integers"): + store.delete([np.int64(0)]) + + +@pytest.mark.parametrize("bare", ["13", "1", b"13", bytearray(b"13")]) +def test_ids_must_be_a_list_not_a_bare_string(bare): + # A str is a sequence of its characters and bytes iterate as ints, so a + # bare "13" would address items 1 and 3, and b"13" items 49 and 51. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts([f"d{i}" for i in range(60)]) + doc = LC_Document(page_content="new") + + for call in ( + lambda: store.delete(bare), + lambda: store.get_by_ids(bare), + lambda: store.update_metadata(bare, ["t"]), + lambda: store.update_documents(bare, [doc]), + lambda: store.update_documents(bare, [doc], update_vectors=False), + lambda: store.upsert_documents([doc], ids=bare), + lambda: store.add_texts(["x"], ids=bare), + ): + with pytest.raises(TypeError, match=r"list of item IDs, e.g. \[doc.id\]"): + call() + assert client.index.deleted == [] and client.index.fetched == [] + assert client.index.updates == [] and client.index.upserts == [] + assert len(client.index.inserted) == 1 + + +def test_ids_accept_tuples_sets_and_generators(): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + assert sorted(d.page_content for d in store.get_by_ids(("1", "3"))) == ["a", "c"] + assert sorted(d.page_content for d in store.get_by_ids({"1", "3"})) == ["a", "c"] + assert [d.page_content for d in store.get_by_ids(x for x in ["2"])] == ["b"] + store.delete(("1", "3")) + assert sorted(client.index.deleted[-1]["item_ids"]) == [1, 3] + + +def test_digit_strings_longer_than_int64_are_not_ids(): + # CPython refuses int() on more than 4300 digits with its own ValueError; + # a 19-digit cap classifies such strings before int() ever runs. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a"]) + huge = "9" * 4301 + twenty = "1" + "0" * 19 + assert store.get_by_ids([huge, twenty]) == [] + assert client.index.fetched == [] + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete([huge]) + with pytest.warns(UserWarning, match="not enVector item IDs"): + assert store.add_texts(["x"], ids=[huge]) == ["2"] + + +@pytest.mark.parametrize("value", ["+3", "3_000", "3.0", "\u0663", "\uff13", b"3"]) +def test_numeric_looking_values_never_address_item_3(value): + # Accepted by int() on main (b"3" and "3_000" too), these name no item now. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + assert store.get_by_ids([value]) == [] + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete([value]) + assert client.index.fetched == [] and client.index.deleted == [] + + +def test_exotic_integer_like_objects_in_add_texts_raise(): + from decimal import Decimal + from fractions import Fraction + + np = pytest.importorskip("numpy") + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b", "c"]) + for value in (Decimal(3), Fraction(3), np.array(3), np.float64(3.0)): + with pytest.raises(ValueError, match="is not an item ID"): + store.add_texts(["x"], ids=[value]) + with pytest.raises(ValueError): + store.delete([value]) + assert client.index.upserts == [] and client.index.deleted == [] + + +def test_signed_zero_and_plus_three_messages(): + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a"]) + with pytest.raises(ValueError, match="positive integers"): + store.delete(["-0"]) + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete(["+0"]) + with pytest.raises(ValueError, match="expects integer item IDs"): + store.delete(["+3"]) + + +def test_none_ids_behave_like_langchain(): + # LangChain's InMemoryVectorStore: get_by_ids(None) raises TypeError, + # delete(None) does nothing, add_texts(ids=None) inserts. update_* and + # upsert_documents have no LangChain counterpart; None updates nothing, + # like delete(None), and upsert_documents(ids=None) inserts. + client = FakeClient() + store = Envector(config=_cfg(), embeddings=FakeEmbeddings(dim=4), client=client) + store.add_texts(["a", "b"]) + doc = LC_Document(page_content="new") + + with pytest.raises(TypeError): + store.get_by_ids(None) + assert store.delete(None) is False + assert store.update_metadata(None, []) == { + "request_id": [], + "not_found_item_ids": [], + } + assert store.update_documents(None, []) == { + "request_id": [], + "not_found_item_ids": [], + } + assert store.update_documents(None, [], update_vectors=False) == { + "request_id": [], + "not_found_item_ids": [], + } + assert client.index.deleted == [] and client.index.updates == [] + assert store.add_texts(["c"], ids=None) == ["3"] + assert store.upsert_documents([doc], ids=None)["inserted_item_ids"] == [4]