diff --git a/neofs-testlib/neofs_testlib/cli/neofs_cli/object.py b/neofs-testlib/neofs_testlib/cli/neofs_cli/object.py index ec121f6f6..6a8be4d5f 100644 --- a/neofs-testlib/neofs_testlib/cli/neofs_cli/object.py +++ b/neofs-testlib/neofs_testlib/cli/neofs_cli/object.py @@ -349,3 +349,51 @@ def search( "object search", **{param: value for param, value in locals().items() if param not in ["self"]}, ) + + def searchv2( + self, + rpc_endpoint: str, + wallet: str, + cid: str, + filters: Optional[list] = None, + attributes: Optional[list] = None, + count: Optional[int] = None, + cursor: Optional[str] = None, + address: Optional[str] = None, + bearer: Optional[str] = None, + oid: Optional[str] = None, + phy: bool = False, + root: bool = False, + session: Optional[str] = None, + ttl: Optional[int] = None, + xhdr: Optional[dict] = None, + timeout: Optional[str] = None, + ) -> CommandResult: + """ + Search object. + + Args: + address: Address of wallet account. + bearer: File with signed JSON or binary encoded bearer token. + cid: Container ID. + filters: Repeated filter expressions or files with protobuf JSON. + attributes: Additional attributes to display for suitable objects + count: Max number of resulting items. Must not exceed 1000 + cursor: Cursor to continue previous search + oid: Object ID. + phy: Search physically stored objects. + root: Search for user objects. + rpc_endpoint: Remote node address (as 'multiaddr' or ':'). + session: Filepath to a JSON- or binary-encoded token of the object SEARCH session. + ttl: TTL value in request meta header (default 2). + wallet: WIF (NEP-2) string or path to the wallet or binary key. + xhdr: Dict with request X-Headers. + timeout: Timeout for the operation (default 15s). + + Returns: + Command's result. + """ + return self._execute( + "object searchv2", + **{param: value for param, value in locals().items() if param not in ["self"]}, + ) diff --git a/peapod-to-fstree b/peapod-to-fstree new file mode 100755 index 000000000..5dd1c4395 Binary files /dev/null and b/peapod-to-fstree differ diff --git a/pytest_tests/lib/helpers/neofs_verbs.py b/pytest_tests/lib/helpers/neofs_verbs.py index 0b0e01026..2d16dbf1c 100644 --- a/pytest_tests/lib/helpers/neofs_verbs.py +++ b/pytest_tests/lib/helpers/neofs_verbs.py @@ -4,7 +4,7 @@ import random import re import uuid -from typing import Any, Optional +from typing import Any, Optional, Union import allure from helpers import json_transformers @@ -516,6 +516,107 @@ def search_object( return found_objects +def parse_searchv2_output(raw_output: str) -> tuple[list[dict], Union[str, None]]: + lines = raw_output.strip().split("\n")[1:] + + objects = [] + cursor = None + current_object = None + + for line in lines: + line = line.strip() + + if "Cursor" in line: + cursor = line.split(":")[1].strip() + elif re.match(r"^[a-zA-Z0-9]{40,}$", line): + if current_object: + objects.append(current_object) + current_object = {"id": line, "attrs": []} + elif ":" in line and current_object: + splitted_line = line.split(":") + if "Timestamp" in line: + key = splitted_line[0].strip() + value = ":".join(splitted_line[1:]).strip() + else: + key = ":".join(splitted_line[:-1]).strip() + value = splitted_line[-1].strip() + current_object["attrs"].append({key.strip(): value.strip()}) + + if current_object: + objects.append(current_object) + + return objects, cursor + + +@allure.step("Search object") +def search_objectv2( + rpc_endpoint: str, + wallet: str, + cid: str, + shell: Shell, + filters: Optional[list] = None, + attributes: Optional[list] = None, + count: Optional[int] = None, + cursor: Optional[str] = None, + address: Optional[str] = None, + bearer: Optional[str] = None, + oid: Optional[str] = None, + phy: bool = False, + root: bool = False, + session: Optional[str] = None, + ttl: Optional[int] = None, + xhdr: Optional[dict] = None, + timeout: Optional[str] = None, + wallet_config: Optional[str] = None, +) -> tuple[list[dict], Union[str, None]]: + """ + SEARCH an Object. + + Args: + address: Address of wallet account. + bearer: File with signed JSON or binary encoded bearer token. + cid: Container ID. + filters: Repeated filter expressions or files with protobuf JSON. + attributes: Additional attributes to display for suitable objects + count: Max number of resulting items. Must not exceed 1000 + cursor: Cursor to continue previous search + oid: Object ID. + phy: Search physically stored objects. + root: Search for user objects. + rpc_endpoint: Remote node address (as 'multiaddr' or ':'). + session: Filepath to a JSON- or binary-encoded token of the object SEARCH session. + ttl: TTL value in request meta header (default 2). + wallet: WIF (NEP-2) string or path to the wallet or binary key. + xhdr: Dict with request X-Headers. + timeout: Timeout for the operation (default 15s). + + Returns: + list of found objects as a dict: [{'oid': '123', 'attrs': [{'attr1': '123'}]}] + """ + + cli = NeofsCli(shell, NEOFS_CLI_EXEC, wallet_config or WALLET_CONFIG) + result = cli.object.searchv2( + rpc_endpoint=rpc_endpoint, + wallet=wallet, + cid=cid, + filters=",".join(filters) if filters else None, + attributes=",".join(attributes) if attributes else None, + count=count, + cursor=cursor, + address=address, + bearer=bearer, + oid=oid, + phy=phy, + root=root, + session=session, + ttl=ttl, + xhdr=xhdr, + timeout=timeout, + ) + + return parse_searchv2_output(result.stdout) + + @allure.step("Get netmap netinfo") def get_netmap_netinfo( wallet: str, diff --git a/pytest_tests/tests/object/test_object_searchv2.py b/pytest_tests/tests/object/test_object_searchv2.py new file mode 100644 index 000000000..cf5a97474 --- /dev/null +++ b/pytest_tests/tests/object/test_object_searchv2.py @@ -0,0 +1,999 @@ +import itertools +import logging +import operator +from datetime import datetime + +import neofs_env.neofs_epoch as neofs_epoch +import pytest +from helpers.complex_object_actions import get_object_chunks +from helpers.container import create_container, delete_container +from helpers.file_helper import generate_file +from helpers.neofs_verbs import ( + delete_object, + head_object, + put_object_to_random_node, + search_objectv2, +) +from neofs_testlib.env.env import NeoFSEnv, NodeWallet +from neofs_testlib.shell import Shell + +logger = logging.getLogger("NeoLogger") + + +def timestamp_to_epoch(timestamp_str: str) -> int: + dt_part = timestamp_str[:-10].strip() + dt_naive = datetime.strptime(dt_part, "%Y-%m-%d %H:%M:%S") + return int(dt_naive.timestamp()) + + +def get_attribute_value_from_found_object(found_object: dict, attr_name: str) -> str: + value = next((attr[attr_name] for attr in found_object["attrs"] if attr_name in attr), None) + assert value, f"no {attr_name} found in {found_object}" + return value + + +@pytest.fixture(scope="module") +def requires_node_from_master(neofs_env: NeoFSEnv): + if neofs_env.get_binary_version(neofs_env.neofs_node_path) <= "0.44.2": + pytest.skip("Test requires fresh node version") + + +@pytest.fixture +def container(default_wallet: NodeWallet, client_shell: Shell, neofs_env: NeoFSEnv, requires_node_from_master) -> str: + cid = create_container(default_wallet.path, shell=client_shell, endpoint=neofs_env.sn_rpc, rule="REP 3") + yield cid + delete_container(default_wallet.path, cid, shell=client_shell, endpoint=neofs_env.sn_rpc) + + +def test_search_sanity(default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int): + cid = container + created_objects = [] + for _ in range(2): + created_objects.append( + put_object_to_random_node( + default_wallet.path, + generate_file(simple_object_size), + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + ) + ) + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, wallet=default_wallet.path, cid=cid, shell=neofs_env.shell + ) + assert len(found_objects) == len(created_objects), "invalid number of objects" + for created_obj_id in created_objects: + assert any(found_obj["id"] == created_obj_id for found_obj in found_objects), ( + f"created object {created_obj_id} not found in search output" + ) + + +def test_search_single_filter_by_custom_int_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + int_attributes_values = [-(2**64) - 1, -1, 0, 1, 10, 2**64 + 1] + int_attribute_name = "int_attribute" + + for int_value in int_attributes_values: + file_path = generate_file(simple_object_size) + + created_objects.append( + { + int_attribute_name: int_value, + "id": put_object_to_random_node( + default_wallet.path, + file_path, + container, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={int_attribute_name: int_value}, + ), + } + ) + + operators = {"GT": operator.gt, "GE": operator.ge, "LT": operator.lt, "LE": operator.le} + + for operator_str, comparator in operators.items(): + for int_value in int_attributes_values: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"{int_attribute_name} {operator_str} {int_value}"], + attributes=[int_attribute_name], + ) + + if found_objects: + assert len(found_objects[0]["attrs"]) == 1, f"invalid number of attributes for {found_objects[0]}" + min_int_value = int(found_objects[0]["attrs"][0][int_attribute_name]) + + for found_obj in found_objects[1:]: + assert len(found_obj["attrs"]) == 1, f"invalid number of attributes for {found_obj}" + assert int(found_obj["attrs"][0][int_attribute_name]) > min_int_value, ( + "invalid ordering in search output" + ) + min_int_value = int(found_obj["attrs"][0][int_attribute_name]) + + for created_obj in created_objects: + condition_met = comparator(created_obj[int_attribute_name], int_value) + + if condition_met: + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + else: + assert not any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} found in search output, while shouldn't" + ) + + +def test_search_single_filter_by_custom_str_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + str_attributes_values = ["Aaa", "Aaabcd", "Aaabcd", "1Aaabcd2", "A11a//b_c.//", "!@#$%ˆ&*()", "#FFFFFF"] + str_attribute_name = "str_attribute" + + for str_value in str_attributes_values: + file_path = generate_file(simple_object_size) + + created_objects.append( + { + str_attribute_name: str_value, + "id": put_object_to_random_node( + default_wallet.path, + file_path, + container, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={str_attribute_name: str_value}, + ), + } + ) + + operators = { + "EQ": lambda obj_val, filter_val: obj_val == filter_val, + "NE": lambda obj_val, filter_val: obj_val != filter_val, + "COMMON_PREFIX": lambda obj_val, filter_val: obj_val.startswith(filter_val), + } + + for operator_str, comparator in operators.items(): + for str_value in str_attributes_values: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"{str_attribute_name} {operator_str} {str_value}"], + attributes=[str_attribute_name], + ) + + for created_obj in created_objects: + obj_value = created_obj[str_attribute_name] + condition_met = comparator(obj_value, str_value) + + if condition_met: + assert any( + found_obj["id"] == created_obj["id"] + and comparator(found_obj["attrs"][0][str_attribute_name], str_value) + for found_obj in found_objects + ), f"Created object {created_obj['id']} not found in search output" + else: + assert not any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"Created object {created_obj['id']} found in search output, but shouldn't" + ) + + +def test_search_multiple_filters_by_custom_int_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + + int_attributes_values = [0, 1, -1] + + for attr0, attr1, attr2 in list(itertools.permutations(int_attributes_values, 3)): + file_path = generate_file(simple_object_size) + attrs = {"int_attr0": attr0, "int_attr1": attr1, "int_attr2": attr2} + created_objects.append( + { + "attrs": attrs, + "id": put_object_to_random_node( + default_wallet.path, + file_path, + container, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes=attrs, + ), + } + ) + + operators = {"GT": operator.gt, "GE": operator.ge, "LT": operator.lt, "LE": operator.le} + + filters = [("GT", "GE", "LT"), ("GT", "GE", "LE"), ("GT", "LT", "GE"), ("GT", "LT", "LE"), ("GT", "LE", "GE")] + + for op0, op1, op2 in filters: + for int_value in int_attributes_values: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[ + f"int_attr0 {op0} {int_value}", + f"int_attr1 {op1} {int_value}", + f"int_attr2 {op2} {int_value}", + ], + attributes=["int_attr0", "int_attr1", "int_attr2"], + ) + + for found_obj in found_objects: + for i, op in enumerate([op0, op1, op2]): + attr_name = f"int_attr{i}" + assert operators[op](int(get_attribute_value_from_found_object(found_obj, attr_name)), int_value), ( + f"Invalid object returned from searchv2: {found_obj}" + ) + + for created_obj in created_objects: + attrs = created_obj["attrs"] + matches_all = all( + operators[op](attrs[f"int_attr{i}"], int_value) for i, op in enumerate([op0, op1, op2]) + ) + if matches_all: + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + else: + assert not any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} found in search output, while shouldn't" + ) + + +def test_search_multiple_filters_by_custom_str_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + str_attributes_values = ["Aaa", "Aaabcd", "Aaabcd"] + + for attr0, attr1, attr2 in list(itertools.permutations(str_attributes_values, 3)): + file_path = generate_file(simple_object_size) + attrs = {"str_attr0": attr0, "str_attr1": attr1, "str_attr2": attr2} + created_objects.append( + { + "attrs": attrs, + "id": put_object_to_random_node( + default_wallet.path, + file_path, + container, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes=attrs, + ), + } + ) + + operators = { + "EQ": lambda obj_val, filter_val: obj_val == filter_val, + "NE": lambda obj_val, filter_val: obj_val != filter_val, + "COMMON_PREFIX": lambda obj_val, filter_val: obj_val.startswith(filter_val), + } + + filters = [ + ("EQ", "NE", "COMMON_PREFIX"), + ("EQ", "COMMON_PREFIX", "NE"), + ("NE", "EQ", "COMMON_PREFIX"), + ("NE", "COMMON_PREFIX", "EQ"), + ] + + for op0, op1, op2 in filters: + for str_value in str_attributes_values: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[ + f"str_attr0 {op0} {str_value}", + f"str_attr1 {op1} {str_value}", + f"str_attr2 {op2} {str_value}", + ], + attributes=["str_attr0", "str_attr1", "str_attr2"], + ) + + for found_obj in found_objects: + for i, op in enumerate([op0, op1, op2]): + attr_name = f"str_attr{i}" + assert operators[op](get_attribute_value_from_found_object(found_obj, attr_name), str_value), ( + f"Invalid object returned from searchv2: {found_obj}" + ) + + for created_obj in created_objects: + attrs = created_obj["attrs"] + matches_all = all( + operators[op](attrs[f"str_attr{i}"], str_value) for i, op in enumerate([op0, op1, op2]) + ) + if matches_all: + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + else: + assert not any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} found in search output, while shouldn't" + ) + + +def test_search_multiple_filters_by_custom_mixed_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + str_attributes_values = ["Aaa", "Aaabcd", "Aaabcd"] + int_attributes_values = [0, 1, -1] + + attr_values = [ + l1 + l2 + for l1, l2 in zip( + list(itertools.permutations(str_attributes_values, 3)), + list(itertools.permutations(int_attributes_values, 3)), + ) + ] + + for str_attr0, str_attr1, str_attr2, int_attr0, int_attr1, int_attr2 in attr_values: + file_path = generate_file(simple_object_size) + attrs = { + "str_attr0": str_attr0, + "str_attr1": str_attr1, + "str_attr2": str_attr2, + "int_attr0": int_attr0, + "int_attr1": int_attr1, + "int_attr2": int_attr2, + } + created_objects.append( + { + "attrs": attrs, + "id": put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes=attrs, + ), + } + ) + + str_operators = { + "EQ": lambda obj_val, filter_val: obj_val == filter_val, + "NE": lambda obj_val, filter_val: obj_val != filter_val, + "COMMON_PREFIX": lambda obj_val, filter_val: obj_val.startswith(filter_val), + } + + int_operators = {"GT": operator.gt, "GE": operator.ge, "LT": operator.lt, "LE": operator.le} + + filters = [ + ("EQ", "NE", "LE"), + ("EQ", "COMMON_PREFIX", "GT"), + ("EQ", "GT", "GE"), + ("NE", "COMMON_PREFIX", "GT"), + ("NE", "GT", "LT"), + ("COMMON_PREFIX", "GT", "GE"), + ("GT", "EQ", "COMMON_PREFIX"), + ("LT", "GE", "EQ"), + ("LT", "NE", "GE"), + ] + + for op0, op1, op2 in filters: + for str_value in str_attributes_values: + for int_value in int_attributes_values: + search_filters = [] + + attributes = [] + + if op0 in str_operators: + search_filters.append(f"str_attr0 {op0} {str_value}") + attributes.append("str_attr0") + else: + search_filters.append(f"int_attr0 {op0} {int_value}") + attributes.append("int_attr0") + if op1 in str_operators: + search_filters.append(f"str_attr1 {op1} {str_value}") + attributes.append("str_attr1") + else: + search_filters.append(f"int_attr1 {op1} {int_value}") + attributes.append("int_attr1") + if op2 in str_operators: + search_filters.append(f"str_attr2 {op2} {str_value}") + attributes.append("str_attr2") + else: + search_filters.append(f"int_attr2 {op2} {int_value}") + attributes.append("int_attr2") + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=search_filters, + attributes=attributes, + ) + + for found_obj in found_objects: + if op0 in str_operators: + assert str_operators[op0]( + get_attribute_value_from_found_object(found_obj, "str_attr0"), str_value + ), f"Invalid object returned from searchv2: {found_obj}" + else: + assert int_operators[op0]( + int(get_attribute_value_from_found_object(found_obj, "int_attr0")), int_value + ), f"Invalid object returned from searchv2: {found_obj}" + if op1 in str_operators: + assert str_operators[op1]( + get_attribute_value_from_found_object(found_obj, "str_attr1"), str_value + ), f"Invalid object returned from searchv2: {found_obj}" + else: + assert int_operators[op1]( + int(get_attribute_value_from_found_object(found_obj, "int_attr1")), int_value + ), f"Invalid object returned from searchv2: {found_obj}" + if op2 in str_operators: + assert str_operators[op2]( + get_attribute_value_from_found_object(found_obj, "str_attr2"), str_value + ), f"Invalid object returned from searchv2: {found_obj}" + else: + assert int_operators[op2]( + int(get_attribute_value_from_found_object(found_obj, "int_attr2")), int_value + ), f"Invalid object returned from searchv2: {found_obj}" + + for created_obj in created_objects: + attrs = created_obj["attrs"] + matches = [] + for i, op in enumerate([op0, op1, op2]): + if op in str_operators: + matches.append(str_operators[op](attrs[f"str_attr{i}"], str_value)) + else: + matches.append(int_operators[op](attrs[f"int_attr{i}"], int_value)) + if all(matches): + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + else: + assert not any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} found in search output, while shouldn't" + ) + + +def test_search_by_system_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + for idx in range(4): + file_path = generate_file(simple_object_size + (idx * 100)) + oid = put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + ) + head_info = head_object( + default_wallet.path, + cid, + oid, + shell=neofs_env.shell, + endpoint=neofs_env.sn_rpc, + ) + logger.info(f"{head_info=}") + system_attributes = { + "FileName": head_info["header"]["attributes"]["FileName"], + "Timestamp": head_info["header"]["attributes"]["Timestamp"], + # '$Object:containerId': head_info['header']['containerID'], + # '$Object:ownerId': head_info['header']['ownerID'], + "$Object:creationEpoch": head_info["header"]["creationEpoch"], + "$Object:payloadLength": head_info["header"]["payloadLength"], + # '$Object:payloadHash': head_info['header']['payloadHash'], + "$Object:objectType": head_info["header"]["objectType"], + # '$Object:homomorphicHash': head_info['header']['homomorphicHash'] + } + created_objects.append({"id": oid, "attrs": system_attributes}) + + for system_attr in system_attributes.keys(): + for created_obj in created_objects: + created_obj_attr_value = created_obj["attrs"][system_attr] + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"{system_attr} EQ {created_obj_attr_value}"], + attributes=[system_attr], + ) + logger.info(f"{found_objects}") + for found_obj in found_objects: + if system_attr == "Timestamp": + epoch_from_timestamp = timestamp_to_epoch( + get_attribute_value_from_found_object(found_obj, system_attr) + ) + assert epoch_from_timestamp == int(created_obj_attr_value), ( + f"Invalid object returned from searchv2: {found_obj}" + ) + else: + assert get_attribute_value_from_found_object(found_obj, system_attr) == created_obj_attr_value, ( + f"Invalid object returned from searchv2: {found_obj}" + ) + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + + +def test_search_by_non_existing_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + for _ in range(3): + file_path = generate_file(simple_object_size) + created_objects.append( + put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + ) + ) + + for op in ["GT", "GE", "LT", "LE", "EQ", "NE", "COMMON_PREFIX"]: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"nonExistentAttr {op} 1234"], + attributes=["nonExistentAttr"], + ) + assert len(found_objects) == 0, "invalid number of found objects" + + +def test_search_of_complex_object( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, complex_object_size: int +): + cid = container + file_path = generate_file(complex_object_size) + oid = put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={"complex_attr": "489-0"}, + ) + + parts = get_object_chunks(default_wallet.path, container, oid, neofs_env.shell, neofs_env) + + found_complex_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["str_attr1 NOPRESENT", "$Object:objectType EQ LINK"], + attributes=["str_attr1", "$Object:objectType"], + ) + + assert len(found_complex_objects) == 1, "there is an unexpected number of LINK objects" + + found_complex_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["str_attr1 NOPRESENT", "$Object:objectType EQ REGULAR"], + attributes=["str_attr1", "$Object:objectType"], + ) + + assert len(found_complex_objects) == len(parts) + 1, "tthere is an unexpected number of REGULAR objects" + + +def test_search_by_various_attributes( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + file_path = generate_file(simple_object_size) + oid1 = put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={"str_attr0": "interesting.value_for_some*reason", "int_attr0": 54321}, + ) + head_info1 = head_object( + default_wallet.path, + cid, + oid1, + shell=neofs_env.shell, + endpoint=neofs_env.sn_rpc, + ) + neofs_epoch.ensure_fresh_epoch(neofs_env) + oid2 = put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={"str_attr0": "interesting.value_for_some*reason", "str_attr1": "oops", "int_attr0": 54321}, + ) + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"$Object:payloadLength EQ {simple_object_size}", "str_attr1 NOPRESENT", "int_attr0 GE 10"], + attributes=["$Object:payloadLength", "str_attr1", "int_attr0"], + ) + assert len(found_objects) == 1, "invalid number of found objects" + assert found_objects[0]["id"] == oid1, "invalid object returned from search" + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[ + "int_attr0 GT 1000", + f"$Object:creationEpoch NE {head_info1['header']['creationEpoch']}", + "str_attr1 NOPRESENT", + ], + attributes=["$Object:creationEpoch", "int_attr0", "str_attr1"], + ) + assert len(found_objects) == 0, "invalid number of found objects" + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[ + "non_existentATTR NOPRESENT", + "$Object:creationEpoch GT 1", + f"$Object:payloadHash NE {head_info1['header']['payloadHash']}", + ], + attributes=["non_existentATTR", "$Object:creationEpoch", "$Object:PayloadHash"], + ) + assert len(found_objects) == 1, "invalid number of found objects" + assert found_objects[0]["id"] == oid2, "invalid object returned from search" + + +def test_search_attrs_ordering( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + for idx in range(5): + file_path = generate_file(simple_object_size + (idx * 100)) + attrs = {"str_attr": "ab" * (idx + 1)} + created_objects.append( + { + "id": put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={"str_attr": "ab" * (6 - idx)}, + ), + "attrs": attrs, + } + ) + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"$Object:payloadLength GE {simple_object_size}", "str_attr COMMON_PREFIX ab"], + attributes=["$Object:payloadLength", "str_attr"], + ) + + max_length = int(get_attribute_value_from_found_object(found_objects[0], "$Object:payloadLength")) + for found_obj in found_objects[1:]: + current_length = int(get_attribute_value_from_found_object(found_obj, "$Object:payloadLength")) + assert current_length > max_length, "invalid ordering in search output" + max_length = current_length + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["str_attr COMMON_PREFIX ab", f"$Object:payloadLength GE {simple_object_size}"], + attributes=["str_attr", "$Object:payloadLength"], + ) + + max_str = get_attribute_value_from_found_object(found_objects[0], "str_attr") + for found_obj in found_objects[1:]: + current_str = get_attribute_value_from_found_object(found_obj, "str_attr") + assert current_str > max_str, "invalid ordering in search output" + max_str = current_str + + +def test_search_count_and_cursor( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + for _ in range(5): + file_path = generate_file(simple_object_size) + created_objects.append( + { + "id": put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + ), + } + ) + + for count_value in [len(created_objects), len(created_objects) + 1]: + found_objects, cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + count=count_value, + ) + + assert not cursor, "there should be no cursor object in search output" + assert len(found_objects) == len(created_objects), "invalid objects count after search" + + found_objects = [] + + first_found_objects, first_cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + count=2, + ) + + assert len(first_found_objects) == 2, "invalid objects count after search" + + found_objects.extend(first_found_objects) + + second_found_objects, cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + cursor=first_cursor, + count=2, + ) + + assert len(second_found_objects) == 2, "invalid objects count after search" + + found_objects.extend(second_found_objects) + + last_found_objects, last_cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + cursor=cursor, + count=2, + ) + + assert not last_cursor, "there should be no last cursor object in search output" + assert len(last_found_objects) == 1, "invalid objects count after search" + + found_objects.extend(last_found_objects) + + for created_obj in created_objects: + assert any(found_obj["id"] == created_obj["id"] for found_obj in found_objects), ( + f"created object {created_obj['id']} not found in search output" + ) + + last_found_objects, last_cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + cursor=cursor, + count=2, + ) + + assert not last_cursor, "there should be no last cursor object in search output" + assert len(last_found_objects) == 1, "invalid objects count after search" + + # invalid cursor + with pytest.raises(Exception): + last_found_objects, cursor = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch"], + cursor="0123_???!##", + count=2, + ) + + for created_obj in created_objects: + delete_object( + default_wallet.path, + cid, + created_obj["id"], + neofs_env.shell, + neofs_env.sn_rpc, + ) + + tombstone_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GE 0"], + attributes=["$Object:creationEpoch", "$Object:objectType"], + cursor=first_cursor, + ) + assert len(tombstone_objects) == len(created_objects), "invalid objects count after search" + + with pytest.raises(Exception, match=".*wrong attribute.*"): + not_tombstone_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:objectType NE TOMBSTONE"], + attributes=["$Object:objectType"], + cursor=first_cursor, + ) + + +def test_search_invalid_filters( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + created_objects = [] + for _ in range(2): + file_path = generate_file(simple_object_size) + created_objects.append( + { + "id": put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + ), + } + ) + for op in ["GE", "GT", "LT", "LE", "EQ", "COMMON_PREFIX"]: + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"$Object:creationEpoch {op} abc"], + attributes=["$Object:creationEpoch"], + ) + assert len(found_objects) == 0, "invalid number of found objects" + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"$Object:creationEpoch {op} 0_0"], + attributes=["$Object:creationEpoch"], + ) + assert len(found_objects) == 0, "invalid number of found objects" + + with pytest.raises(Exception, match=r".*unsupported operation.*"): + search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch ?? 0"], + attributes=["$Object:creationEpoch"], + ) + + with pytest.raises(Exception, match=r".*unsupported operation.*"): + search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch GT 0, ab ?? 0"], + attributes=["$Object:creationEpoch"], + ) + + for op in ["GE", "GT", "LT", "LE", "EQ", "NE", "COMMON_PREFIX"]: + with pytest.raises(Exception, match=r".*unsupported operation.*"): + search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"$Object:creationEpoch {op}"], + attributes=["$Object:creationEpoch"], + ) + + with pytest.raises(Exception, match=r".*unsupported operation.*"): + search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["$Object:creationEpoch NOPRESENT 123"], + attributes=["$Object:creationEpoch"], + ) + + +def test_search_conflicting_filters( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + file_path = generate_file(simple_object_size) + ( + put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={"int_attr": 1000}, + ), + ) + + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=["int_attr GE 1000", "int_attr LT 1000"], + attributes=["int_attr"], + ) + + assert len(found_objects) == 0, "invalid number of found objects" + + +@pytest.mark.skip(reason="rpc error: more than 4 attributes; need to update sdk in neofs-node") +def test_search_filters_attributes_limits( + default_wallet: NodeWallet, container: str, neofs_env: NeoFSEnv, simple_object_size: int +): + cid = container + file_path = generate_file(simple_object_size) + put_object_to_random_node( + default_wallet.path, + file_path, + cid, + shell=neofs_env.shell, + neofs_env=neofs_env, + attributes={f"int_attr{x}": 1000 for x in range(10)}, + ) + found_objects, _ = search_objectv2( + rpc_endpoint=neofs_env.sn_rpc, + wallet=default_wallet.path, + cid=cid, + shell=neofs_env.shell, + filters=[f"int_attr{x} GE 1000" for x in range(8)], + attributes=[f"int_attr{x}" for x in range(8)], + )