diff --git a/aerospike_sdk/ael/server_filter.py b/aerospike_sdk/ael/server_filter.py new file mode 100644 index 0000000..244ba38 --- /dev/null +++ b/aerospike_sdk/ael/server_filter.py @@ -0,0 +1,45 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Pick client-parsed vs server-compiled filter wire form for AEL strings.""" + +from __future__ import annotations + +from aerospike_async import FilterExpression + +from aerospike_sdk.ael.parser import parse_ael + +# Resolved once at import — the PAC factory does not change at runtime. +_SERVER_COMPILED_FACTORY = getattr( + FilterExpression, "from_server_compiled_ael", None, +) +_PAC_EXPOSES_SERVER_COMPILED: bool = callable(_SERVER_COMPILED_FACTORY) + + +def filter_expression_from_ael_string( + ael: str, + *, + supports_server_compiled_ael: bool, +) -> FilterExpression: + """Return a ``FilterExpression`` for *ael*, using server-compiled wire when allowed. + + When ``supports_server_compiled_ael`` is true and PAC exposes the factory, + returns field **43** MessagePack ``[128, ""]`` via + :meth:`~aerospike_async.FilterExpression.from_server_compiled_ael`. + Otherwise parses on the client via :func:`~aerospike_sdk.ael.parser.parse_ael`. + """ + if supports_server_compiled_ael and _PAC_EXPOSES_SERVER_COMPILED: + return _SERVER_COMPILED_FACTORY(ael) # type: ignore[misc] + return parse_ael(ael) diff --git a/aerospike_sdk/aio/background.py b/aerospike_sdk/aio/background.py index febbfb7..7c99863 100644 --- a/aerospike_sdk/aio/background.py +++ b/aerospike_sdk/aio/background.py @@ -40,7 +40,7 @@ reject_unsupported_background_write_ops, ) from aerospike_sdk.dataset import DataSet -from aerospike_sdk.ael.parser import parse_ael +from aerospike_sdk.ael.server_filter import filter_expression_from_ael_string from aerospike_sdk.exceptions import _convert_pac_exception from aerospike_sdk.operations_shared import _seconds_from_timedelta, _seconds_until @@ -201,6 +201,9 @@ def __init__( self._records_per_second: Optional[int] = None self._durable_delete_command_default: Optional[bool] = None self._durable_delete_override: Optional[bool] = None + self._supports_server_compiled_ael = bool( + getattr(session.client, "_cached_supports_server_compiled_ael", False), + ) def default_with_durable_delete(self) -> BackgroundOperationBuilder: """Prefer durable deletes when resolving policy defaults (SC namespaces).""" @@ -246,7 +249,10 @@ def where( "use one narrowing mechanism.", ) if isinstance(expression, str): - self._filter_expression = parse_ael(expression) + self._filter_expression = filter_expression_from_ael_string( + expression, + supports_server_compiled_ael=self._supports_server_compiled_ael, + ) else: self._filter_expression = expression return self @@ -542,6 +548,9 @@ def __init__( self._records_per_second: Optional[int] = None self._durable_delete_command_default: Optional[bool] = None self._durable_delete_override: Optional[bool] = None + self._supports_server_compiled_ael = bool( + getattr(session.client, "_cached_supports_server_compiled_ael", False), + ) def default_with_durable_delete(self) -> BackgroundUdfBuilder: """Prefer durable deletes when resolving policy defaults (SC namespaces).""" @@ -587,7 +596,10 @@ def where( ) -> BackgroundUdfBuilder: """Optional predicate limiting which records invoke the UDF.""" if isinstance(expression, str): - self._filter_expression = parse_ael(expression) + self._filter_expression = filter_expression_from_ael_string( + expression, + supports_server_compiled_ael=self._supports_server_compiled_ael, + ) else: self._filter_expression = expression return self diff --git a/aerospike_sdk/aio/client.py b/aerospike_sdk/aio/client.py index a96be2a..cdbb6a9 100644 --- a/aerospike_sdk/aio/client.py +++ b/aerospike_sdk/aio/client.py @@ -44,7 +44,20 @@ from aerospike_sdk.policy.behavior_settings import Mode from aerospike_sdk.policy.sdk_config_loader import fill_hard_defaults from aerospike_sdk.policy.system_settings import SystemSettings +from aerospike_sdk.feature_gates import ( + PSDK_ENABLE_QUERY_SELECTION, + PSDK_ENABLE_SERVER_COMPILED_AEL, + cached_ael_capability_kwargs, +) +from aerospike_sdk.query_selection import ( + compute_query_selection_support, + compute_query_selection_support_blocking, +) from aerospike_sdk.sdk_config_monitor import AsyncSdkConfigMonitor, SdkConfigSource +from aerospike_sdk.server_compiled_ael import ( + compute_server_compiled_ael_support, + compute_server_compiled_ael_support_blocking, +) if typing.TYPE_CHECKING: from aerospike_sdk.aio.session import Session @@ -147,6 +160,8 @@ def __init__( # Shared by all Session instances from this client; avoids repeated # namespace/ info probes when callers use multiple sessions. self._namespace_mode_cache: Dict[str, Mode] = {} + self._cached_supports_query_selection: Optional[bool] = None + self._cached_supports_server_compiled_ael: Optional[bool] = None # Resolved SDK-level settings (file over programmatic over defaults). # A frozen snapshot swapped wholesale by the config monitor, so the # operation path reads it lock-free. @@ -220,6 +235,18 @@ async def connect(self) -> None: log.debug("Connecting to cluster seeds=%r", self._seeds) self._client = await new_client(self._policy, self._seeds) self._connected = True + if PSDK_ENABLE_QUERY_SELECTION: + self._cached_supports_query_selection = await compute_query_selection_support( + self._client, + ) + else: + self._cached_supports_query_selection = False + if PSDK_ENABLE_SERVER_COMPILED_AEL: + self._cached_supports_server_compiled_ael = ( + await compute_server_compiled_ael_support(self._client) + ) + else: + self._cached_supports_server_compiled_ael = False log.info( "Connected seeds=%r", self._seeds, extra={"aerospike.cluster": self._policy.cluster_name}, @@ -261,9 +288,33 @@ async def close(self) -> None: self._client = None self._connected = False log.info("Client closed") + self._cached_supports_query_selection = None + self._cached_supports_server_compiled_ael = None self._namespace_mode_cache.clear() self._supports_mrt_cache = None + @property + def supports_query_selection(self) -> bool: + """``True`` when all cluster nodes support field ``44`` query selection (>= 8.1.3). + + Computed at :meth:`connect` / :meth:`connect_blocking` from PAC + ``Version.supports_query_selection()`` on every node. + """ + if not self._connected or self._client is None: + return False + return bool(self._cached_supports_query_selection) + + @property + def supports_server_compiled_ael(self) -> bool: + """``True`` when server-compiled AEL filters are usable on this connection. + + Requires all nodes >= 8.1.3 (PAC ``Version.supports_server_compiled_ael``) + and PAC ``FilterExpression.from_server_compiled_ael``. Cached at connect. + """ + if not self._connected or self._client is None: + return False + return bool(self._cached_supports_server_compiled_ael) + def connect_blocking(self) -> None: """Synchronously open a connection without requiring an asyncio loop. @@ -295,6 +346,18 @@ def connect_blocking(self) -> None: log.debug("Connecting (blocking) to cluster seeds=%r", self._seeds) self._client = new_client_blocking(self._policy, self._seeds) self._connected = True + if PSDK_ENABLE_QUERY_SELECTION: + self._cached_supports_query_selection = compute_query_selection_support_blocking( + self._client, + ) + else: + self._cached_supports_query_selection = False + if PSDK_ENABLE_SERVER_COMPILED_AEL: + self._cached_supports_server_compiled_ael = ( + compute_server_compiled_ael_support_blocking(self._client) + ) + else: + self._cached_supports_server_compiled_ael = False log.info( "Connected seeds=%r", self._seeds, extra={"aerospike.cluster": self._policy.cluster_name}, @@ -313,6 +376,8 @@ def close_blocking(self) -> None: self._client = None self._connected = False log.info("Client closed") + self._cached_supports_query_selection = None + self._cached_supports_server_compiled_ael = None self._namespace_mode_cache.clear() self._supports_mrt_cache = None @@ -487,7 +552,12 @@ def _query( behavior=behavior, indexes_monitor=self._indexes_monitor, namespace_mode_resolver=namespace_mode_resolver, + namespace_mode_resolver_blocking=namespace_mode_resolver_blocking, sdk_client=self, + **cached_ael_capability_kwargs( + self._cached_supports_server_compiled_ael, + self._cached_supports_query_selection, + ), ) builder._single_key = key return builder @@ -505,7 +575,12 @@ def _query( behavior=behavior, indexes_monitor=self._indexes_monitor, namespace_mode_resolver=namespace_mode_resolver, + namespace_mode_resolver_blocking=namespace_mode_resolver_blocking, sdk_client=self, + **cached_ael_capability_kwargs( + self._cached_supports_server_compiled_ael, + self._cached_supports_query_selection, + ), ) builder._keys = keys return builder @@ -535,6 +610,10 @@ def _query( namespace_mode_resolver=namespace_mode_resolver, namespace_mode_resolver_blocking=namespace_mode_resolver_blocking, sdk_client=self, + **cached_ael_capability_kwargs( + self._cached_supports_server_compiled_ael, + self._cached_supports_query_selection, + ), ) @overload diff --git a/aerospike_sdk/aio/operations/query.py b/aerospike_sdk/aio/operations/query.py index 8f06abd..716322f 100644 --- a/aerospike_sdk/aio/operations/query.py +++ b/aerospike_sdk/aio/operations/query.py @@ -78,6 +78,7 @@ AerospikeError, _convert_pac_exception, ) +from aerospike_sdk.feature_gates import cached_ael_capability_kwargs from aerospike_sdk.policy.behavior_settings import Mode, OpKind, OpShape from aerospike_sdk.record_result import RecordResult from aerospike_sdk.record_stream import RecordStream @@ -965,7 +966,9 @@ async def _execute_dataset_query(self) -> RecordStream: log.debug( "dataset query: %s.%s filter=%s chunk=%s hint=%s", self._namespace, self._set_name, - self._filter_expression is not None or bool(self._filter_records), + self._filter_expression is not None + or self._where_ael is not None + or bool(self._filter_records), self._chunk_size, self._query_hint is not None, extra={"aerospike.cluster": _cmd_cluster(self._client)}, @@ -980,40 +983,52 @@ async def _execute_dataset_query(self) -> RecordStream: policy = self._apply_txn(QueryPolicy()) if self._chunk_size is not None and self._chunk_size > 0: policy.max_records = self._chunk_size - if self._filter_expression is not None: - policy.filter_expression = self._filter_expression - hint = self._query_hint + use_server_query_selection = self._use_server_query_selection(hint) + self._apply_dataset_query_policy_filter( + policy, use_server_query_selection=use_server_query_selection, + ) + if hint is not None and hint.query_duration is not None: policy.expected_duration = hint.query_duration - if self._where_ael is not None and self._indexes_monitor is not None: - # Lazy start: the monitor's daemon thread only spins up on the - # first AEL ``where()`` query. ``start()`` is idempotent. - self._indexes_monitor.start(self._client) - # Offload the readiness wait so the event loop isn't pinned for - # the first-fetch case (subsequent calls return immediately). - await asyncio.to_thread(self._indexes_monitor.wait_until_ready) + self._prepare_dataset_query_index_context( + use_server_query_selection=use_server_query_selection, + ) + await self._wait_for_dataset_query_index_context( + use_server_query_selection=use_server_query_selection, + ) - self._resolve_index_context() + if not use_server_query_selection and self._where_ael is not None: + self._resolve_index_context() partition_filter = self._partition_filter or PartitionFilter.all() - if self._where_ael is not None and self._index_context is not None: - self._auto_generate_filters(hint, policy) + self._maybe_auto_generate_filters( + hint, policy, use_server_query_selection=use_server_query_selection, + ) statement = self._build_statement() try: - recordset = await self._client.query(statement, partition_filter, policy=policy) + recordset, plan = await self._run_dataset_query_async( + policy, partition_filter, hint, statement, + use_server_query_selection=use_server_query_selection, + ) except Exception as e: raise _convert_pac_exception(e) from e if self._chunk_size is not None and self._chunk_size > 0: client = self._client - async def _reexecute(pf: PartitionFilter) -> Any: - return await client.query(statement, pf, policy=policy) + if plan is not None: + async def _reexecute(pf: PartitionFilter) -> Any: + return await client.query_with_plan( + statement, pf, plan, policy=policy, + ) + else: + async def _reexecute(pf: PartitionFilter) -> Any: + return await client.query(statement, pf, policy=policy) return RecordStream._from_chunked_pac_recordset( recordset, @@ -1148,6 +1163,10 @@ def _promote(self) -> None: txn=self._txn, namespace_mode_resolver=self._namespace_mode_resolver, namespace_mode_resolver_blocking=self._namespace_mode_resolver_blocking, + **cached_ael_capability_kwargs( + getattr(self._sdk_client_fast, "_cached_supports_server_compiled_ael", None), + getattr(self._sdk_client_fast, "_cached_supports_query_selection", None), + ), ) qb._op_type = self._op_type_fast qb._single_key = self._key diff --git a/aerospike_sdk/aio/session.py b/aerospike_sdk/aio/session.py index fbd9862..f33e91f 100644 --- a/aerospike_sdk/aio/session.py +++ b/aerospike_sdk/aio/session.py @@ -48,6 +48,7 @@ ) from aerospike_sdk.aio.operations.udf import UdfFunctionBuilder from aerospike_sdk.dataset import DataSet +from aerospike_sdk.feature_gates import cached_ael_capability_kwargs from aerospike_sdk.policy.behavior import Behavior, OpKind, OpShape from aerospike_sdk.policy.behavior_settings import Mode from aerospike_sdk.policy.policy_mapper import to_read_policy, to_write_policy @@ -610,6 +611,10 @@ def execute_udf(self, *keys: Key) -> "UdfFunctionBuilder": namespace_mode_resolver=self._resolve_namespace_mode, namespace_mode_resolver_blocking=self._resolve_namespace_mode_blocking, sdk_client=self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) qb._set_current_keys_from_varargs(keys) return UdfFunctionBuilder(qb) @@ -691,6 +696,10 @@ def _build_write_segment( namespace_mode_resolver=self._resolve_namespace_mode, namespace_mode_resolver_blocking=self._resolve_namespace_mode_blocking, sdk_client=self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) target: Union[Key, List[Key]] = all_keys[0] if len(all_keys) == 1 else all_keys return qb._start_write_verb(op_type, target) @@ -746,6 +755,10 @@ def _fast_query_builder(self, key: Key, behavior: Behavior) -> QueryBuilder: self._resolve_namespace_mode, self._resolve_namespace_mode_blocking, self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) builder._single_key = key return builder diff --git a/aerospike_sdk/exceptions.py b/aerospike_sdk/exceptions.py index 1dc4580..facf496 100644 --- a/aerospike_sdk/exceptions.py +++ b/aerospike_sdk/exceptions.py @@ -661,6 +661,9 @@ def _convert_pac_exception(exc: Exception) -> AerospikeError: should use ``raise convert_pac_exception(e) from e``. :func:`_result_code_to_exception` """ + if isinstance(exc, AerospikeError): + return exc + if isinstance(exc, PacServerError): return _result_code_to_exception( exc.result_code, diff --git a/aerospike_sdk/feature_gates.py b/aerospike_sdk/feature_gates.py new file mode 100644 index 0000000..6d6970b --- /dev/null +++ b/aerospike_sdk/feature_gates.py @@ -0,0 +1,36 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""PSDK runtime feature gates (dark-launch until flipped).""" + +from __future__ import annotations + +from typing import Optional + +# Hard-false so field 44 query selection and field 43 server-compiled AEL can +# merge without changing dev behavior. Flip to True when ready to enable. +PSDK_ENABLE_QUERY_SELECTION: bool = False +PSDK_ENABLE_SERVER_COMPILED_AEL: bool = False + + +def cached_ael_capability_kwargs( + supports_server_compiled_ael: Optional[bool], + supports_query_selection: Optional[bool], +) -> dict[str, bool]: + """Build QueryBuilder capability kwargs from connect-time cache.""" + return { + "supports_server_compiled_ael": bool(supports_server_compiled_ael), + "supports_query_selection": bool(supports_query_selection), + } diff --git a/aerospike_sdk/operations_shared.py b/aerospike_sdk/operations_shared.py index 496b880..8ed2afc 100644 --- a/aerospike_sdk/operations_shared.py +++ b/aerospike_sdk/operations_shared.py @@ -58,7 +58,7 @@ ) from aerospike_async.exceptions import ResultCode -from aerospike_sdk.ael.parser import parse_ael +from aerospike_sdk.ael.server_filter import filter_expression_from_ael_string from aerospike_sdk.exceptions import _convert_pac_exception from aerospike_sdk.loggers import SdkLoggers from aerospike_sdk.policy.behavior_settings import Mode, OpKind, OpShape @@ -364,10 +364,38 @@ class is defined). The base's :meth:`bin` reads through that hook so it # is tier-neutral but lives in aio.operations.query, so we avoid the # cross-tier reverse import). _bin_builder_cls: ClassVar[type] = None # type: ignore[assignment] + _ssael_flag: Optional[bool] = None def __init__(self, qb: _QB) -> None: self._qb: _QB = qb + def _resolve_ssael_flag(self) -> bool: + """Lazy snapshot of server-compiled AEL support for this segment's lifetime.""" + flag = self._ssael_flag + if flag is None: + if self._qb is not None: + flag = self._qb._supports_server_compiled_ael + else: + client = getattr(self, "_sdk_client_fast", None) + flag = ( + bool(getattr(client, "_cached_supports_server_compiled_ael", False)) + if client is not None + else False + ) + self._ssael_flag = flag + return flag + + def _expression_from_ael_string_for_ops( + self, expression: Union[str, FilterExpression], + ) -> FilterExpression: + """Resolve AEL for bin expression read/write ops (server-compiled when supported).""" + if not isinstance(expression, str): + return expression + return filter_expression_from_ael_string( + expression, + supports_server_compiled_ael=self._resolve_ssael_flag(), + ) + def with_txn(self, txn: Optional[Txn]) -> Self: """Opt this write into (or out of) a specific transaction. @@ -402,7 +430,7 @@ def where( self for method chaining. """ if isinstance(expression, str): - self._qb._filter_expression = parse_ael(expression) + self._qb._filter_expression = self._qb._filter_expression_from_ael(expression) else: self._qb._filter_expression = expression return self @@ -669,7 +697,7 @@ def select_from( ) -> Self: """Read a computed value into a bin using an AEL expression.""" flags = ExpReadFlags.EVAL_NO_FAIL if ignore_eval_failure else ExpReadFlags.DEFAULT - expr = parse_ael(expression) if isinstance(expression, str) else expression + expr = self._expression_from_ael_string_for_ops(expression) return self._add_op(ExpOperation.read(bin_name, expr, flags)) def insert_from( @@ -686,7 +714,7 @@ def insert_from( ExpWriteFlags.CREATE_ONLY, ignore_op_failure, ignore_eval_failure, delete_if_null, ) - expr = parse_ael(expression) if isinstance(expression, str) else expression + expr = self._expression_from_ael_string_for_ops(expression) return self._add_op(ExpOperation.write(bin_name, expr, flags)) def update_from( @@ -703,7 +731,7 @@ def update_from( ExpWriteFlags.UPDATE_ONLY, ignore_op_failure, ignore_eval_failure, delete_if_null, ) - expr = parse_ael(expression) if isinstance(expression, str) else expression + expr = self._expression_from_ael_string_for_ops(expression) return self._add_op(ExpOperation.write(bin_name, expr, flags)) def upsert_from( @@ -720,7 +748,7 @@ def upsert_from( ExpWriteFlags.DEFAULT, ignore_op_failure, ignore_eval_failure, delete_if_null, ) - expr = parse_ael(expression) if isinstance(expression, str) else expression + expr = self._expression_from_ael_string_for_ops(expression) return self._add_op(ExpOperation.write(bin_name, expr, flags)) def query( @@ -977,6 +1005,10 @@ def include_missing_keys(self): self._promote() return super().include_missing_keys() + def respond_all_keys(self): + self._promote() + return super().respond_all_keys() + def fail_on_filtered_out(self): self._promote() return super().fail_on_filtered_out() diff --git a/aerospike_sdk/query_selection.py b/aerospike_sdk/query_selection.py new file mode 100644 index 0000000..acdb50b --- /dev/null +++ b/aerospike_sdk/query_selection.py @@ -0,0 +1,59 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Server-led query selection capability helpers (field ``44`` explain → execute).""" + +from __future__ import annotations + +from typing import Any + +from aerospike_sdk.feature_gates import PSDK_ENABLE_QUERY_SELECTION + + +def _version_supports_query_selection(version_obj: object) -> bool: + """Call PAC ``Version.supports_query_selection()`` when present.""" + fn = getattr(version_obj, "supports_query_selection", None) + if not callable(fn): + return False + return bool(fn()) + + +async def compute_query_selection_support(pac: Any) -> bool: + """``True`` when every connected node reports query-selection support. + + Mirrors Rust ``Cluster::supports_query_selection()`` (all nodes >= 8.1.3). + """ + if not PSDK_ENABLE_QUERY_SELECTION: + return False + nodes_fn = getattr(pac, "nodes", None) + if not callable(nodes_fn): + return False + nodes = await nodes_fn() + if not nodes: + return False + return all(_version_supports_query_selection(n.version) for n in nodes) + + +def compute_query_selection_support_blocking(pac: Any) -> bool: + """Blocking counterpart of :func:`compute_query_selection_support`.""" + if not PSDK_ENABLE_QUERY_SELECTION: + return False + nodes_fn = getattr(pac, "nodes_blocking", None) + if not callable(nodes_fn): + return False + nodes = nodes_fn() + if not nodes: + return False + return all(_version_supports_query_selection(n.version) for n in nodes) diff --git a/aerospike_sdk/query_shared.py b/aerospike_sdk/query_shared.py index 716912c..12baf2e 100644 --- a/aerospike_sdk/query_shared.py +++ b/aerospike_sdk/query_shared.py @@ -26,6 +26,7 @@ from __future__ import annotations +import asyncio import logging from datetime import datetime, timedelta from dataclasses import dataclass, field @@ -96,6 +97,11 @@ ) from aerospike_async.exceptions import ResultCode +try: + from aerospike_async import QueryWhereFlags +except ImportError: # pragma: no cover - older PAC without Tier-D flags + QueryWhereFlags = None # type: ignore[misc, assignment] + from aerospike_sdk.aio.operations.cdt_read import ( CdtReadBuilder, CdtReadInvertableBuilder, @@ -193,6 +199,7 @@ def _resolve_hll_flags( reject_unsupported_background_write_ops, ) from aerospike_sdk.ael.parser import parse_ael, parse_ael_with_index +from aerospike_sdk.ael.server_filter import filter_expression_from_ael_string from aerospike_sdk.error_strategy import ( ErrorHandler, OnError, @@ -214,10 +221,12 @@ def _resolve_hll_flags( class QueryHint: """Hint for influencing secondary index selection and query scheduling. - Provide ``index_name`` to force a specific named secondary index, or - ``bin_name`` to redirect the filter to a different bin's index. These two - are mutually exclusive. ``query_duration`` overrides the policy's - ``expected_duration`` for this query only. + Provide ``index_name`` as a soft explain hint on the server-led path, or + ``bin_name`` to skip explain and use legacy client-side index selection. + ``index_name`` and ``bin_name`` are mutually exclusive. + + On clusters that support field ``44`` query selection (>= 8.1.3), + ``require_index`` and ``hard_hint`` set Tier-D WHERE flags on explain. Example:: @@ -227,18 +236,21 @@ class QueryHint: ) stream = await ( session.query(dataset) - .filter(Filter.equal("age", 30)) + .where("$.age > 30") .with_hint(hint) .execute() ) Args: - index_name: Force the query to use the named secondary index. - bin_name: Redirect the filter to use a different bin's index. + index_name: Soft index name hint (field ``21`` on explain). + bin_name: Legacy path — skip server explain; client picks index by bin. query_duration: Override ``expected_duration`` on the query policy. + require_index: Explain flag — reject primary-index fallback. + hard_hint: Explain flag — require ``index_name`` to be selected. Raises: - ValueError: If both ``index_name`` and ``bin_name`` are provided. + ValueError: If both ``index_name`` and ``bin_name`` are provided, or + ``hard_hint`` without ``index_name``. See Also: :meth:`QueryBuilder.with_hint` @@ -247,12 +259,17 @@ class QueryHint: index_name: Optional[str] = None bin_name: Optional[str] = None query_duration: Optional[QueryDuration] = None + require_index: bool = False + hard_hint: bool = False def __post_init__(self) -> None: if self.index_name is not None and self.bin_name is not None: raise ValueError( - "index_name and bin_name are mutually exclusive; provide one or neither, not both" + "index_name and bin_name are mutually exclusive; " + "provide one or neither, not both" ) + if self.hard_hint and not self.index_name: + raise ValueError("hard_hint requires index_name") @dataclass @@ -401,6 +418,9 @@ class _QueryBuilderBase: # Set by with_txn(None): the caller explicitly opted out of any # transaction, so the implicit batch-write wrap must not fire either. _txn_opted_out: bool = False + _default_where_ael: Optional[str] = None + _supports_server_compiled_ael: bool = False + _supports_query_selection: bool = False def __init__( self, @@ -417,6 +437,8 @@ def __init__( namespace_mode_resolver: NamespaceModeResolver = None, namespace_mode_resolver_blocking: Optional[Callable[[str], "Mode"]] = None, sdk_client: Optional[Any] = None, + supports_server_compiled_ael: Optional[bool] = None, + supports_query_selection: Optional[bool] = None, ) -> None: """ Initialize a QueryBuilder. @@ -470,6 +492,22 @@ def __init__( self._namespace_mode_resolver = namespace_mode_resolver self._namespace_mode: Optional[Mode] = None self._sdk_client = sdk_client + if supports_server_compiled_ael is True: + self._supports_server_compiled_ael = True + elif ( + supports_server_compiled_ael is None + and sdk_client is not None + and getattr(sdk_client, "supports_server_compiled_ael", False) + ): + self._supports_server_compiled_ael = True + if supports_query_selection is True: + self._supports_query_selection = True + elif ( + supports_query_selection is None + and sdk_client is not None + and getattr(sdk_client, "supports_query_selection", False) + ): + self._supports_query_selection = True if txn is None: self._base_read_policy: Optional[ReadPolicy] = cached_read_policy self._base_write_policy: Optional[WritePolicy] = cached_write_policy @@ -482,6 +520,31 @@ def __init__( self._base_write_policy = None self._base_read_policy_sc = None self._base_write_policy_sc = None + + def _filter_expression_from_ael(self, ael: str) -> FilterExpression: + return filter_expression_from_ael_string( + ael, + supports_server_compiled_ael=self._supports_server_compiled_ael, + ) + + def _resolve_where_filter_expression(self) -> None: + """Materialize a pending string ``where()`` into ``_filter_expression``.""" + if self._where_ael is not None and self._filter_expression is None: + self._filter_expression = self._filter_expression_from_ael(self._where_ael) + + def _resolve_default_filter_expression(self) -> None: + """Materialize a pending string ``default_where()``.""" + if self._default_where_ael is not None and self._default_filter_expression is None: + self._default_filter_expression = self._filter_expression_from_ael( + self._default_where_ael, + ) + + def _effective_filter_expression(self) -> Optional[FilterExpression]: + """Return the active filter, materializing pending AEL strings on demand.""" + self._resolve_where_filter_expression() + self._resolve_default_filter_expression() + return self._filter_expression or self._default_filter_expression + def _apply_txn(self, policy: Any) -> Any: """Stamp this builder's captured txn on an outer policy in place. @@ -753,7 +816,6 @@ def where( """ if isinstance(expression, str): self._where_ael = expression - self._filter_expression = parse_ael(expression) else: self._where_ael = None self._filter_expression = expression @@ -1174,8 +1236,10 @@ def default_where( :meth:`where`: Per-operation filter on the current operation. """ if isinstance(expression, str): - self._default_filter_expression = parse_ael(expression) + self._default_where_ael = expression + self._default_filter_expression = None else: + self._default_where_ael = None self._default_filter_expression = expression return self @@ -1278,7 +1342,7 @@ def _finalize_current_spec(self) -> None: else: return - filt = self._filter_expression or self._default_filter_expression + filt = self._effective_filter_expression() ttl = self._ttl_seconds if self._ttl_seconds is not None else self._default_ttl_seconds # Hand off the current operations list directly; allocate a fresh @@ -1305,6 +1369,7 @@ def _finalize_current_spec(self) -> None: self._bins = None self._with_no_bins = False self._filter_expression = None + self._where_ael = None self._op_type = None self._generation = None self._ttl_seconds = None @@ -1336,7 +1401,7 @@ def _finalize_udf_spec(self) -> None: keys = list(self._keys) else: return - filt = self._filter_expression or self._default_filter_expression + filt = self._effective_filter_expression() udf_args: Optional[List[Any]] = ( list(self._udf_args) if self._udf_args is not None else None ) @@ -1696,6 +1761,177 @@ def _resolve_index_context(self) -> None: ctx = _IndexContext.with_query_set(ctx.namespace, self._set_name, ctx.indexes) self._index_context = ctx + def _dataset_set_name(self) -> Optional[str]: + return self._set_name or None + + def _query_explain_index_hint(self, hint: Optional[QueryHint]) -> Optional[str]: + if hint is None: + return None + return hint.index_name + + def _query_explain_where_flags(self, hint: Optional[QueryHint]) -> Optional[int]: + if hint is None or QueryWhereFlags is None: + return None + flags = QueryWhereFlags.EXPLAIN + if hint.require_index: + flags |= QueryWhereFlags.REQUIRE_INDEX + if hint.hard_hint: + flags |= QueryWhereFlags.HARD_HINT + if flags == QueryWhereFlags.EXPLAIN: + return None + return int(flags) + + def _raise_if_filtered_out_plan(self, plan: Any) -> None: + """Phase-1 plan with no matching records; do not run execute.""" + if plan.is_filtered_out: + raise _result_code_to_exception( + ResultCode.FILTERED_OUT, + "Query plan filtered out by server", + ) + + def _use_server_query_selection(self, hint: Optional[QueryHint]) -> bool: + """Route string-AEL dataset queries through PAC explain→execute (field 44).""" + if self._where_ael is None: + return False + if self._filter_records: + return False + if hint is not None and hint.bin_name is not None: + return False + return self._supports_query_selection + + def _apply_dataset_query_policy_filter( + self, + policy: QueryPolicy, + *, + use_server_query_selection: bool, + ) -> None: + if use_server_query_selection: + return + self._resolve_where_filter_expression() + if self._filter_expression is not None: + policy.filter_expression = self._filter_expression + + def _prepare_dataset_query_index_context( + self, + *, + use_server_query_selection: bool, + ) -> None: + if self._where_ael is None or self._indexes_monitor is None: + return + if use_server_query_selection: + return + self._indexes_monitor.start(self._client) + + async def _wait_for_dataset_query_index_context( + self, + *, + use_server_query_selection: bool, + ) -> None: + if self._where_ael is None or self._indexes_monitor is None: + return + if use_server_query_selection: + return + await asyncio.to_thread(self._indexes_monitor.wait_until_ready) + + def _wait_for_dataset_query_index_context_blocking( + self, + *, + use_server_query_selection: bool, + ) -> None: + if self._where_ael is None or self._indexes_monitor is None: + return + if use_server_query_selection: + return + self._indexes_monitor.wait_until_ready() + + def _maybe_auto_generate_filters( + self, + hint: Optional[QueryHint], + policy: QueryPolicy, + *, + use_server_query_selection: bool, + ) -> None: + if self._where_ael is None or self._index_context is None: + return + if use_server_query_selection: + return + self._auto_generate_filters(hint, policy) + + async def _run_dataset_query_async( + self, + policy: QueryPolicy, + partition_filter: PartitionFilter, + hint: Optional[QueryHint], + statement: Statement, + *, + use_server_query_selection: bool, + ) -> tuple[Any, Any | None]: + """Run dataset query; returns (recordset, plan) when server selection was used.""" + if not use_server_query_selection: + recordset = await self._client.query( + statement, partition_filter, policy=policy, + ) + return recordset, None + + assert self._where_ael is not None + plan = await self._client.query_explain( + self._namespace, + self._where_ael, + set_name=self._dataset_set_name(), + index_name_hint=self._query_explain_index_hint(hint), + explain_where_flags=self._query_explain_where_flags(hint), + policy=policy, + ) + log.debug( + "Server query selection: explain→execute for %s.%s selection=%s index=%s", + self._namespace, + self._set_name, + plan.selection, + plan.index_name, + ) + self._raise_if_filtered_out_plan(plan) + recordset = await self._client.query_with_plan( + statement, partition_filter, plan, policy=policy, + ) + return recordset, plan + + def _run_dataset_query_blocking( + self, + policy: QueryPolicy, + partition_filter: PartitionFilter, + hint: Optional[QueryHint], + statement: Statement, + *, + use_server_query_selection: bool, + ) -> tuple[Any, Any | None]: + if not use_server_query_selection: + recordset = self._client.query_blocking( + statement, partition_filter, policy=policy, + ) + return recordset, None + + assert self._where_ael is not None + plan = self._client.query_explain_blocking( + self._namespace, + self._where_ael, + set_name=self._dataset_set_name(), + index_name_hint=self._query_explain_index_hint(hint), + explain_where_flags=self._query_explain_where_flags(hint), + policy=policy, + ) + log.debug( + "Server query selection: explain→execute for %s.%s selection=%s index=%s", + self._namespace, + self._set_name, + plan.selection, + plan.index_name, + ) + self._raise_if_filtered_out_plan(plan) + recordset = self._client.query_with_plan_blocking( + statement, partition_filter, plan, policy=policy, + ) + return recordset, plan + def _auto_generate_filters( self, hint: Optional[QueryHint], @@ -5015,7 +5251,19 @@ def select_from( The parent builder for method chaining. """ flags = ExpReadFlags.EVAL_NO_FAIL if ignore_eval_failure else ExpReadFlags.DEFAULT - expr = parse_ael(expression) if isinstance(expression, str) else expression + if isinstance(expression, str): + materialize = getattr(self._parent, "_filter_expression_from_ael", None) + if materialize is not None: + expr = materialize(expression) + else: + expr = filter_expression_from_ael_string( + expression, + supports_server_compiled_ael=getattr( + self._parent, "_supports_server_compiled_ael", False, + ), + ) + else: + expr = expression self._parent.add_operation(ExpOperation.read(self._bin, expr, flags)) # type: ignore[union-attr] return self._parent diff --git a/aerospike_sdk/server_compiled_ael.py b/aerospike_sdk/server_compiled_ael.py new file mode 100644 index 0000000..ba9dfc0 --- /dev/null +++ b/aerospike_sdk/server_compiled_ael.py @@ -0,0 +1,70 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Server-compiled AEL filter capability helpers (field **43** ``[128, ""]``).""" + +from __future__ import annotations + +from typing import Any + +from aerospike_async import FilterExpression + +from aerospike_sdk.feature_gates import PSDK_ENABLE_SERVER_COMPILED_AEL + + +def _version_supports_server_compiled_ael(version_obj: object) -> bool: + """Call PAC ``Version.supports_server_compiled_ael()`` when present.""" + fn = getattr(version_obj, "supports_server_compiled_ael", None) + if not callable(fn): + return False + return bool(fn()) + + +def _pac_exposes_server_compiled_factory() -> bool: + return callable(getattr(FilterExpression, "from_server_compiled_ael", None)) + + +async def compute_server_compiled_ael_support(pac: Any) -> bool: + """``True`` when every connected node reports server-compiled AEL support. + + Mirrors Rust ``Cluster::supports_server_compiled_ael()`` (all nodes >= 8.1.3) + and requires PAC ``FilterExpression.from_server_compiled_ael``. + """ + if not PSDK_ENABLE_SERVER_COMPILED_AEL: + return False + if not _pac_exposes_server_compiled_factory(): + return False + nodes_fn = getattr(pac, "nodes", None) + if not callable(nodes_fn): + return False + nodes = await nodes_fn() + if not nodes: + return False + return all(_version_supports_server_compiled_ael(n.version) for n in nodes) + + +def compute_server_compiled_ael_support_blocking(pac: Any) -> bool: + """Blocking counterpart of :func:`compute_server_compiled_ael_support`.""" + if not PSDK_ENABLE_SERVER_COMPILED_AEL: + return False + if not _pac_exposes_server_compiled_factory(): + return False + nodes_fn = getattr(pac, "nodes_blocking", None) + if not callable(nodes_fn): + return False + nodes = nodes_fn() + if not nodes: + return False + return all(_version_supports_server_compiled_ael(n.version) for n in nodes) diff --git a/aerospike_sdk/sync/client.py b/aerospike_sdk/sync/client.py index ff73abe..6f3a751 100644 --- a/aerospike_sdk/sync/client.py +++ b/aerospike_sdk/sync/client.py @@ -47,7 +47,14 @@ from aerospike_sdk.policy.behavior_settings import Mode from aerospike_sdk.policy.sdk_config_loader import fill_hard_defaults from aerospike_sdk.policy.system_settings import SystemSettings +from aerospike_sdk.feature_gates import ( + PSDK_ENABLE_QUERY_SELECTION, + PSDK_ENABLE_SERVER_COMPILED_AEL, + cached_ael_capability_kwargs, +) +from aerospike_sdk.query_selection import compute_query_selection_support_blocking from aerospike_sdk.sdk_config_monitor import SdkConfigSource, SyncSdkConfigMonitor +from aerospike_sdk.server_compiled_ael import compute_server_compiled_ael_support_blocking if TYPE_CHECKING: # avoid circular imports — type-only annotations from aerospike_sdk.sync.operations.index import IndexBuilder @@ -151,6 +158,8 @@ def __init__( # Shared by all sessions from this client; avoids repeated # namespace/ info probes when callers use multiple sessions. self._namespace_mode_cache: Dict[str, Mode] = {} + self._cached_supports_query_selection: Optional[bool] = None + self._cached_supports_server_compiled_ael: Optional[bool] = None # Resolved SDK-level settings (file over programmatic over defaults). # A frozen snapshot swapped wholesale by the config monitor, so the # operation path reads it lock-free. @@ -174,6 +183,7 @@ def _supports_mrt_blocking(self) -> bool: if self._supports_mrt_cache is None: nodes_fn = getattr(self._client, "nodes_blocking", None) if nodes_fn is None: + self._supports_mrt_cache = False return False nodes = nodes_fn() self._supports_mrt_cache = bool(nodes) and all( @@ -222,10 +232,23 @@ def connect(self) -> None: else: self._client = new_client_blocking(self._policy, self._seeds) self._connected = True + if PSDK_ENABLE_QUERY_SELECTION: + self._cached_supports_query_selection = compute_query_selection_support_blocking( + self._client, + ) + else: + self._cached_supports_query_selection = False + if PSDK_ENABLE_SERVER_COMPILED_AEL: + self._cached_supports_server_compiled_ael = ( + compute_server_compiled_ael_support_blocking(self._client) + ) + else: + self._cached_supports_server_compiled_ael = False log.info( "Connected seeds=%r", self._seeds, extra={"aerospike.cluster": self._policy.cluster_name}, ) + # IndexesMonitor starts lazily on the first AEL ``where()`` query. def close(self) -> None: """Close the connection synchronously. @@ -243,6 +266,8 @@ def close(self) -> None: self._client = None self._connected = False log.info("Client closed") + self._cached_supports_query_selection = None + self._cached_supports_server_compiled_ael = None self._namespace_mode_cache.clear() self._supports_mrt_cache = None @@ -281,6 +306,28 @@ def _async_client(self) -> AsyncClient: :class:`~aerospike_sdk.aio.client.Client`.""" return self.underlying_client + @property + def supports_query_selection(self) -> bool: + """``True`` when all cluster nodes support field ``44`` query selection (>= 8.1.3). + + Computed at :meth:`connect` from PAC ``Version.supports_query_selection()`` + on every node. + """ + if not self._connected or self._client is None: + return False + return bool(self._cached_supports_query_selection) + + @property + def supports_server_compiled_ael(self) -> bool: + """``True`` when server-compiled AEL filters are usable on this connection. + + Requires all nodes >= 8.1.3 (PAC ``Version.supports_server_compiled_ael``) + and PAC ``FilterExpression.from_server_compiled_ael``. Cached at connect. + """ + if not self._connected or self._client is None: + return False + return bool(self._cached_supports_server_compiled_ael) + def _ensure_connected(self) -> SyncClient: """Connect if not already connected; return ``self`` for chaining.""" if not self._connected: diff --git a/aerospike_sdk/sync/operations/query.py b/aerospike_sdk/sync/operations/query.py index 2ceda05..1c7360d 100644 --- a/aerospike_sdk/sync/operations/query.py +++ b/aerospike_sdk/sync/operations/query.py @@ -42,6 +42,7 @@ ) from aerospike_sdk.sync.operations.query_dispatch import _BlockingQueryDispatch from aerospike_sdk.exceptions import _convert_pac_exception +from aerospike_sdk.feature_gates import cached_ael_capability_kwargs from aerospike_sdk.operations_shared import ( _OP_TYPE_TO_REA, _SingleKeyWriteSegmentBase, @@ -446,6 +447,10 @@ def _promote(self) -> None: # type: ignore[override] txn=self._txn, namespace_mode_resolver=self._namespace_mode_resolver, namespace_mode_resolver_blocking=self._namespace_mode_resolver_blocking, + **cached_ael_capability_kwargs( + getattr(self._sdk_client_fast, "_cached_supports_server_compiled_ael", None), + getattr(self._sdk_client_fast, "_cached_supports_query_selection", None), + ), ) qb._op_type = self._op_type_fast qb._single_key = self._key diff --git a/aerospike_sdk/sync/operations/query_dispatch.py b/aerospike_sdk/sync/operations/query_dispatch.py index 8fd6934..17dd308 100644 --- a/aerospike_sdk/sync/operations/query_dispatch.py +++ b/aerospike_sdk/sync/operations/query_dispatch.py @@ -738,7 +738,9 @@ def _execute_dataset_query_blocking(self) -> Any: log.debug( "dataset query (blocking): %s.%s filter=%s chunk=%s hint=%s", self._namespace, self._set_name, - self._filter_expression is not None or bool(self._filter_records), + self._filter_expression is not None + or self._where_ael is not None + or bool(self._filter_records), self._chunk_size, self._query_hint is not None, extra={"aerospike.cluster": _cmd_cluster(self._client)}, @@ -753,37 +755,52 @@ def _execute_dataset_query_blocking(self) -> Any: policy = self._apply_txn(QueryPolicy()) if self._chunk_size is not None and self._chunk_size > 0: policy.max_records = self._chunk_size - if self._filter_expression is not None: - policy.filter_expression = self._filter_expression - hint = self._query_hint + use_server_query_selection = self._use_server_query_selection(hint) + self._apply_dataset_query_policy_filter( + policy, use_server_query_selection=use_server_query_selection, + ) + if hint is not None and hint.query_duration is not None: policy.expected_duration = hint.query_duration - if self._where_ael is not None and self._indexes_monitor is not None: - # Lazy start (idempotent); mirrors the async path. - self._indexes_monitor.start(self._client) - self._indexes_monitor.wait_until_ready() + self._prepare_dataset_query_index_context( + use_server_query_selection=use_server_query_selection, + ) + self._wait_for_dataset_query_index_context_blocking( + use_server_query_selection=use_server_query_selection, + ) - self._resolve_index_context() + if not use_server_query_selection and self._where_ael is not None: + self._resolve_index_context() partition_filter = self._partition_filter or PartitionFilter.all() - if self._where_ael is not None and self._index_context is not None: - self._auto_generate_filters(hint, policy) + self._maybe_auto_generate_filters( + hint, policy, use_server_query_selection=use_server_query_selection, + ) statement = self._build_statement() try: - recordset = self._client.query_blocking(statement, partition_filter, policy=policy) + recordset, plan = self._run_dataset_query_blocking( + policy, partition_filter, hint, statement, + use_server_query_selection=use_server_query_selection, + ) except Exception as e: raise _convert_pac_exception(e) from e if self._chunk_size is not None and self._chunk_size > 0: client = self._client - def _reexecute_blocking(pf: PartitionFilter) -> Any: - return client.query_blocking(statement, pf, policy=policy) + if plan is not None: + def _reexecute_blocking(pf: PartitionFilter) -> Any: + return client.query_with_plan_blocking( + statement, pf, plan, policy=policy, + ) + else: + def _reexecute_blocking(pf: PartitionFilter) -> Any: + return client.query_blocking(statement, pf, policy=policy) return (recordset, _reexecute_blocking) diff --git a/aerospike_sdk/sync/session.py b/aerospike_sdk/sync/session.py index f54f438..37bf509 100644 --- a/aerospike_sdk/sync/session.py +++ b/aerospike_sdk/sync/session.py @@ -28,6 +28,7 @@ from aerospike_async import Key, Record, Txn, UDFLang from aerospike_sdk.dataset import DataSet +from aerospike_sdk.feature_gates import cached_ael_capability_kwargs from aerospike_sdk.session_shared import NamespaceScStatus, SessionBase from aerospike_sdk.policy.behavior import Behavior, OpKind, OpShape from aerospike_sdk.policy.behavior_settings import Mode @@ -282,6 +283,10 @@ def _build_sync_query_builder( namespace_mode_resolver=None, namespace_mode_resolver_blocking=self._resolve_namespace_mode_blocking, sdk_client=self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) builder._single_key = key return builder @@ -303,6 +308,10 @@ def _build_sync_query_builder( namespace_mode_resolver=None, namespace_mode_resolver_blocking=self._resolve_namespace_mode_blocking, sdk_client=self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) builder._keys = keys return builder @@ -329,6 +338,10 @@ def _build_sync_query_builder( namespace_mode_resolver=None, namespace_mode_resolver_blocking=self._resolve_namespace_mode_blocking, sdk_client=self._client, + **cached_ael_capability_kwargs( + self._client._cached_supports_server_compiled_ael, + self._client._cached_supports_query_selection, + ), ) def background_task(self) -> SyncBackgroundTaskSession: diff --git a/conftest.py b/conftest.py index 0242165..322cdd9 100644 --- a/conftest.py +++ b/conftest.py @@ -542,6 +542,33 @@ async def supports_query_ops_projection_ext(server_version): return server_version is not None and server_version >= SERVER_8_1_2 +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def supports_query_selection(aerospike_host, client_policy): + """``True`` when connected nodes report query-selection support via PAC. + + Uses :func:`aerospike_sdk.query_selection.compute_query_selection_support` + (PAC ``Version.supports_query_selection()`` on every node), not the raw + server ``build`` string. Tests that exercise field ``44`` explain→execute + should ``pytest.skip`` when this is ``False``. + """ + if not aerospike_host: + return False + from aerospike_sdk.feature_gates import PSDK_ENABLE_QUERY_SELECTION + from aerospike_sdk.query_selection import compute_query_selection_support + + if not PSDK_ENABLE_QUERY_SELECTION: + return False + + try: + client = await new_client(client_policy, aerospike_host) + except Exception: + return False + try: + return await compute_query_selection_support(client) + finally: + await client.close() + + @pytest_asyncio.fixture(scope="session", loop_scope="session") async def supports_enhanced_expression_api(server_version): """``True`` when the cluster supports the 8.1.2 enhanced expression API. diff --git a/pyproject.toml b/pyproject.toml index 07b401d..d49ff02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,10 @@ asyncio_mode = "auto" asyncio_default_test_loop_scope = "session" asyncio_default_fixture_loop_scope = "session" addopts = "-s" +markers = [ + "requires_server_compiled_ael: needs connected client with server-compiled AEL (see tests/integration/conftest.py)", + "requires_client_side_ael: needs connected client without server-compiled string AEL (client parse path; see tests/integration/conftest.py)", +] testpaths = ["tests"] python_files = ["*_test.py"] python_classes = ["Test*"] diff --git a/tests/integration/async/batch_test.py b/tests/integration/async/batch_test.py index 9be7bb4..53c48d9 100644 --- a/tests/integration/async/batch_test.py +++ b/tests/integration/async/batch_test.py @@ -27,6 +27,8 @@ from aerospike_sdk.dataset import DataSet from aerospike_sdk.exceptions import AerospikeError, ResultCode +from tests.pac_compat import requires_client_side_ael, requires_server_compiled_ael + @pytest.fixture def users(): @@ -627,7 +629,19 @@ async def test_batch_upsert_from(self, cluster, users: DataSet): rec = await rs.first_or_raise() assert rec.record.bins["C"] == (i + 1) * 10 + 1 - async def test_batch_select_from(self, cluster, users: DataSet): + @pytest.mark.parametrize("sum_ael", [ + pytest.param( + "$.A + $.B", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + $.B:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) + async def test_batch_select_from(self, cluster, users: DataSet, sum_ael): """select_from (expression read) in batch context.""" session = cluster.create_session() keys = [users.id(f"bexp_sel_{i}") for i in range(2)] @@ -636,8 +650,8 @@ async def test_batch_select_from(self, cluster, users: DataSet): await session.upsert(keys[1]).put({"A": 10, "B": 7}).execute() stream = await ( - session.query(keys[0]).bin("sum").select_from("$.A + $.B") - .query(keys[1]).bin("sum").select_from("$.A + $.B") + session.query(keys[0]).bin("sum").select_from(sum_ael) + .query(keys[1]).bin("sum").select_from(sum_ael) .execute() ) results = await stream.collect() @@ -697,15 +711,27 @@ def track(key): except Exception: pass + @pytest.mark.parametrize("sum_ael", [ + pytest.param( + "$.A + $.B", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + $.B:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) async def test_stream_mixed_ops_yields_all( - self, cluster, users: DataSet, track_key, + self, cluster, users: DataSet, track_key, sum_ael, ): """Mixed writes + AEL read + delete in one streaming batch. Verifies: - All 4 ops yield a RecordResult (set-equality on input indices). - The streamed expression-read result carries the computed value - (`select_from "$.A + $.B"` → sum bin). + (`select_from` bin+bin sum → sum bin). - Post-batch persisted state matches op semantics: the WRITE actually flipped its bin; the two READS did NOT persist a `sum` bin (select_from is a read, not a write); the DELETE @@ -718,8 +744,8 @@ async def test_stream_mixed_ops_yields_all( stream = await ( session.upsert(keys[0]).bin("A").set_to(99) - .query(keys[1]).bin("sum").select_from("$.A + $.B") - .query(keys[2]).bin("sum").select_from("$.A + $.B") + .query(keys[1]).bin("sum").select_from(sum_ael) + .query(keys[2]).bin("sum").select_from(sum_ael) .delete(keys[3]) .stream() ) @@ -756,8 +782,20 @@ async def test_stream_mixed_ops_yields_all( empty = await (await session.query(keys[3]).execute()).collect() assert empty == [] + @pytest.mark.parametrize("sum_ael", [ + pytest.param( + "$.A + $.B", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + $.B:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) async def test_stream_read_only_ops_dispatch_as_reads( - self, cluster, users: DataSet, track_key, + self, cluster, users: DataSet, track_key, sum_ael, ): """AEL select_from under the read verb dispatches as BatchReadOp on the wire so the server accepts it, even in a lazy write-batch @@ -770,8 +808,8 @@ async def test_stream_read_only_ops_dispatch_as_reads( await session.upsert(k).put({"A": 5 + i, "B": 3}).execute() stream = await ( - session.query(keys[0]).bin("sum").select_from("$.A + $.B") - .query(keys[1]).bin("sum").select_from("$.A + $.B") + session.query(keys[0]).bin("sum").select_from(sum_ael) + .query(keys[1]).bin("sum").select_from(sum_ael) .stream() ) results = await stream.collect() diff --git a/tests/integration/async/complex_batch_test.py b/tests/integration/async/complex_batch_test.py index e5c5f1f..dadc53e 100644 --- a/tests/integration/async/complex_batch_test.py +++ b/tests/integration/async/complex_batch_test.py @@ -202,6 +202,10 @@ async def test_upsert_from_expression(self, session, ds): rec_result = await (await session.query(k).execute()).first_or_raise() rec = rec_result.record + assert "computed" in rec.bins, ( + "expected upsert_from to create bin 'computed'; " + f"bins={rec.bins!r}" + ) assert rec.bins["computed"] == 1006 await _cleanup(session, k) diff --git a/tests/integration/async/exp_test.py b/tests/integration/async/exp_test.py index fc3fe7b..07e4e38 100644 --- a/tests/integration/async/exp_test.py +++ b/tests/integration/async/exp_test.py @@ -18,6 +18,7 @@ Tests expression building and usage with actual database operations. """ +import base64 import pytest from aerospike_async import FilterExpression @@ -25,6 +26,11 @@ from aerospike_sdk import AelParseException, Exp, in_list, map_keys, map_values, val from aerospike_sdk.dataset import DataSet +from tests.pac_compat import ( + assert_dataset_invalid_ael_rejected, + requires_client_side_ael, + requires_server_compiled_ael, +) class TestExpAlias: """Test that Exp is properly aliased to FilterExpression.""" @@ -651,8 +657,10 @@ async def test_where_float_comparison(self, session_with_data): for rec in records: assert rec.bins["B"] > 1.0 + @requires_client_side_ael async def test_where_invalid_ael(self, session_with_data): """Test that invalid AEL raises AelParseException.""" + with pytest.raises(AelParseException): await ( session_with_data.query("test", "exp_test") @@ -660,6 +668,15 @@ async def test_where_invalid_ael(self, session_with_data): .execute() ) + @requires_server_compiled_ael + async def test_where_invalid_ael_server_compiled(self, session_with_data): + """Invalid AEL on server path surfaces as ``PARAMETER_ERROR`` from the server.""" + await assert_dataset_invalid_ael_rejected( + session_with_data.query("test", "exp_test") + .where("this is not valid AEL !!!") + .execute() + ) + # CDT Path Access Tests @@ -669,6 +686,7 @@ async def _seed_cdt_data(cluster, *, wait_for_set_visible): Used by both ``session_with_cdt_data`` (ungated) and ``session_with_cdt_data_812`` (skips unless the default seed is 8.1.2+) so the gated and ungated tests see the exact same shape. + """ session = cluster.create_session() ds = DataSet.of("test", "cdt_test") @@ -714,7 +732,8 @@ async def _drop_cdt_data(session, ds): async def session_with_cdt_data_812(aerospike_host_812_required, make_cluster_definition, wait_for_set_visible): """Connected cluster + CDT dataset on the default 8.1.2+ seed. - Used by tests that exercise convenience wrappers around server-8.1.2 + + Used by tests that exercise convenience wrappers around server-8.1.3 ExpOps (``in_list`` / ``map_keys`` / ``map_values``). The dependent ``aerospike_host_812_required`` fixture connects to the default ``AEROSPIKE_HOST`` and skips the test cleanly unless it is 8.1.2+. @@ -732,6 +751,7 @@ async def session_with_cdt_data(aerospike_host, make_cluster_definition, wait_fo Tests that exercise convenience wrappers around server-8.1.2 ExpOps should consume ``session_with_cdt_data_812`` instead, which uses the default ``AEROSPIKE_HOST`` and skips cleanly unless it is 8.1.2+. + """ async with await make_cluster_definition(aerospike_host).connect() as cluster: session, ds = await _seed_cdt_data(cluster, wait_for_set_visible=wait_for_set_visible) @@ -925,12 +945,25 @@ async def test_bin_exists(self, session_with_cdt_data): assert len(records) == 3 - async def test_list_count_comparison(self, session_with_cdt_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.numbers:LIST.count() > 3", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.numbers.count() > 3", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_count_comparison(self, session_with_cdt_data, ael): """Test $.listBin.count() for getting list size.""" # rec1 has 5 numbers, rec2 has 5 numbers, rec3 has 3 numbers stream = await ( session_with_cdt_data.query("test", "cdt_test") - .where("$.numbers.count() > 3") + .where(ael) + .execute() ) records = [] @@ -942,12 +975,25 @@ async def test_list_count_comparison(self, session_with_cdt_data): for rec in records: assert len(rec.bins["numbers"]) > 3 - async def test_list_count_equals(self, session_with_cdt_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.numbers:LIST.count() == 3", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.numbers.count() == 3", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_count_equals(self, session_with_cdt_data, ael): """Test $.listBin.count() == value.""" # rec3 has exactly 3 numbers stream = await ( session_with_cdt_data.query("test", "cdt_test") - .where("$.numbers.count() == 3") + .where(ael) + .execute() ) records = [] @@ -958,12 +1004,25 @@ async def test_list_count_equals(self, session_with_cdt_data): assert len(records) == 1 assert len(records[0].bins["numbers"]) == 3 - async def test_names_list_count(self, session_with_cdt_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.names:LIST.count() >= 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.names.count() >= 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_names_list_count(self, session_with_cdt_data, ael): """Test count on names list.""" # rec1: 3 names, rec2: 2 names, rec3: 1 name stream = await ( session_with_cdt_data.query("test", "cdt_test") - .where("$.names.count() >= 2") + .where(ael) + .execute() ) records = [] @@ -990,13 +1049,27 @@ async def test_exists_with_and(self, session_with_cdt_data): assert len(records) == 1 assert records[0].bins["info"]["age"] > 30 - async def test_count_with_arithmetic(self, session_with_cdt_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "($.numbers:LIST.count() + $.names:LIST.count()) > 5", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "($.numbers.count() + $.names.count()) > 5", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_count_with_arithmetic(self, session_with_cdt_data, ael): + """Test count() in arithmetic expressions.""" # Count of numbers + count of names > 5 # rec1: 5+3=8, rec2: 5+2=7, rec3: 3+1=4 stream = await ( session_with_cdt_data.query("test", "cdt_test") - .where("($.numbers.count() + $.names.count()) > 5") + .where(ael) + .execute() ) records = [] @@ -1085,12 +1158,25 @@ async def test_list_by_rank_smallest(self, session_with_list_data): assert len(records) == 1 assert min(records[0].bins["values"]) < 5 - async def test_list_by_value(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.values:LIST.[=30,].count() > 0", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.values.[=30].count() > 0", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_by_value(self, session_with_list_data, ael): """Test $.list.[=value] to find items containing specific value.""" # rec1 and rec3 have 30 in their values list stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.values.[=30].count() > 0") + .where(ael) + .execute() ) records = [] @@ -1102,14 +1188,28 @@ async def test_list_by_value(self, session_with_list_data): for rec in records: assert 30 in rec.bins["values"] - async def test_list_index_range(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.values:LIST.[1:3].count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.values.[1:3].count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_index_range(self, session_with_list_data, ael): + """Test $.list.[1:3] to get a range of indices.""" # [1:3] gets indices 1 and 2 (count=2) # We can't directly compare the returned list in AEL, # but we can verify it parses and executes without error stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.values.[1:3].count() == 2") + .where(ael) + .execute() ) records = [] @@ -1120,7 +1220,20 @@ async def test_list_index_range(self, session_with_list_data): # All records should have at least 3 elements, so [1:3] returns 2 items assert len(records) == 4 - async def test_list_index_range_from_start(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.values:LIST.[2:].count() == 3", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.values.[2:].count() == 3", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_index_range_from_start(self, session_with_list_data, ael): + """Test $.list.[2:] to get from index 2 to end.""" # All 5-element lists have 3 items from index 2 # rec1: [30, 40, 50] (3 items) @@ -1129,7 +1242,8 @@ async def test_list_index_range_from_start(self, session_with_list_data): # rec4: [3, 4, 5] (3 items) stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.values.[2:].count() == 3") + .where(ael) + .execute() ) records = [] @@ -1139,14 +1253,28 @@ async def test_list_index_range_from_start(self, session_with_list_data): assert len(records) == 3 - async def test_list_value_range(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.values:LIST.[=10:40].count() == 3", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.values.[=10:40].count() == 3", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_value_range(self, session_with_list_data, ael): + """Test $.list.[=10:40] to get values in range.""" # [=10:40] gets values >= 10 and < 40 # rec1: [10, 20, 30, 40, 50] -> [10, 20, 30] (3 items) # rec2: [5, 15, 25, 35, 45] -> [15, 25, 35] (3 items) stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.values.[=10:40].count() == 3") + .where(ael) + .execute() ) records = [] @@ -1156,12 +1284,25 @@ async def test_list_value_range(self, session_with_list_data): assert len(records) == 2 - async def test_list_rank_range(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.values:LIST.[#0:2].count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.values.[#0:2].count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_rank_range(self, session_with_list_data, ael): """Test $.list.[#0:2] to get smallest 2 items by rank.""" # [#0:2] gets rank 0 and 1 (2 smallest items) stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.values.[#0:2].count() == 2") + .where(ael) + .execute() ) records = [] @@ -1172,12 +1313,25 @@ async def test_list_rank_range(self, session_with_list_data): # All records have at least 2 items assert len(records) == 4 - async def test_list_value_list(self, session_with_list_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.tags:LIST.[=alpha,].count() > 0", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.tags.[=alpha].count() > 0", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_value_list(self, session_with_list_data, ael): """Test $.list.[=a,b,c] to find items matching value list.""" # Find records where tags contain "alpha" stream = await ( session_with_list_data.query("test", "list_ael_test") - .where("$.tags.[=alpha].count() > 0") + .where(ael) + .execute() ) records = [] @@ -1230,12 +1384,25 @@ async def session_with_map_data(aerospike_host, make_cluster_definition, wait_fo class TestAdvancedMapAel: """Test advanced map AEL features.""" - async def test_map_by_value(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{=100,}.count() > 0", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{=100}.count() > 0", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_by_value(self, session_with_map_data, ael): """Test $.map.{=value} to find entries with specific value.""" # Find records where scores contains value 100 stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{=100}.count() > 0") + .where(ael) + .execute() ) records = [] @@ -1246,12 +1413,25 @@ async def test_map_by_value(self, session_with_map_data): assert len(records) == 1 assert 100 in records[0].bins["scores"].values() - async def test_map_index_range(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{0:2}.count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{0:2}.count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_index_range(self, session_with_map_data, ael): """Test $.map.{0:2} to get first 2 entries by index.""" # Get first 2 entries (count=2) stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{0:2}.count() == 2") + .where(ael) + .execute() ) records = [] @@ -1262,7 +1442,20 @@ async def test_map_index_range(self, session_with_map_data): # rec2 has only 2 entries, others have 3 assert len(records) == 3 - async def test_map_value_range(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{=80:95}.count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{=80:95}.count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_value_range(self, session_with_map_data, ael): + """Test $.map.{=80:95} to get values in range.""" # Get values >= 80 and < 95 # rec1: bob=85, alice=90 (2 items) @@ -1270,7 +1463,8 @@ async def test_map_value_range(self, session_with_map_data): # rec3: heidi=88 (1 item) stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{=80:95}.count() == 2") + .where(ael) + .execute() ) records = [] @@ -1280,12 +1474,25 @@ async def test_map_value_range(self, session_with_map_data): assert len(records) == 1 - async def test_map_rank_range(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{#0:2}.count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{#0:2}.count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_rank_range(self, session_with_map_data, ael): """Test $.map.{#0:2} to get smallest 2 values by rank.""" # Get 2 smallest values stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{#0:2}.count() == 2") + .where(ael) + .execute() ) records = [] @@ -1376,12 +1583,25 @@ async def test_nested_map_access(self, session_with_nested_data): assert len(records) == 1 assert records[0].bins["nested_map"]["a"]["aa"] == 100 - async def test_nested_list_count(self, session_with_nested_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.nested_list.[0]:LIST.count() == 3", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.nested_list.[0].count() == 3", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_nested_list_count(self, session_with_nested_data, ael): """Test $.list.[0].count() - count of nested list.""" # nested_list[0] has 3 elements for rec1, 2 for rec2 stream = await ( session_with_nested_data.query("test", "nested_ael_test") - .where("$.nested_list.[0].count() == 3") + .where(ael) + .execute() ) records = [] @@ -1392,11 +1612,24 @@ async def test_nested_list_count(self, session_with_nested_data): assert len(records) == 1 assert len(records[0].bins["nested_list"][0]) == 3 - async def test_list_size_simple(self, session_with_nested_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.simple_list:LIST.count() == 5", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.simple_list.count() == 5", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_size_simple(self, session_with_nested_data, ael): """Test $.list.count() - basic list size.""" stream = await ( session_with_nested_data.query("test", "nested_ael_test") - .where("$.simple_list.count() == 5") + .where(ael) + .execute() ) records = [] @@ -1425,12 +1658,25 @@ async def test_nested_list_with_rank(self, session_with_nested_data): class TestMapKeyOperationsAel: """Tests for map key range and key list operations.""" - async def test_map_key_list(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{alice,bob}.count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{alice,bob}.count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_key_list(self, session_with_map_data, ael): """Test $.map.{a,b,c} - get entries by key list.""" # Get entries for keys alice and bob from scores stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{alice,bob}.count() == 2") + .where(ael) + .execute() ) records = [] @@ -1441,12 +1687,25 @@ async def test_map_key_list(self, session_with_map_data): # Only rec1 has both alice and bob assert len(records) == 1 - async def test_map_key_range(self, session_with_map_data): - """Test $.map.{a-d} - get entries by key range.""" + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{@alice:dave}.count() >= 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{alice-dave}.count() >= 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_key_range(self, session_with_map_data, ael): + """Test $.map.{@a:b} - map key range (server AEL; bare {a:b} is index-only).""" # Get entries with keys from 'a' to 'd' (alice, bob, charlie) stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{alice-dave}.count() >= 2") + .where(ael) + .execute() ) records = [] @@ -1499,13 +1758,27 @@ async def session_with_relative_range_data(aerospike_host, make_cluster_definiti class TestRelativeRangeAel: """Tests for relative rank/index range operations.""" - async def test_list_rank_range_relative(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.numbers:LIST.[#0:2~5].count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.numbers.[#0:2~5].count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_rank_range_relative(self, session_with_relative_range_data, ael): + """Test $.list.[#rank:end~value] - list value-relative rank range.""" # Get items with rank 0 to 2 (count=2) relative to value 5 # For rec1 [0, 4, 5, 9, 11, 15]: value 5 is at index 2, rank 0-2 relative gets [5,9] stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.numbers.[#0:2~5].count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1516,12 +1789,25 @@ async def test_list_rank_range_relative(self, session_with_relative_range_data): # Just verify it executes without error - relative rank semantics are complex assert isinstance(records, list) - async def test_list_rank_range_relative_no_count(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.numbers:LIST.[#0:~5].count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.numbers.[#0:~5].count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_rank_range_relative_no_count(self, session_with_relative_range_data, ael): """Test $.list.[#rank:~value] - list value-relative rank range without end count.""" # Get all items from rank 0 relative to value 5 stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.numbers.[#0:~5].count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1532,12 +1818,25 @@ async def test_list_rank_range_relative_no_count(self, session_with_relative_ran # Just verify it executes without error assert isinstance(records, list) - async def test_list_rank_range_relative_inverted(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.numbers:LIST.[!#0:2~5].count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.numbers.[!#0:2~5].count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_list_rank_range_relative_inverted(self, session_with_relative_range_data, ael): """Test $.list.[!#rank:end~value] - inverted list value-relative rank range.""" # Get items NOT in rank range stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.numbers.[!#0:2~5].count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1548,12 +1847,25 @@ async def test_list_rank_range_relative_inverted(self, session_with_relative_ran # Just verify it executes without error assert isinstance(records, list) - async def test_map_rank_range_relative(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{#-1:1~80}.count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{#-1:1~80}.count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_rank_range_relative(self, session_with_relative_range_data, ael): """Test $.map.{#rank:end~value} - map value-relative rank range.""" # Get map entries with rank relative to value 80 stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{#-1:1~80}.count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1563,11 +1875,24 @@ async def test_map_rank_range_relative(self, session_with_relative_range_data): assert len(records) >= 1 - async def test_map_rank_range_relative_no_count(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{#-2:~80}.count() >= 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{#-2:~80}.count() >= 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_rank_range_relative_no_count(self, session_with_relative_range_data, ael): """Test $.map.{#rank:~value} - map value-relative rank range without end count.""" stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{#-2:~80}.count() >= 2") + .where(ael) + .execute() ) records = [] @@ -1577,11 +1902,24 @@ async def test_map_rank_range_relative_no_count(self, session_with_relative_rang assert len(records) >= 1 - async def test_map_rank_range_relative_inverted(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{!#-1:1~80}.count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{!#-1:1~80}.count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_rank_range_relative_inverted(self, session_with_relative_range_data, ael): """Test $.map.{!#rank:end~value} - inverted map value-relative rank range.""" stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{!#-1:1~80}.count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1591,12 +1929,25 @@ async def test_map_rank_range_relative_inverted(self, session_with_relative_rang assert len(records) >= 1 - async def test_map_index_range_relative(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{0:1~bob}.count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{0:1~bob}.count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_index_range_relative(self, session_with_relative_range_data, ael): """Test $.map.{start:end~key} - map key-relative index range.""" # Get map entries at index 0 to 1 relative to key "bob" stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{0:1~bob}.count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1606,11 +1957,24 @@ async def test_map_index_range_relative(self, session_with_relative_range_data): assert len(records) >= 1 - async def test_map_index_range_relative_no_count(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{0:~bob}.count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{0:~bob}.count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_index_range_relative_no_count(self, session_with_relative_range_data, ael): """Test $.map.{start:~key} - map key-relative index range without end count.""" stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{0:~bob}.count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1620,11 +1984,24 @@ async def test_map_index_range_relative_no_count(self, session_with_relative_ran assert len(records) >= 1 - async def test_map_index_range_relative_inverted(self, session_with_relative_range_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{!0:1~bob}.count() >= 1", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{!0:1~bob}.count() >= 1", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_index_range_relative_inverted(self, session_with_relative_range_data, ael): """Test $.map.{!start:end~key} - inverted map key-relative index range.""" stream = await ( session_with_relative_range_data.query("test", "rel_range_test") - .where("$.scores.{!0:1~bob}.count() >= 1") + .where(ael) + .execute() ) records = [] @@ -1638,8 +2015,9 @@ async def test_map_index_range_relative_inverted(self, session_with_relative_ran class TestAelErrorHandling: """Tests for AEL error handling.""" - async def test_invalid_ael_syntax(self, session_with_cdt_data): - """Test that invalid AEL raises AelParseException.""" + @requires_client_side_ael + async def test_invalid_ael_syntax_client_parse(self, session_with_cdt_data): + """Invalid AEL raises :class:`AelParseException` when parsed client-side.""" with pytest.raises(AelParseException): await ( session_with_cdt_data.query("test", "cdt_test") @@ -1647,9 +2025,18 @@ async def test_invalid_ael_syntax(self, session_with_cdt_data): .execute() ) - async def test_invalid_list_syntax(self, session_with_cdt_data): - """Test invalid list syntax raises AelParseException.""" - # [stringValue] is not valid - should be [=stringValue] or ["stringValue"] + @requires_server_compiled_ael + async def test_invalid_ael_syntax_server_path(self, session_with_cdt_data): + """Invalid AEL on server path surfaces as ``PARAMETER_ERROR`` from the server.""" + await assert_dataset_invalid_ael_rejected( + session_with_cdt_data.query("test", "cdt_test") + .where("this is not valid AEL !!!") + .execute() + ) + + @requires_client_side_ael + async def test_invalid_list_syntax_client_parse(self, session_with_cdt_data): + """Invalid list path raises :class:`AelParseException` when parsed client-side.""" with pytest.raises(AelParseException): await ( session_with_cdt_data.query("test", "cdt_test") @@ -1657,13 +2044,27 @@ async def test_invalid_list_syntax(self, session_with_cdt_data): .execute() ) + @requires_server_compiled_ael + async def test_invalid_list_syntax_server_path(self, session_with_cdt_data): + """Invalid list path on server path surfaces as ``PARAMETER_ERROR`` from the server.""" + await assert_dataset_invalid_ael_rejected( + session_with_cdt_data.query("test", "cdt_test") + .where("$.numbers.[invalidSyntax] == 100") + .execute() + ) + # ============================================================================= # Advanced expression filter tests (JFC FilterExpTest equivalents) # ============================================================================= +DS = DataSet.of("test", "filter_exp_test") + + @pytest.fixture -async def filter_session(aerospike_host, make_cluster_definition, wait_for_set_visible): +async def session_with_filter_exp( + aerospike_host, make_cluster_definition, wait_for_set_visible, +): """Session with test data matching JFC FilterExpTest setUp. Key "A": A=1, B=1.1, C="abcde", D=1, E=-1 @@ -1672,25 +2073,24 @@ async def filter_session(aerospike_host, make_cluster_definition, wait_for_set_v """ async with await make_cluster_definition(aerospike_host).connect() as cluster: session = cluster.create_session() - ds = DataSet.of("test", "filter_exp_test") for key in ["A", "B", "C"]: try: - await session.delete(ds.id(key)).execute() + await session.delete(DS.id(key)).execute() except Exception: pass - await session.upsert(ds.id("A")).put({"A": 1, "B": 1.1, "C": "abcde", "D": 1, "E": -1}).execute() - await session.upsert(ds.id("B")).put({"A": 2, "B": 2.2, "C": "abcdeabcde", "D": 1, "E": -2}).execute() - await session.upsert(ds.id("C")).put({"A": 0, "B": -1.0, "C": "1"}).execute() + await session.upsert(DS.id("A")).put({"A": 1, "B": 1.1, "C": "abcde", "D": 1, "E": -1}).execute() + await session.upsert(DS.id("B")).put({"A": 2, "B": 2.2, "C": "abcdeabcde", "D": 1, "E": -2}).execute() + await session.upsert(DS.id("C")).put({"A": 0, "B": -1.0, "C": "1"}).execute() await wait_for_set_visible(session, "test", "filter_exp_test", 3) - yield session, ds + yield session for key in ["A", "B", "C"]: try: - await session.delete(ds.id(key)).execute() + await session.delete(DS.id(key)).execute() except Exception: pass @@ -1731,61 +2131,66 @@ async def _assert_matches(self, session, key, ael, bin_name, expected_value): rr = await rs.first_or_raise() assert rr.record.bins[bin_name] == expected_value - async def test_filter_arshift(self, filter_session): + async def test_filter_arshift(self, session_with_filter_exp): """Arithmetic right shift: arshift(-2, 62) == -1 for key B.""" - session, ds = filter_session - key = ds.id("B") - await self._assert_filtered_out(session, key, "not (($.E >> 62) == -1)") - await self._assert_matches(session, key, "($.E >> 62) == -1", "E", -2) + key = DS.id("B") + await self._assert_filtered_out(session_with_filter_exp, key, "not (($.E >> 62) == -1)") + await self._assert_matches(session_with_filter_exp, key, "($.E >> 62) == -1", "E", -2) - async def test_filter_bit_count(self, filter_session): + async def test_filter_bit_count(self, session_with_filter_exp): """Bit count (popcount): countOneBits(1) == 1 for key A.""" - session, ds = filter_session - key = ds.id("A") - await self._assert_filtered_out(session, key, "not (countOneBits($.A) == 1)") - await self._assert_matches(session, key, "countOneBits($.A) == 1", "A", 1) - - async def test_filter_lscan(self, filter_session): - """Left scan: findBitLeft(1, true) == 63 for key A.""" - session, ds = filter_session - key = ds.id("A") - await self._assert_filtered_out(session, key, "not (findBitLeft($.A, true) == 63)") - await self._assert_matches(session, key, "findBitLeft($.A, true) == 63", "A", 1) - - async def test_filter_rscan(self, filter_session): + key = DS.id("A") + await self._assert_filtered_out(session_with_filter_exp, key, "not (countOneBits($.A) == 1)") + await self._assert_matches(session_with_filter_exp, key, "countOneBits($.A) == 1", "A", 1) + + async def test_filter_lscan(self, session_with_filter_exp): + """Left scan: findBitLeft($.A, true) == 63 for key A.""" + key = DS.id("A") + expr = f"findBitLeft($.A, true) == 63" + await self._assert_filtered_out(session_with_filter_exp, key, f"not ({expr})") + await self._assert_matches(session_with_filter_exp, key, expr, "A", 1) + + async def test_filter_rscan(self, session_with_filter_exp): """Right scan: findBitRight(1, true) == 63 for key A.""" - session, ds = filter_session - key = ds.id("A") - await self._assert_filtered_out(session, key, "not (findBitRight($.A, true) == 63)") - await self._assert_matches(session, key, "findBitRight($.A, true) == 63", "A", 1) + key = DS.id("A") + await self._assert_filtered_out(session_with_filter_exp, key, "not (findBitRight($.A, true) == 63)") + await self._assert_matches(session_with_filter_exp, key, "findBitRight($.A, true) == 63", "A", 1) - async def test_filter_min(self, filter_session): + async def test_filter_min(self, session_with_filter_exp): """Min of bins: min(1, 1, -1) == -1 for key A.""" - session, ds = filter_session - key = ds.id("A") - await self._assert_filtered_out(session, key, "not (min($.A, $.D, $.E) == -1)") - await self._assert_matches(session, key, "min($.A, $.D, $.E) == -1", "A", 1) + key = DS.id("A") + await self._assert_filtered_out(session_with_filter_exp, key, "not (min($.A, $.D, $.E) == -1)") + await self._assert_matches(session_with_filter_exp, key, "min($.A, $.D, $.E) == -1", "A", 1) - async def test_filter_max(self, filter_session): + async def test_filter_max(self, session_with_filter_exp): """Max of bins: max(1, 1, -1) == 1 for key A.""" - session, ds = filter_session - key = ds.id("A") - await self._assert_filtered_out(session, key, "not (max($.A, $.D, $.E) == 1)") - await self._assert_matches(session, key, "max($.A, $.D, $.E) == 1", "A", 1) - - async def test_filter_cond(self, filter_session): - """Conditional: when A==1 => D-E == 2 for key A.""" - session, ds = filter_session - key = ds.id("A") - when_expr = ( - "when($.A == 0 => $.D + $.E, " + key = DS.id("A") + await self._assert_filtered_out(session_with_filter_exp, key, "not (max($.A, $.D, $.E) == 1)") + await self._assert_matches(session_with_filter_exp, key, "max($.A, $.D, $.E) == 1", "A", 1) + + @pytest.mark.parametrize("ael", [ + pytest.param( + "(when($.A == 0 => $.D + $.E, " "$.A == 1 => $.D - $.E, " "$.A == 2 => $.D * $.E, " - "default => -1)" - ) - cond_ael = f"({when_expr}) == 2" - await self._assert_filtered_out(session, key, f"not ({cond_ael})") - await self._assert_matches(session, key, cond_ael, "A", 1) + "default => -1)) == 2", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "(when($.A:INT == 0 => $.D:INT + $.E:INT, " + "$.A:INT == 1 => $.D:INT - $.E:INT, " + "$.A:INT == 2 => $.D:INT * $.E:INT, " + "default => -1)) == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) + async def test_filter_cond(self, session_with_filter_exp, ael): + """Conditional ``when(...) == 2`` for key A (A==1 ⇒ D−E==2); client vs typed server AEL.""" + key = DS.id("A") + await self._assert_filtered_out(session_with_filter_exp, key, f"not ({ael})") + await self._assert_matches(session_with_filter_exp, key, ael, "A", 1) class TestInExpression: @@ -1878,11 +2283,12 @@ async def test_in_no_match(self, session_with_cdt_data): class TestConvenienceWrappers: """Tests for in_list(), map_keys(), map_values() convenience functions. - These helpers are thin pass-throughs to the native 8.1.2 ExpOps (see + These helpers are thin pass-throughs to the native 8.1.3 ExpOps (see the docstrings in ``aerospike_sdk/exp.py``). Server versions older than 8.1.2 reject the opcodes with ``ParameterError``, so the tests consume ``session_with_cdt_data_812`` which uses the default ``AEROSPIKE_HOST`` and skips cleanly unless it is 8.1.2+. Callers + that need broader compatibility should build the equivalent expression explicitly with ``Exp.list_get_by_value`` / ``Exp.map_get_by_index_range`` rather than using these wrappers. @@ -1966,6 +2372,21 @@ async def test_map_values(self, session_with_cdt_data_812): assert len(records) == 3 +def _hex_blob_expr(payload: bytes) -> str: + return f"$.payload:BLOB == X'{payload.hex()}'" + + +def _b64_blob_expr(payload: bytes) -> str: + enc = base64.b64encode(payload).decode("ascii") + return f'$.payload.get(type: BLOB) == "{enc}"' + + +@pytest.fixture +async def cluster_ael_blob(aerospike_host, make_cluster_definition): + async with await make_cluster_definition(aerospike_host).connect() as cluster: + yield cluster + + class TestAelMapBlobIntegrationQueries: """Extra map and blob AEL filters exercised against a live server.""" @@ -1984,11 +2405,24 @@ async def test_map_ael_numeric_field_filters_tier(self, session_with_map_data): for rec in records: assert rec.bins["metadata"]["level"] in (2, 3) - async def test_map_ael_key_list_count_on_server(self, session_with_map_data): + @pytest.mark.parametrize("ael", [ + pytest.param( + "$.scores:MAP.{alice,bob}.count() == 2", + id="server-side", + marks=requires_server_compiled_ael, + ), + pytest.param( + "$.scores.{alice,bob}.count() == 2", + id="client-side", + marks=requires_client_side_ael, + ), + ]) + async def test_map_ael_key_list_count_on_server(self, session_with_map_data, ael): """Map key list slice: ``$.scores.{alice,bob}``.""" stream = await ( session_with_map_data.query("test", "map_ael_test") - .where("$.scores.{alice,bob}.count() == 2") + .where(ael) + .execute() ) records = [] @@ -1999,36 +2433,49 @@ async def test_map_ael_key_list_count_on_server(self, session_with_map_data): assert "alice" in records[0].bins["scores"] assert "bob" in records[0].bins["scores"] - async def test_blob_bin_ael_equality_on_server( + @pytest.mark.parametrize( + "make_expr", + [ + pytest.param( + _hex_blob_expr, + id="server-side-hex", + marks=requires_server_compiled_ael, + ), + pytest.param( + _b64_blob_expr, + id="client-side-b64", + marks=requires_client_side_ael, + ), + ], + ) + async def test_blob_bin_ael_equality( self, - aerospike_host, - make_cluster_definition, + cluster_ael_blob, wait_for_set_visible, + make_expr, ): - """BLOB bin filter using a base64 literal in AEL.""" - import base64 + """BLOB bin filter — hex literal (server-side) or base64 literal (client-side).""" + session = cluster_ael_blob.create_session() + k = DataSet.of("test", "ael_blob_srv_it").id("blob_row") + payload = bytes([1, 2, 254]) - async with await make_cluster_definition(aerospike_host).connect() as cluster: - session = cluster.create_session() - k = DataSet.of("test", "ael_blob_srv_it").id("blob_row") - payload = bytes([1, 2, 254]) - try: - await session.delete(k).execute() - except Exception: - pass + try: + await session.delete(k).execute() + except Exception: + pass - await session.upsert(k).put({"payload": payload}).execute() - await wait_for_set_visible(session, "test", "ael_blob_srv_it", 1) + await session.upsert(k).put({"payload": payload}).execute() + await wait_for_set_visible(session, "test", "ael_blob_srv_it", 1) - enc = base64.b64encode(payload).decode("ascii") - stream = await ( - session.query("test", "ael_blob_srv_it") - .where(f'$.payload.get(type: BLOB) == "{enc}"') - .execute() - ) - rows = [r.record async for r in stream] - stream.close() - assert len(rows) == 1 - assert rows[0].bins["payload"] == payload + stream = await ( + session.query("test", "ael_blob_srv_it") + .where(make_expr(payload)) + .execute() + ) + rows = [r.record async for r in stream] + stream.close() - await session.delete(k).execute() + assert len(rows) == 1 + assert rows[0].bins["payload"] == payload + + await session.delete(k).execute() diff --git a/tests/integration/async/expression_ops_test.py b/tests/integration/async/expression_ops_test.py index 66357e2..f5217b4 100644 --- a/tests/integration/async/expression_ops_test.py +++ b/tests/integration/async/expression_ops_test.py @@ -35,6 +35,7 @@ from aerospike_sdk.exceptions import ResultCode, ServerError from aerospike_sdk.exceptions import AerospikeError +from tests.pac_compat import requires_client_side_ael, requires_server_compiled_ael NS = "test" SET = "exp_ops" @@ -127,10 +128,24 @@ async def test_select_from_ignore_eval_failure(self, session): result = await rs.first_or_raise() assert result.record.bins.get("ev") is None - async def test_select_from_returns_nil(self, session): + @pytest.mark.parametrize("bin_ael", [ + pytest.param( + "$.A", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) + async def test_select_from_returns_nil(self, session, bin_ael): """select_from on missing bin with ignore_eval_failure returns None.""" rs = await ( - session.query(_key(KEY_B)).bin("ev").select_from("$.A", ignore_eval_failure=True) + session.query(_key(KEY_B)).bin("ev").select_from( + bin_ael, ignore_eval_failure=True, + ) .execute() ) result = await rs.first_or_raise() @@ -262,13 +277,26 @@ async def test_insert_from_existing_bin_ignore_op_failure(self, cluster): class TestCombinedExpression: - async def test_upsert_from_and_select_from(self, cluster): + @pytest.mark.parametrize("upsert_ael,select_ael", [ + pytest.param( + "$.D + 10", "$.A", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.D:INT + 10", "$.A:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) + async def test_upsert_from_and_select_from(self, cluster, upsert_ael, select_ael): """upsert_from + select_from in same execute.""" session = cluster.create_session() + stream = await ( session.update(_key(KEY_A)) - .bin("D").upsert_from("$.D + 10") - .bin("ev").select_from("$.A") + .bin("D").upsert_from(upsert_ael) + .bin("ev").select_from(select_ael) .execute() ) result = await stream.first_or_raise() @@ -286,13 +314,28 @@ async def test_upsert_from_and_get(self, cluster): assert result is not None assert result.record.bins["C"] == 5 - async def test_write_eval_error_with_ignore(self, cluster): + @pytest.mark.parametrize("upsert_ael,select_ael", [ + pytest.param( + "$.A + 4", "$.A", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + 4", "$.A:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) + async def test_write_eval_error_with_ignore( + self, cluster, upsert_ael, select_ael, + ): """upsert_from + select_from with ignore_eval_failure on both.""" session = cluster.create_session() + stream = await ( session.update(_key(KEY_B)) - .bin("C").upsert_from("$.A + 4", ignore_eval_failure=True) - .bin("ev").select_from("$.A", ignore_eval_failure=True) + .bin("C").upsert_from(upsert_ael, ignore_eval_failure=True) + .bin("ev").select_from(select_ael, ignore_eval_failure=True) .execute() ) result = await stream.first_or_raise() diff --git a/tests/integration/async/geo_test.py b/tests/integration/async/geo_test.py index d488701..81c5950 100644 --- a/tests/integration/async/geo_test.py +++ b/tests/integration/async/geo_test.py @@ -28,6 +28,8 @@ from aerospike_sdk import Exp from aerospike_sdk.dataset import DataSet +from tests.pac_compat import requires_client_side_ael + REGION_SET = "georeg_psdk" INDEX_NAME = "geoidx_psdk" @@ -135,6 +137,7 @@ async def test_ael_geo_compare_returns_5_intersecting_regions(self, session): stream.close() assert count == 5 + @requires_client_side_ael async def test_ael_with_explicit_get_type_geo(self, session): """Same query expressed with explicit ``.get(type: GEO)`` cast on the bin.""" stream = await ( diff --git a/tests/integration/async/implicit_batch_txn_test.py b/tests/integration/async/implicit_batch_txn_test.py index 73ecb6d..a08879b 100644 --- a/tests/integration/async/implicit_batch_txn_test.py +++ b/tests/integration/async/implicit_batch_txn_test.py @@ -38,11 +38,22 @@ from integration.sc_namespace_resolve import ( MultipleScNamespacesError, NoStrongConsistencyNamespace, + pinned_namespace_env_hint, resolve_sc_namespace, skip_reason_no_sc_namespace, ) +async def _namespaces_on_cluster_hint(session) -> str: + try: + names = sorted(await session.info().namespaces()) + except Exception: + return "" + if not names: + return "" + return f" Namespaces on this cluster: {', '.join(names)}." + + @pytest_asyncio.fixture(scope="module", loop_scope="session") async def sc_namespace(cluster_sc): sess = cluster_sc.create_session() @@ -62,7 +73,19 @@ async def session(cluster_sc, sc_namespace): """Session on the SC cluster; skips when the cluster cannot run MRTs.""" if not await cluster_sc._client._supports_mrt(): pytest.skip("cluster does not support multi-record transactions") - return cluster_sc.create_session() + sess = cluster_sc.create_session() + try: + status = await sess.namespace_sc_status(sc_namespace) + except Exception as exc: + pytest.skip( + f"SC namespace {sc_namespace!r} unreachable " + f"({exc}); set AEROSPIKE_HOST_SC / AEROSPIKE_SC_NAMESPACE or stand up SC" + ) + if not status.is_sc: + ns_hint = await _namespaces_on_cluster_hint(sess) + pin = pinned_namespace_env_hint() + pytest.skip(f"{status.detail}{ns_hint}{pin} Implicit batch txn tests require SC.") + return sess @pytest.fixture diff --git a/tests/integration/async/index_monitor_test.py b/tests/integration/async/index_monitor_test.py index cc75bd2..538a869 100644 --- a/tests/integration/async/index_monitor_test.py +++ b/tests/integration/async/index_monitor_test.py @@ -82,6 +82,12 @@ async def cluster(aerospike_host, make_cluster_definition, enterprise): except Exception: pass + # Index monitor tests exercise the client-side cache directly; start it + # explicitly because server query selection skips lazy monitor startup. + sdk_client = c._client + sdk_client._indexes_monitor.start(sdk_client.underlying_client) + await asyncio.to_thread(sdk_client._indexes_monitor.wait_until_ready) + await asyncio.sleep(0.75 if not enterprise else 0.4) yield c diff --git a/tests/integration/async/query_planner_collection_cdt_test.py b/tests/integration/async/query_planner_collection_cdt_test.py new file mode 100644 index 0000000..d909319 --- /dev/null +++ b/tests/integration/async/query_planner_collection_cdt_test.py @@ -0,0 +1,177 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""MAPKEYS / LIST collection CDT ``.exists()`` planner tests (Java ``QueryPlannerCollectionCdtTest``).""" + +from __future__ import annotations + +import pytest_asyncio +from aerospike_async import CollectionIndexType, IndexType + +from aerospike_sdk import DataSet + +from tests.integration.query_selection_helpers import ( + CDT_LIST_BIN, + CDT_LIST_INDEX, + CDT_MAP_BIN, + CDT_MAP_INDEX, + CDT_MAP_KEY, + CDT_SET_NAME, + CDT_SIZE, + NS, + QuerySelection, + cdt_key_name, + create_index_quiet_async, + explain_plan_async, + long_bytes_be, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def qp_cdt_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, + wait_for_set_visible, +): + skip_unless_query_selection(supports_query_selection) + + list_blob_bytes = long_bytes_be(50003) + + cluster_def = make_cluster_definition(aerospike_host) + cluster_def.with_index_refresh_interval(0.25) + async with await cluster_def.connect() as cluster: + client = cluster._sdk_client + pac = client.underlying_client + session = cluster.create_session() + ds = DataSet.of(NS, CDT_SET_NAME) + + for i in range(1, CDT_SIZE + 1): + try: + await session.delete(ds.id(cdt_key_name(i))).execute() + except Exception: + pass + + await create_index_quiet_async( + pac, + set_name=CDT_SET_NAME, + bin_name=CDT_MAP_BIN, + index_name=CDT_MAP_INDEX, + index_type=IndexType.STRING, + collection_type=CollectionIndexType.MAP_KEYS, + ) + await create_index_quiet_async( + pac, + set_name=CDT_SET_NAME, + bin_name=CDT_LIST_BIN, + index_name=CDT_LIST_INDEX, + index_type=IndexType.BLOB, + collection_type=CollectionIndexType.LIST, + ) + + for i in range(1, CDT_SIZE + 1): + map_data = {"mkey1": f"v{i}"} + if i % 2 == 0: + map_data[CDT_MAP_KEY] = f"v{i}" + + if i == 3: + list_data = [list_blob_bytes] + else: + list_data = [long_bytes_be(50000 + i)] + + await ( + session.upsert(ds.id(cdt_key_name(i))) + .put({CDT_MAP_BIN: map_data, CDT_LIST_BIN: list_data}) + .execute() + ) + + await wait_for_set_visible(session, NS, CDT_SET_NAME, CDT_SIZE) + + yield client + + for i in range(1, CDT_SIZE + 1): + try: + await session.delete(ds.id(cdt_key_name(i))).execute() + except Exception: + pass + for index_name in (CDT_MAP_INDEX, CDT_LIST_INDEX): + try: + await client.index(NS, CDT_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestQueryPlannerCollectionCdt: + async def test_plan_map_keys_exists_primary_index_fallback( + self, qp_cdt_client, + ): + pac = qp_cdt_client.underlying_client + where = f"$.{CDT_MAP_BIN}.{CDT_MAP_KEY}.exists() == true" + plan = await explain_plan_async(pac, where, set_name=CDT_SET_NAME) + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + async def test_plan_list_exists_primary_index_fallback(self, qp_cdt_client): + pac = qp_cdt_client.underlying_client + where = f"$.{CDT_LIST_BIN}.[0].exists() == true" + plan = await explain_plan_async(pac, where, set_name=CDT_SET_NAME) + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + async def test_execute_cdt_exists_without_for_bin_returns_matching_rows( + self, qp_cdt_client, + ): + session = qp_cdt_client.create_session() + ds = DataSet.of(NS, CDT_SET_NAME) + map_where = f"$.{CDT_MAP_BIN}.{CDT_MAP_KEY}.exists() == true" + list_where = f"$.{CDT_LIST_BIN}.[0].exists() == true" + + map_stream = await ( + session.query(ds) + .bins([CDT_MAP_BIN]) + .where(map_where) + .execute() + ) + map_count = 0 + try: + async for result in map_stream: + rec = result.record_or_raise() + assert CDT_MAP_KEY in rec.bins[CDT_MAP_BIN] + map_count += 1 + finally: + map_stream.close() + assert map_count == 10 + + list_stream = await ( + session.query(ds) + .bins([CDT_LIST_BIN]) + .where(list_where) + .execute() + ) + list_count = 0 + try: + async for result in list_stream: + rec = result.record_or_raise() + assert len(rec.bins[CDT_LIST_BIN]) == 1 + list_count += 1 + finally: + list_stream.close() + assert list_count == CDT_SIZE diff --git a/tests/integration/async/query_selection_explain_scope_test.py b/tests/integration/async/query_selection_explain_scope_test.py new file mode 100644 index 0000000..a492374 --- /dev/null +++ b/tests/integration/async/query_selection_explain_scope_test.py @@ -0,0 +1,212 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Field ``44`` explain scope across index shapes (Java ``QuerySelectionExplainScopeTest``).""" + +from __future__ import annotations + +import pytest_asyncio +from aerospike_async import CollectionIndexType, IndexType + +from aerospike_sdk import DataSet + +from tests.integration.query_selection_helpers import ( + NS, + QuerySelection, + SCOPE_AGE_BIN, + SCOPE_BLOB_BIN, + SCOPE_BLOB_INDEX, + SCOPE_COUNTRY_BIN, + SCOPE_INT_INDEX, + SCOPE_MAP_BIN, + SCOPE_MAP_INDEX, + SCOPE_MAP_KEY, + SCOPE_SET_NAME, + blob_hex_literal, + count_records_async, + create_index_quiet_async, + explain_plan_async, + long_bytes_be, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def qscexp_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, + wait_for_set_visible, +): + skip_unless_query_selection(supports_query_selection) + + blob_bytes = long_bytes_be(50001) + + cluster_def = make_cluster_definition(aerospike_host) + cluster_def.with_index_refresh_interval(0.25) + async with await cluster_def.connect() as cluster: + client = cluster._sdk_client + pac = client.underlying_client + session = cluster.create_session() + ds = DataSet.of(NS, SCOPE_SET_NAME) + + for key_id in ("k1", "k2"): + try: + await session.delete(ds.id(key_id)).execute() + except Exception: + pass + + await create_index_quiet_async( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_AGE_BIN, + index_name=SCOPE_INT_INDEX, + index_type=IndexType.NUMERIC, + ) + await create_index_quiet_async( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_BLOB_BIN, + index_name=SCOPE_BLOB_INDEX, + index_type=IndexType.BLOB, + ) + await create_index_quiet_async( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_MAP_BIN, + index_name=SCOPE_MAP_INDEX, + index_type=IndexType.STRING, + collection_type=CollectionIndexType.MAP_KEYS, + ) + + await ( + session.upsert(ds.id("k1")) + .put({ + SCOPE_AGE_BIN: 25, + SCOPE_COUNTRY_BIN: "US", + SCOPE_BLOB_BIN: blob_bytes, + SCOPE_MAP_BIN: {SCOPE_MAP_KEY: "v1"}, + }) + .execute() + ) + await ( + session.upsert(ds.id("k2")) + .put({SCOPE_AGE_BIN: 30, SCOPE_COUNTRY_BIN: "CA"}) + .execute() + ) + + await wait_for_set_visible(session, NS, SCOPE_SET_NAME, 2) + + yield client, blob_bytes + + for key_id in ("k1", "k2"): + try: + await session.delete(ds.id(key_id)).execute() + except Exception: + pass + for index_name in (SCOPE_INT_INDEX, SCOPE_BLOB_INDEX, SCOPE_MAP_INDEX): + try: + await client.index(NS, SCOPE_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestQuerySelectionExplainScope: + async def test_explain_scalar_integer_secondary_index_succeeds( + self, qscexp_client, + ): + client, _ = qscexp_client + pac = client.underlying_client + plan = await explain_plan_async( + pac, "$.age == 25", set_name=SCOPE_SET_NAME, + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == SCOPE_INT_INDEX + + async def test_explain_scalar_string_primary_index_no_index_fields( + self, qscexp_client, + ): + client, _ = qscexp_client + pac = client.underlying_client + plan = await explain_plan_async( + pac, "$.country == 'US'", set_name=SCOPE_SET_NAME, + ) + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + async def test_explain_blob_equality_selects_secondary_index( + self, qscexp_client, + ): + client, blob_bytes = qscexp_client + pac = client.underlying_client + where = f"$.{SCOPE_BLOB_BIN} == x'{blob_hex_literal(blob_bytes)}'" + plan = await explain_plan_async(pac, where, set_name=SCOPE_SET_NAME) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == SCOPE_BLOB_INDEX + + async def test_explain_map_keys_exists_primary_index_fallback( + self, qscexp_client, + ): + client, _ = qscexp_client + pac = client.underlying_client + where = f"$.{SCOPE_MAP_BIN}.{SCOPE_MAP_KEY}.exists() == true" + plan = await explain_plan_async(pac, where, set_name=SCOPE_SET_NAME) + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + async def test_execute_blob_equality_returns_matching_row( + self, qscexp_client, + ): + client, blob_bytes = qscexp_client + session = client.create_session() + where = f"$.{SCOPE_BLOB_BIN} == x'{blob_hex_literal(blob_bytes)}'" + + stream = await ( + session.query(DataSet.of(NS, SCOPE_SET_NAME)) + .bins([SCOPE_BLOB_BIN]) + .where(where) + .execute() + ) + assert await count_records_async(stream) == 1 + + async def test_execute_map_keys_exists_returns_matching_rows( + self, qscexp_client, + ): + client, _ = qscexp_client + session = client.create_session() + where = f"$.{SCOPE_MAP_BIN}.{SCOPE_MAP_KEY}.exists() == true" + + stream = await ( + session.query(DataSet.of(NS, SCOPE_SET_NAME)) + .bins([SCOPE_MAP_BIN]) + .where(where) + .execute() + ) + count = 0 + try: + async for result in stream: + rec = result.record_or_raise() + assert SCOPE_MAP_KEY in rec.bins[SCOPE_MAP_BIN] + count += 1 + finally: + stream.close() + assert count > 0 diff --git a/tests/integration/async/query_selection_hint_flags_test.py b/tests/integration/async/query_selection_hint_flags_test.py new file mode 100644 index 0000000..a03ed29 --- /dev/null +++ b/tests/integration/async/query_selection_hint_flags_test.py @@ -0,0 +1,200 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Tier D integration tests: ``REQUIRE_INDEX`` and ``HARD_HINT`` on field 44 explain. + +Port of Java ``QuerySelectionHintFlagsTest``. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from aerospike_async import Filter, ResultCode +from aerospike_async.exceptions import IndexNotFound, InvalidRequest + +from aerospike_sdk import DataSet, QueryHint + +from tests.integration.query_selection_helpers import ( + BIN_AGE, + BIN_COUNTRY, + BIN_SCORE, + HINT_BOGUS_INDEX_NAME, + HINT_INDEX_NAME, + HINT_KEY_PREFIX, + HINT_SCORE_INDEX_NAME, + HINT_SET_NAME, + NS, + QuerySelection, + explain_plan_async, + hint_key_name, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def qselhint_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, + wait_for_index, + wait_for_set_visible, +): + skip_unless_query_selection(supports_query_selection) + + cluster_def = make_cluster_definition(aerospike_host) + cluster_def.with_index_refresh_interval(0.25) + async with await cluster_def.connect() as cluster: + client = cluster._sdk_client + session = cluster.create_session() + ds = DataSet.of(NS, HINT_SET_NAME) + + for suffix in ("1", "2"): + try: + await session.delete(ds.id(hint_key_name(suffix))).execute() + except Exception: + pass + + for index_name, bin_name in ( + (HINT_INDEX_NAME, BIN_AGE), + (HINT_SCORE_INDEX_NAME, BIN_SCORE), + ): + try: + await ( + client.index(NS, HINT_SET_NAME) + .on_bin(bin_name) + .named(index_name) + .numeric() + .create() + ) + except Exception: + pass + + await ( + session.upsert(ds.id(hint_key_name("1"))) + .put({BIN_AGE: 25, BIN_SCORE: 25, BIN_COUNTRY: "US"}) + .execute() + ) + await ( + session.upsert(ds.id(hint_key_name("2"))) + .put({BIN_AGE: 30, BIN_SCORE: 30, BIN_COUNTRY: "CA"}) + .execute() + ) + + await wait_for_set_visible(session, NS, HINT_SET_NAME, 2) + await wait_for_index( + client, NS, HINT_SET_NAME, Filter.range(BIN_AGE, 25, 30), + ) + await wait_for_index( + client, NS, HINT_SET_NAME, Filter.range(BIN_SCORE, 25, 30), + ) + + yield client + + for suffix in ("1", "2"): + try: + await session.delete(ds.id(hint_key_name(suffix))).execute() + except Exception: + pass + for index_name in (HINT_INDEX_NAME, HINT_SCORE_INDEX_NAME): + try: + await client.index(NS, HINT_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestQuerySelectionHintFlags: + async def test_require_index_on_primary_index_plan_fails_explain( + self, qselhint_client, + ): + pac = qselhint_client.underlying_client + with pytest.raises(IndexNotFound) as exc_info: + await explain_plan_async( + pac, + "$.country == 'US'", + set_name=HINT_SET_NAME, + hint=QueryHint(require_index=True), + ) + assert exc_info.value.result_code == ResultCode.INDEX_NOT_FOUND + + async def test_require_index_with_soft_hint_selects_secondary_index( + self, qselhint_client, + ): + pac = qselhint_client.underlying_client + plan = await explain_plan_async( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint(require_index=True, index_name=HINT_SCORE_INDEX_NAME), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == HINT_INDEX_NAME + + async def test_hard_hint_with_matching_index_selects_hinted_index( + self, qselhint_client, + ): + pac = qselhint_client.underlying_client + plan = await explain_plan_async( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint(index_name=HINT_INDEX_NAME, hard_hint=True), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == HINT_INDEX_NAME + + async def test_require_index_and_hard_hint_selects_hinted_index( + self, qselhint_client, + ): + pac = qselhint_client.underlying_client + plan = await explain_plan_async( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint( + index_name=HINT_INDEX_NAME, + require_index=True, + hard_hint=True, + ), + ) + + assert plan.index_name == HINT_INDEX_NAME + + async def test_hard_hint_with_wrong_index_fails_explain(self, qselhint_client): + pac = qselhint_client.underlying_client + with pytest.raises(IndexNotFound) as exc_info: + await explain_plan_async( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint( + index_name=HINT_BOGUS_INDEX_NAME, + hard_hint=True, + ), + ) + assert exc_info.value.result_code == ResultCode.INDEX_NOT_FOUND + + async def test_bad_ael_fails_explain_with_parameter(self, qselhint_client): + pac = qselhint_client.underlying_client + with pytest.raises(InvalidRequest) as exc_info: + await explain_plan_async( + pac, "$.age > 30 and", set_name=HINT_SET_NAME, + ) + assert exc_info.value.result_code == ResultCode.PARAMETER_ERROR diff --git a/tests/integration/async/query_server_selection_test.py b/tests/integration/async/query_server_selection_test.py new file mode 100644 index 0000000..1cafd10 --- /dev/null +++ b/tests/integration/async/query_server_selection_test.py @@ -0,0 +1,478 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Integration tests for two-phase server query selection (explain → execute). + +Requires Aerospike cluster on ``AEROSPIKE_HOST``. Tests are skipped when PAC +reports no query-selection support (``Version.supports_query_selection()``). +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from aerospike_async import Filter, QueryDuration, ResultCode + +from aerospike_sdk import DataSet, Exp, QueryHint, val +from aerospike_sdk.exceptions import AerospikeError + +from tests.integration.query_selection_helpers import ( + BIN_AGE, + BIN_COUNTRY, + BIN_SCORE, + BOGUS_INDEX_NAME, + INDEX_NAME, + NS, + QuerySelection, + QuerySelectionClientFacade, + SCORE_INDEX_NAME, + SET_NAME, + SIZE, + collect_ages_async, + collect_scores_async, + count_records_async, + explain_plan_async, + key_name, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest_asyncio.fixture(scope="module", loop_scope="session") +async def qsel_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, + wait_for_index, + wait_for_set_visible, +): + skip_unless_query_selection(supports_query_selection) + + cluster_def = make_cluster_definition(aerospike_host) + cluster_def.with_index_refresh_interval(0.25) + async with await cluster_def.connect() as cluster: + client = cluster._sdk_client + session = cluster.create_session() + ds = DataSet.of(NS, SET_NAME) + + for i in range(1, SIZE + 1): + try: + await session.delete(ds.id(key_name(i))).execute() + except Exception: + pass + + for i in range(1, SIZE + 1): + country = "US" if i % 2 == 0 else "CA" + await ( + session.upsert(ds.id(key_name(i))) + .put({BIN_AGE: i, BIN_SCORE: i, BIN_COUNTRY: country}) + .execute() + ) + + await wait_for_set_visible(session, NS, SET_NAME, SIZE) + + for index_name, bin_name in ( + (INDEX_NAME, BIN_AGE), + (SCORE_INDEX_NAME, BIN_SCORE), + ): + try: + await ( + client.index(NS, SET_NAME) + .on_bin(bin_name) + .named(index_name) + .numeric() + .create() + ) + except Exception: + pass + + await wait_for_index( + client, NS, SET_NAME, Filter.range(BIN_AGE, 1, SIZE), + ) + await wait_for_index( + client, NS, SET_NAME, Filter.range(BIN_SCORE, 1, SIZE), + ) + + yield QuerySelectionClientFacade(client, session) + + for i in range(1, SIZE + 1): + try: + await session.delete(ds.id(key_name(i))).execute() + except Exception: + pass + for index_name in (INDEX_NAME, SCORE_INDEX_NAME): + try: + await client.index(NS, SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestQueryExplain: + async def test_range_selects_secondary_index(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = await pac.query_explain(NS, where, set_name=SET_NAME) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.namespace == NS + assert plan.set_name == SET_NAME + assert plan.index_name == INDEX_NAME + assert plan.is_secondary_index + + async def test_non_indexed_predicate_selects_primary(self, qsel_client): + pac = qsel_client.underlying_client + plan = await pac.query_explain( + NS, "$.country == 'US'", set_name=SET_NAME, + ) + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.is_primary_index + assert plan.index_name is None + + async def test_contradiction_filtered_out(self, qsel_client): + pac = qsel_client.underlying_client + plan = await pac.query_explain( + NS, "$.age > 100 and $.age < 10", set_name=SET_NAME, + ) + + assert plan.selection == QuerySelection.FILTERED_OUT + assert plan.is_filtered_out + + async def test_for_index_hint(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = await explain_plan_async( + pac, where, hint=QueryHint(index_name=INDEX_NAME), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + async def test_plan_bytes_stable_across_repeated_probes(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + + first = await explain_plan_async(pac, where) + second = await explain_plan_async(pac, where) + + assert first.selection == QuerySelection.SECONDARY_INDEX + assert first.index_name == INDEX_NAME + assert second.selection == first.selection + assert second.index_name == first.index_name + assert second.ael == first.ael + + async def test_index_probe_planner_smoke(self, qsel_client): + """PAC explain path (Python equivalent of Java ``IndexProbePlanner.plan``).""" + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = await explain_plan_async(pac, where) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert plan.ael is not None + + async def test_for_index_hint_on_nonexistent_index(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = await explain_plan_async( + pac, where, hint=QueryHint(index_name=BOGUS_INDEX_NAME), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name != BOGUS_INDEX_NAME + assert plan.index_name == INDEX_NAME + + async def test_for_index_hint_on_wrong_existing_index(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(index_name=SCORE_INDEX_NAME) + plan = await explain_plan_async(pac, where, hint=hint) + + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute() + ) + ages = await collect_ages_async(stream) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name != SCORE_INDEX_NAME + assert plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + +class TestQueryExecute: + async def test_simple_range_returns_matching_records(self, qsel_client): + where = "$.age >= 14 and $.age <= 18" + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + ages = await collect_ages_async(stream) + assert ages == [14, 15, 16, 17, 18] + + async def test_equality_returns_single_record(self, qsel_client): + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where("$.age == 25") + .execute() + ) + ages = await collect_ages_async(stream) + assert ages == [25] + + async def test_primary_index_predicate(self, qsel_client): + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_COUNTRY]) + .where("$.country == 'US'") + .execute() + ) + countries = [] + try: + async for result in stream: + rec = result.record_or_raise() + countries.append(rec.bins[BIN_COUNTRY]) + finally: + stream.close() + assert len(countries) == 25 + assert all(c == "US" for c in countries) + + async def test_plan_then_execute_consistency_for_secondary_index( + self, qsel_client, + ): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + + plan = await explain_plan_async(pac, where) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + assert await collect_ages_async(stream) == [14, 15, 16, 17, 18] + + async def test_compound_predicate(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age > 30 and $.country == 'US'" + + plan = await explain_plan_async(pac, where) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE, BIN_COUNTRY]) + .where(where) + .execute() + ) + ages = [] + try: + async for result in stream: + rec = result.record_or_raise() + assert rec.bins[BIN_COUNTRY] == "US" + assert rec.bins[BIN_AGE] > 30 + ages.append(rec.bins[BIN_AGE]) + finally: + stream.close() + assert sorted(ages) == [32, 34, 36, 38, 40, 42, 44, 46, 48, 50] + + async def test_reading_only_bins_projects_requested_bins(self, qsel_client): + where = "$.age >= 14 and $.age <= 18" + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + ages = [] + try: + async for result in stream: + rec = result.record_or_raise() + ages.append(rec.bins[BIN_AGE]) + assert BIN_COUNTRY not in rec.bins + finally: + stream.close() + assert sorted(ages) == [14, 15, 16, 17, 18] + + async def test_contradiction_raises_filtered_out(self, qsel_client): + with pytest.raises(AerospikeError) as exc_info: + await ( + qsel_client.query(NS, SET_NAME) + .where("$.age > 100 and $.age < 10") + .execute() + ) + assert exc_info.value.result_code == ResultCode.FILTERED_OUT + + async def test_empty_secondary_index_result(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age == 999" + plan = await pac.query_explain(NS, where, set_name=SET_NAME) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + count = await count_records_async(stream) + assert count == 0 + + +class TestQuerySelectionRouting: + async def test_for_bin_hint_uses_legacy_execute_path(self, qsel_client): + where = "$.age >= 14 and $.age <= 18" + + default_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + for_bin_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(QueryHint(bin_name=BIN_AGE)) + .execute() + ) + + default_ages = await collect_ages_async(default_stream) + for_bin_ages = await collect_ages_async(for_bin_stream) + assert default_ages == for_bin_ages == [14, 15, 16, 17, 18] + + async def test_for_index_hint_probes_and_executes(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(index_name=INDEX_NAME) + + plan = await explain_plan_async(pac, where, hint=hint) + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute() + ) + ages = await collect_ages_async(stream) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + async def test_query_duration_only_hint_still_probes_and_executes( + self, qsel_client, + ): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(query_duration=QueryDuration.SHORT) + + plan = await explain_plan_async(pac, where, hint=hint) + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute() + ) + ages = await collect_ages_async(stream) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + async def test_where_exp_uses_non_probe_execute_path(self, qsel_client): + stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where( + Exp.and_([ + Exp.ge(Exp.int_bin(BIN_AGE), val(14)), + Exp.le(Exp.int_bin(BIN_AGE), val(18)), + ]), + ) + .execute() + ) + assert await collect_ages_async(stream) == [14, 15, 16, 17, 18] + + async def test_server_led_matches_legacy_for_bin(self, qsel_client): + where = "$.age > 30 and $.country == 'US'" + + server_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + server_ages = await collect_ages_async(server_stream) + + legacy_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(QueryHint(bin_name=BIN_AGE)) + .execute() + ) + legacy_ages = await collect_ages_async(legacy_stream) + + assert server_ages == legacy_ages + assert server_ages == [32, 34, 36, 38, 40, 42, 44, 46, 48, 50] + + async def test_multiple_indexes_auto_select(self, qsel_client): + pac = qsel_client.underlying_client + age_where = "$.age >= 14 and $.age <= 18" + score_where = "$.score >= 40 and $.score <= 44" + + age_plan = await explain_plan_async(pac, age_where) + score_plan = await explain_plan_async(pac, score_where) + + age_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(age_where) + .execute() + ) + score_stream = await ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_SCORE]) + .where(score_where) + .execute() + ) + ages = await collect_ages_async(age_stream) + scores = await collect_scores_async(score_stream) + + assert age_plan.selection == QuerySelection.SECONDARY_INDEX + assert age_plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + assert score_plan.selection == QuerySelection.SECONDARY_INDEX + assert score_plan.index_name == SCORE_INDEX_NAME + assert scores == [40, 41, 42, 43, 44] + + async def test_no_where_scan_returns_all_records(self, qsel_client): + stream = await qsel_client.query(NS, SET_NAME).execute() + count = await count_records_async(stream) + assert count == SIZE diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..86e1833 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,67 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Integration-test-only pytest hooks and fixtures.""" + +from __future__ import annotations + +import pytest + +from tests.pac_compat import ( + SupportsServerCompiledAel, + skip_if_lacks_server_compiled_ael, + skip_if_server_compiled_ael_available, +) + + +def pytest_runtest_call(item: pytest.Item) -> None: + """Honor AEL path markers once the test's fixtures are materialized.""" + need_server = item.get_closest_marker("requires_server_compiled_ael") is not None + need_client = item.get_closest_marker("requires_client_side_ael") is not None + if not (need_server or need_client): + return + client = resolve_ael_client_from_funcargs(item.funcargs) + if client is None: + pytest.skip( + "AEL path marker present but no client/cluster/session fixture found" + ) + if need_server: + skip_if_lacks_server_compiled_ael(client) + if need_client: + skip_if_server_compiled_ael_available(client) + + +def resolve_ael_client_from_funcargs( + funcargs: dict[str, object], +) -> SupportsServerCompiledAel | None: + """Return a connected SDK client from a test's resolved fixture dict.""" + if "client" in funcargs: + client = funcargs["client"] + if getattr(client, "supports_server_compiled_ael", None) is not None: + return client # type: ignore[return-value] + + for name, value in funcargs.items(): + if name == "cluster" or name.startswith("cluster_"): + sdk_client = getattr(value, "_sdk_client", None) + if sdk_client is not None: + return sdk_client # type: ignore[return-value] + + for name, value in funcargs.items(): + if name == "session" or name.startswith("session_with_"): + client = getattr(value, "client", None) + if getattr(client, "supports_server_compiled_ael", None) is not None: + return client # type: ignore[return-value] + + return None diff --git a/tests/integration/query_selection_helpers.py b/tests/integration/query_selection_helpers.py new file mode 100644 index 0000000..6f450e0 --- /dev/null +++ b/tests/integration/query_selection_helpers.py @@ -0,0 +1,284 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Shared constants and helpers for query-selection integration tests.""" + +from __future__ import annotations + +import struct +from typing import TYPE_CHECKING, Any, Optional + +import pytest + +from aerospike_sdk.feature_gates import PSDK_ENABLE_QUERY_SELECTION + +try: + from aerospike_async import QuerySelection +except ImportError: + QuerySelection = None # type: ignore[misc, assignment] + +requires_pac_query_selection_api = pytest.mark.skipif( + QuerySelection is None, + reason="PAC QuerySelection API not available (requires newer aerospike-async)", +) + + +def skip_unless_query_selection(supports_query_selection: bool) -> None: + """Skip integration tests that need field ``44`` explain→execute routing.""" + if not PSDK_ENABLE_QUERY_SELECTION: + pytest.skip( + "query selection feature gate disabled (PSDK_ENABLE_QUERY_SELECTION)" + ) + if supports_query_selection: + return + pytest.skip("cluster lacks query selection (PAC)") + + +if TYPE_CHECKING: + from aerospike_async import QuerySelection as QuerySelectionType + from aerospike_sdk import QueryHint +else: + QuerySelectionType = Any + + +class QuerySelectionClientFacade: + """Test helper: ``Client`` no longer exposes ``query()`` — delegate to ``Session``. + + Fixtures yield this so selection integration tests keep ``qsel_client.query(ns, set)`` + while still exposing ``underlying_client`` for direct PAC explain probes. + """ + + __slots__ = ("_client", "_session") + + def __init__(self, client: Any, session: Any) -> None: + self._client = client + self._session = session + + @property + def underlying_client(self) -> Any: + return self._client.underlying_client + + def query(self, namespace: str, set_name: str) -> Any: + return self._session.query(namespace=namespace, set_name=set_name) + + def index(self, *args: Any, **kwargs: Any) -> Any: + return self._client.index(*args, **kwargs) + + +NS = "test" +SET_NAME = "qselint" +INDEX_NAME = "qsel_age_idx" +SCORE_INDEX_NAME = "qsel_score_idx" +BOGUS_INDEX_NAME = "qsel_nonexistent_idx" +BIN_AGE = "age" +BIN_SCORE = "score" +BIN_COUNTRY = "country" +KEY_PREFIX = "qselkey" +SIZE = 50 + +# QuerySelectionHintFlagsTest fixture (Java qselhint set) +HINT_SET_NAME = "qselhint" +HINT_INDEX_NAME = "qselhint_age_idx" +HINT_SCORE_INDEX_NAME = "qselhint_score_idx" +HINT_BOGUS_INDEX_NAME = "qselhint_missing_idx" +HINT_KEY_PREFIX = "qselhintkey" + +# QuerySelectionExplainScopeTest fixture (Java qscexp set) +SCOPE_SET_NAME = "qscexp" +SCOPE_INT_INDEX = "qscexp_age_idx" +SCOPE_BLOB_INDEX = "qscexp_bb_idx" +SCOPE_MAP_INDEX = "qscexp_map_idx" +SCOPE_AGE_BIN = "age" +SCOPE_COUNTRY_BIN = "country" +SCOPE_BLOB_BIN = "bb" +SCOPE_MAP_BIN = "map_bin" +SCOPE_MAP_KEY = "mkey2" + +# QueryPlannerCollectionCdtTest fixture (Java qp_cdt set) +CDT_SET_NAME = "qp_cdt" +CDT_KEY_PREFIX = "qpcdt" +CDT_MAP_BIN = "map_bin" +CDT_LIST_BIN = "list_bin" +CDT_MAP_KEY = "mkey2" +CDT_MAP_INDEX = "qp_mapkeys_idx" +CDT_LIST_INDEX = "qp_list_idx" +CDT_SIZE = 20 + + +def key_name(i: int) -> str: + return f"{KEY_PREFIX}{i}" + + +def hint_key_name(suffix: str) -> str: + return f"{HINT_KEY_PREFIX}{suffix}" + + +def cdt_key_name(i: int) -> str: + return f"{CDT_KEY_PREFIX}{i}" + + +def long_bytes_be(value: int) -> bytes: + """8-byte big-endian integer (Java ``Buffer.longToBytes``).""" + return struct.pack(">q", value) + + +def blob_hex_literal(blob_bytes: bytes) -> str: + """Server AEL hex blob literal for equality (Java ``x'...'``).""" + return blob_bytes.hex() + + +def explain_where_flags(hint: Optional["QueryHint"]) -> Optional[int]: + """Map :class:`QueryHint` to PAC ``explain_where_flags`` (field ``44``).""" + from aerospike_async import QueryWhereFlags + + if hint is None: + return None + flags = QueryWhereFlags.EXPLAIN + if hint.require_index: + flags |= QueryWhereFlags.REQUIRE_INDEX + if hint.hard_hint: + flags |= QueryWhereFlags.HARD_HINT + if flags == QueryWhereFlags.EXPLAIN: + return None + return int(flags) + + +async def explain_plan_async(pac, where: str, *, set_name: str = SET_NAME, hint=None): + """Run phase-1 explain (mirrors Java ``IndexProbePlanner.plan``).""" + index_name_hint = hint.index_name if hint is not None else None + return await pac.query_explain( + NS, + where, + set_name=set_name, + index_name_hint=index_name_hint, + explain_where_flags=explain_where_flags(hint), + ) + + +def explain_plan_blocking(pac, where: str, *, set_name: str = SET_NAME, hint=None): + index_name_hint = hint.index_name if hint is not None else None + return pac.query_explain_blocking( + NS, + where, + set_name=set_name, + index_name_hint=index_name_hint, + explain_where_flags=explain_where_flags(hint), + ) + + +async def create_index_quiet_async( + pac, + *, + set_name: str, + bin_name: str, + index_name: str, + index_type, + collection_type=None, +) -> None: + from aerospike_async import ResultCode + + try: + await pac.create_index( + NS, set_name, bin_name, index_name, index_type, collection_type, + ) + except Exception as exc: + if getattr(exc, "result_code", None) != ResultCode.INDEX_FOUND: + raise + + +def create_index_quiet_blocking( + pac, + *, + set_name: str, + bin_name: str, + index_name: str, + index_type, + collection_type=None, +) -> None: + from aerospike_async import ResultCode + + try: + pac.create_index_blocking( + NS, set_name, bin_name, index_name, index_type, collection_type, + ) + except Exception as exc: + if getattr(exc, "result_code", None) != ResultCode.INDEX_FOUND: + raise + + +async def collect_scores_async(stream) -> list[int]: + scores: list[int] = [] + try: + async for result in stream: + rec = result.record_or_raise() + scores.append(rec.bins[BIN_SCORE]) + finally: + stream.close() + return sorted(scores) + + +def collect_scores_sync(stream) -> list[int]: + scores: list[int] = [] + try: + for result in stream: + rec = result.record_or_raise() + scores.append(rec.bins[BIN_SCORE]) + finally: + stream.close() + return sorted(scores) + + +async def collect_ages_async(stream) -> list[int]: + ages: list[int] = [] + try: + async for result in stream: + rec = result.record_or_raise() + ages.append(rec.bins[BIN_AGE]) + finally: + stream.close() + return sorted(ages) + + +def collect_ages_sync(stream) -> list[int]: + ages: list[int] = [] + try: + for result in stream: + rec = result.record_or_raise() + ages.append(rec.bins[BIN_AGE]) + finally: + stream.close() + return sorted(ages) + + +async def count_records_async(stream) -> int: + count = 0 + try: + async for result in stream: + result.record_or_raise() + count += 1 + finally: + stream.close() + return count + + +def count_records_sync(stream) -> int: + count = 0 + try: + for result in stream: + result.record_or_raise() + count += 1 + finally: + stream.close() + return count diff --git a/tests/integration/sync/batch_test.py b/tests/integration/sync/batch_test.py index c56bcc7..dd69a4f 100644 --- a/tests/integration/sync/batch_test.py +++ b/tests/integration/sync/batch_test.py @@ -25,6 +25,8 @@ from aerospike_sdk.policy.behavior_settings import Scope, Settings from aerospike_sdk.sync import Cluster +from tests.pac_compat import requires_client_side_ael, requires_server_compiled_ael + @pytest.fixture def cluster(aerospike_host, make_cluster_definition, enterprise): @@ -146,8 +148,20 @@ def track(key): except Exception: pass + @pytest.mark.parametrize("sum_ael", [ + pytest.param( + "$.A + $.B", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + $.B:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) def test_stream_mixed_ops_yields_all( - self, cluster: Cluster, users: DataSet, track_key, + self, cluster: Cluster, users: DataSet, track_key, sum_ael, ): """Mixed writes + AEL read + delete dispatch correctly via ``batch_stream_blocking``; results yielded one-by-one with idx @@ -156,7 +170,7 @@ def test_stream_mixed_ops_yields_all( Verifies: - All 4 ops yield a RecordResult (set-equality on input indices). - The streamed expression-read result carries the computed value - (`select_from "$.A + $.B"` → sum bin). + (`select_from` bin+bin sum → sum bin). - Post-batch persisted state matches op semantics: the WRITE actually flipped its bin; the two READS did NOT persist a `sum` bin (select_from is a read, not a write); the DELETE @@ -169,8 +183,8 @@ def test_stream_mixed_ops_yields_all( stream = ( session.upsert(keys[0]).bin("A").set_to(99) - .query(keys[1]).bin("sum").select_from("$.A + $.B") - .query(keys[2]).bin("sum").select_from("$.A + $.B") + .query(keys[1]).bin("sum").select_from(sum_ael) + .query(keys[2]).bin("sum").select_from(sum_ael) .delete(keys[3]) .stream() ) @@ -205,8 +219,20 @@ def test_stream_mixed_ops_yields_all( empty = list(session.query(keys[3]).execute()) assert empty == [] + @pytest.mark.parametrize("sum_ael", [ + pytest.param( + "$.A + $.B", + id="client-side", + marks=requires_client_side_ael, + ), + pytest.param( + "$.A:INT + $.B:INT", + id="server-side", + marks=requires_server_compiled_ael, + ), + ]) def test_stream_read_only_ops_dispatch_as_reads( - self, cluster: Cluster, users: DataSet, track_key, + self, cluster: Cluster, users: DataSet, track_key, sum_ael, ): """Read-only op lists (AEL `select_from` under the read verb) land as BatchReadOp on the wire, even in a lazy write-batch stream. @@ -218,8 +244,8 @@ def test_stream_read_only_ops_dispatch_as_reads( session.upsert(k).put({"A": 5 + i, "B": 3}).execute() stream = ( - session.query(keys[0]).bin("sum").select_from("$.A + $.B") - .query(keys[1]).bin("sum").select_from("$.A + $.B") + session.query(keys[0]).bin("sum").select_from(sum_ael) + .query(keys[1]).bin("sum").select_from(sum_ael) .stream() ) results = list(stream) diff --git a/tests/integration/sync/implicit_batch_txn_test.py b/tests/integration/sync/implicit_batch_txn_test.py index ba00c1b..a380bb3 100644 --- a/tests/integration/sync/implicit_batch_txn_test.py +++ b/tests/integration/sync/implicit_batch_txn_test.py @@ -32,11 +32,22 @@ from integration.sc_namespace_resolve import ( MultipleScNamespacesError, NoStrongConsistencyNamespace, + pinned_namespace_env_hint, resolve_sc_namespace_sync, skip_reason_no_sc_namespace, ) +def _namespaces_on_cluster_hint(session) -> str: + try: + names = sorted(session.info().namespaces()) + except Exception: + return "" + if not names: + return "" + return f" Namespaces on this cluster: {', '.join(names)}." + + @pytest.fixture(scope="module") def cluster_sc(aerospike_host_sc, make_cluster_definition): try: @@ -66,7 +77,19 @@ def session(cluster_sc, sc_namespace): # MRT support probe has no Cluster surface; reach through to the client. if not cluster_sc._client._supports_mrt_blocking(): pytest.skip("cluster does not support multi-record transactions") - return cluster_sc.create_session() + sess = cluster_sc.create_session() + try: + status = sess.namespace_sc_status(sc_namespace) + except Exception as exc: + pytest.skip( + f"SC namespace {sc_namespace!r} unreachable " + f"({exc}); set AEROSPIKE_HOST_SC / AEROSPIKE_SC_NAMESPACE or stand up SC" + ) + if not status.is_sc: + ns_hint = _namespaces_on_cluster_hint(sess) + pin = pinned_namespace_env_hint() + pytest.skip(f"{status.detail}{ns_hint}{pin} Implicit batch txn tests require SC.") + return sess @pytest.fixture diff --git a/tests/integration/sync/query_planner_collection_cdt_test.py b/tests/integration/sync/query_planner_collection_cdt_test.py new file mode 100644 index 0000000..0c75040 --- /dev/null +++ b/tests/integration/sync/query_planner_collection_cdt_test.py @@ -0,0 +1,161 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Sync MAPKEYS / LIST CDT planner tests (Java ``QueryPlannerCollectionCdtTest``).""" + +from __future__ import annotations + +import pytest +from aerospike_async import CollectionIndexType, IndexType + +from aerospike_sdk import DataSet + +from tests.integration.query_selection_helpers import ( + CDT_LIST_BIN, + CDT_LIST_INDEX, + CDT_MAP_BIN, + CDT_MAP_INDEX, + CDT_MAP_KEY, + CDT_SET_NAME, + CDT_SIZE, + NS, + QuerySelection, + cdt_key_name, + create_index_quiet_blocking, + explain_plan_blocking, + long_bytes_be, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest.fixture(scope="module") +def qp_cdt_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, +): + skip_unless_query_selection(supports_query_selection) + + cluster_def = make_cluster_definition(aerospike_host, sync=True) + cluster_def.with_index_refresh_interval(0.25) + with cluster_def.connect() as cluster: + client = cluster._sdk_client + pac = client.underlying_client + session = cluster.create_session() + ds = DataSet.of(NS, CDT_SET_NAME) + + for i in range(1, CDT_SIZE + 1): + try: + session.delete(ds.id(cdt_key_name(i))).execute() + except Exception: + pass + + create_index_quiet_blocking( + pac, + set_name=CDT_SET_NAME, + bin_name=CDT_MAP_BIN, + index_name=CDT_MAP_INDEX, + index_type=IndexType.STRING, + collection_type=CollectionIndexType.MAP_KEYS, + ) + create_index_quiet_blocking( + pac, + set_name=CDT_SET_NAME, + bin_name=CDT_LIST_BIN, + index_name=CDT_LIST_INDEX, + index_type=IndexType.BLOB, + collection_type=CollectionIndexType.LIST, + ) + + for i in range(1, CDT_SIZE + 1): + map_data = {"mkey1": f"v{i}"} + if i % 2 == 0: + map_data[CDT_MAP_KEY] = f"v{i}" + list_data = ( + [long_bytes_be(50003)] + if i == 3 + else [long_bytes_be(50000 + i)] + ) + session.upsert(ds.id(cdt_key_name(i))).put( + {CDT_MAP_BIN: map_data, CDT_LIST_BIN: list_data}, + ).execute() + + yield client + + for i in range(1, CDT_SIZE + 1): + try: + session.delete(ds.id(cdt_key_name(i))).execute() + except Exception: + pass + for index_name in (CDT_MAP_INDEX, CDT_LIST_INDEX): + try: + client.index(NS, CDT_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestSyncQueryPlannerCollectionCdt: + def test_plan_map_keys_exists_primary_index_fallback(self, qp_cdt_client): + where = f"$.{CDT_MAP_BIN}.{CDT_MAP_KEY}.exists() == true" + plan = explain_plan_blocking( + qp_cdt_client.underlying_client, where, set_name=CDT_SET_NAME, + ) + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + def test_plan_list_exists_primary_index_fallback(self, qp_cdt_client): + where = f"$.{CDT_LIST_BIN}.[0].exists() == true" + plan = explain_plan_blocking( + qp_cdt_client.underlying_client, where, set_name=CDT_SET_NAME, + ) + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + def test_execute_cdt_exists_without_for_bin_returns_matching_rows( + self, qp_cdt_client, + ): + session = qp_cdt_client.create_session() + ds = DataSet.of(NS, CDT_SET_NAME) + map_where = f"$.{CDT_MAP_BIN}.{CDT_MAP_KEY}.exists() == true" + list_where = f"$.{CDT_LIST_BIN}.[0].exists() == true" + + map_stream = ( + session.query(ds).bins([CDT_MAP_BIN]).where(map_where).execute() + ) + map_count = 0 + try: + for result in map_stream: + rec = result.record_or_raise() + assert CDT_MAP_KEY in rec.bins[CDT_MAP_BIN] + map_count += 1 + finally: + map_stream.close() + assert map_count == 10 + + list_stream = ( + session.query(ds).bins([CDT_LIST_BIN]).where(list_where).execute() + ) + list_count = 0 + try: + for result in list_stream: + rec = result.record_or_raise() + assert len(rec.bins[CDT_LIST_BIN]) == 1 + list_count += 1 + finally: + list_stream.close() + assert list_count == CDT_SIZE diff --git a/tests/integration/sync/query_selection_explain_scope_test.py b/tests/integration/sync/query_selection_explain_scope_test.py new file mode 100644 index 0000000..78c935a --- /dev/null +++ b/tests/integration/sync/query_selection_explain_scope_test.py @@ -0,0 +1,185 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Sync field ``44`` explain scope tests (Java ``QuerySelectionExplainScopeTest``).""" + +from __future__ import annotations + +import pytest +from aerospike_async import CollectionIndexType, IndexType + +from aerospike_sdk import DataSet + +from tests.integration.query_selection_helpers import ( + NS, + QuerySelection, + SCOPE_AGE_BIN, + SCOPE_BLOB_BIN, + SCOPE_BLOB_INDEX, + SCOPE_COUNTRY_BIN, + SCOPE_INT_INDEX, + SCOPE_MAP_BIN, + SCOPE_MAP_INDEX, + SCOPE_MAP_KEY, + SCOPE_SET_NAME, + blob_hex_literal, + count_records_sync, + create_index_quiet_blocking, + explain_plan_blocking, + long_bytes_be, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +@pytest.fixture(scope="module") +def qscexp_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, +): + skip_unless_query_selection(supports_query_selection) + + blob_bytes = long_bytes_be(50001) + + cluster_def = make_cluster_definition(aerospike_host, sync=True) + cluster_def.with_index_refresh_interval(0.25) + with cluster_def.connect() as cluster: + client = cluster._sdk_client + pac = client.underlying_client + session = cluster.create_session() + ds = DataSet.of(NS, SCOPE_SET_NAME) + + for key_id in ("k1", "k2"): + try: + session.delete(ds.id(key_id)).execute() + except Exception: + pass + + create_index_quiet_blocking( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_AGE_BIN, + index_name=SCOPE_INT_INDEX, + index_type=IndexType.NUMERIC, + ) + create_index_quiet_blocking( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_BLOB_BIN, + index_name=SCOPE_BLOB_INDEX, + index_type=IndexType.BLOB, + ) + create_index_quiet_blocking( + pac, + set_name=SCOPE_SET_NAME, + bin_name=SCOPE_MAP_BIN, + index_name=SCOPE_MAP_INDEX, + index_type=IndexType.STRING, + collection_type=CollectionIndexType.MAP_KEYS, + ) + + session.upsert(ds.id("k1")).put({ + SCOPE_AGE_BIN: 25, + SCOPE_COUNTRY_BIN: "US", + SCOPE_BLOB_BIN: blob_bytes, + SCOPE_MAP_BIN: {SCOPE_MAP_KEY: "v1"}, + }).execute() + session.upsert(ds.id("k2")).put( + {SCOPE_AGE_BIN: 30, SCOPE_COUNTRY_BIN: "CA"}, + ).execute() + + yield client, blob_bytes + + for key_id in ("k1", "k2"): + try: + session.delete(ds.id(key_id)).execute() + except Exception: + pass + for index_name in (SCOPE_INT_INDEX, SCOPE_BLOB_INDEX, SCOPE_MAP_INDEX): + try: + client.index(NS, SCOPE_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestSyncQuerySelectionExplainScope: + def test_explain_scalar_integer_secondary_index_succeeds(self, qscexp_client): + client, _ = qscexp_client + plan = explain_plan_blocking( + client.underlying_client, "$.age == 25", set_name=SCOPE_SET_NAME, + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == SCOPE_INT_INDEX + + def test_explain_scalar_string_primary_index_no_index_fields(self, qscexp_client): + client, _ = qscexp_client + plan = explain_plan_blocking( + client.underlying_client, "$.country == 'US'", set_name=SCOPE_SET_NAME, + ) + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + def test_explain_blob_equality_selects_secondary_index(self, qscexp_client): + client, blob_bytes = qscexp_client + where = f"$.{SCOPE_BLOB_BIN} == x'{blob_hex_literal(blob_bytes)}'" + plan = explain_plan_blocking( + client.underlying_client, where, set_name=SCOPE_SET_NAME, + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == SCOPE_BLOB_INDEX + + def test_explain_map_keys_exists_primary_index_fallback(self, qscexp_client): + client, _ = qscexp_client + where = f"$.{SCOPE_MAP_BIN}.{SCOPE_MAP_KEY}.exists() == true" + plan = explain_plan_blocking( + client.underlying_client, where, set_name=SCOPE_SET_NAME, + ) + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + def test_execute_blob_equality_returns_matching_row(self, qscexp_client): + client, blob_bytes = qscexp_client + session = client.create_session() + where = f"$.{SCOPE_BLOB_BIN} == x'{blob_hex_literal(blob_bytes)}'" + count = count_records_sync( + session.query(DataSet.of(NS, SCOPE_SET_NAME)) + .bins([SCOPE_BLOB_BIN]) + .where(where) + .execute(), + ) + assert count == 1 + + def test_execute_map_keys_exists_returns_matching_rows(self, qscexp_client): + client, _ = qscexp_client + session = client.create_session() + where = f"$.{SCOPE_MAP_BIN}.{SCOPE_MAP_KEY}.exists() == true" + stream = ( + session.query(DataSet.of(NS, SCOPE_SET_NAME)) + .bins([SCOPE_MAP_BIN]) + .where(where) + .execute() + ) + count = 0 + try: + for result in stream: + rec = result.record_or_raise() + assert SCOPE_MAP_KEY in rec.bins[SCOPE_MAP_BIN] + count += 1 + finally: + stream.close() + assert count > 0 diff --git a/tests/integration/sync/query_selection_hint_flags_test.py b/tests/integration/sync/query_selection_hint_flags_test.py new file mode 100644 index 0000000..54d8ed7 --- /dev/null +++ b/tests/integration/sync/query_selection_hint_flags_test.py @@ -0,0 +1,191 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Sync Tier D integration tests for query-selection hint flags.""" + +from __future__ import annotations + +import time + +import pytest +from aerospike_async import Filter, ResultCode +from aerospike_async.exceptions import IndexNotFound, InvalidRequest + +from aerospike_sdk import DataSet, QueryHint + +from tests.integration.query_selection_helpers import ( + BIN_AGE, + BIN_COUNTRY, + BIN_SCORE, + HINT_BOGUS_INDEX_NAME, + HINT_INDEX_NAME, + HINT_SCORE_INDEX_NAME, + HINT_SET_NAME, + NS, + QuerySelection, + explain_plan_blocking, + hint_key_name, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +def _sync_wait_for_index(client, session, ns, set_name, sindex_filter, *, timeout=5.0, interval=0.25): + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + stream = session.query(namespace=ns, set_name=set_name).filter(sindex_filter).execute() + for _ in stream: + break + stream.close() + return + except Exception as exc: + if "IndexNotReadable" not in str(exc): + raise + last_err = exc + time.sleep(interval) + raise last_err # type: ignore[misc] + + +@pytest.fixture(scope="module") +def qselhint_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, +): + skip_unless_query_selection(supports_query_selection) + + cluster_def = make_cluster_definition(aerospike_host, sync=True) + cluster_def.with_index_refresh_interval(0.25) + with cluster_def.connect() as cluster: + client = cluster._sdk_client + session = cluster.create_session() + ds = DataSet.of(NS, HINT_SET_NAME) + + for suffix in ("1", "2"): + try: + session.delete(ds.id(hint_key_name(suffix))).execute() + except Exception: + pass + + for index_name, bin_name in ( + (HINT_INDEX_NAME, BIN_AGE), + (HINT_SCORE_INDEX_NAME, BIN_SCORE), + ): + try: + client.index(NS, HINT_SET_NAME).on_bin(bin_name).named( + index_name, + ).numeric().create() + except Exception: + pass + + session.upsert(ds.id(hint_key_name("1"))).put( + {BIN_AGE: 25, BIN_SCORE: 25, BIN_COUNTRY: "US"}, + ).execute() + session.upsert(ds.id(hint_key_name("2"))).put( + {BIN_AGE: 30, BIN_SCORE: 30, BIN_COUNTRY: "CA"}, + ).execute() + + _sync_wait_for_index( + client, session, NS, HINT_SET_NAME, Filter.range(BIN_AGE, 25, 30), + ) + _sync_wait_for_index( + client, session, NS, HINT_SET_NAME, Filter.range(BIN_SCORE, 25, 30), + ) + + yield client + + for suffix in ("1", "2"): + try: + session.delete(ds.id(hint_key_name(suffix))).execute() + except Exception: + pass + for index_name in (HINT_INDEX_NAME, HINT_SCORE_INDEX_NAME): + try: + client.index(NS, HINT_SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestSyncQuerySelectionHintFlags: + def test_require_index_on_primary_index_plan_fails_explain(self, qselhint_client): + pac = qselhint_client.underlying_client + with pytest.raises(IndexNotFound) as exc_info: + explain_plan_blocking( + pac, + "$.country == 'US'", + set_name=HINT_SET_NAME, + hint=QueryHint(require_index=True), + ) + assert exc_info.value.result_code == ResultCode.INDEX_NOT_FOUND + + def test_require_index_with_soft_hint_selects_secondary_index(self, qselhint_client): + pac = qselhint_client.underlying_client + plan = explain_plan_blocking( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint(require_index=True, index_name=HINT_SCORE_INDEX_NAME), + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == HINT_INDEX_NAME + + def test_hard_hint_with_matching_index_selects_hinted_index(self, qselhint_client): + pac = qselhint_client.underlying_client + plan = explain_plan_blocking( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint(index_name=HINT_INDEX_NAME, hard_hint=True), + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == HINT_INDEX_NAME + + def test_require_index_and_hard_hint_selects_hinted_index(self, qselhint_client): + pac = qselhint_client.underlying_client + plan = explain_plan_blocking( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint( + index_name=HINT_INDEX_NAME, + require_index=True, + hard_hint=True, + ), + ) + assert plan.index_name == HINT_INDEX_NAME + + def test_hard_hint_with_wrong_index_fails_explain(self, qselhint_client): + pac = qselhint_client.underlying_client + with pytest.raises(IndexNotFound) as exc_info: + explain_plan_blocking( + pac, + "$.age == 25", + set_name=HINT_SET_NAME, + hint=QueryHint( + index_name=HINT_BOGUS_INDEX_NAME, + hard_hint=True, + ), + ) + assert exc_info.value.result_code == ResultCode.INDEX_NOT_FOUND + + def test_bad_ael_fails_explain_with_parameter(self, qselhint_client): + pac = qselhint_client.underlying_client + with pytest.raises(InvalidRequest) as exc_info: + explain_plan_blocking(pac, "$.age > 30 and", set_name=HINT_SET_NAME) + assert exc_info.value.result_code == ResultCode.PARAMETER_ERROR diff --git a/tests/integration/sync/query_server_selection_test.py b/tests/integration/sync/query_server_selection_test.py new file mode 100644 index 0000000..f710006 --- /dev/null +++ b/tests/integration/sync/query_server_selection_test.py @@ -0,0 +1,425 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Sync integration tests for two-phase server query selection.""" + +from __future__ import annotations + +import time + +import pytest +from aerospike_async import Filter, QueryDuration, ResultCode + +from aerospike_sdk import DataSet, Exp, QueryHint, val +from aerospike_sdk.exceptions import AerospikeError + +from tests.integration.query_selection_helpers import ( + BIN_AGE, + BIN_COUNTRY, + BIN_SCORE, + BOGUS_INDEX_NAME, + INDEX_NAME, + NS, + QuerySelection, + QuerySelectionClientFacade, + SCORE_INDEX_NAME, + SET_NAME, + SIZE, + collect_ages_sync, + collect_scores_sync, + count_records_sync, + explain_plan_blocking, + key_name, + requires_pac_query_selection_api, + skip_unless_query_selection, +) + +pytestmark = requires_pac_query_selection_api + + +def _sync_wait_for_index(client, session, ns, set_name, sindex_filter, *, timeout=5.0, interval=0.25): + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + stream = session.query(namespace=ns, set_name=set_name).filter(sindex_filter).execute() + for _ in stream: + break + stream.close() + return + except Exception as exc: + if "IndexNotReadable" not in str(exc): + raise + last_err = exc + time.sleep(interval) + raise last_err # type: ignore[misc] + + +@pytest.fixture(scope="module") +def qsel_client( + aerospike_host, + make_cluster_definition, + supports_query_selection, +): + skip_unless_query_selection(supports_query_selection) + + cluster_def = make_cluster_definition(aerospike_host, sync=True) + cluster_def.with_index_refresh_interval(0.25) + with cluster_def.connect() as cluster: + client = cluster._sdk_client + session = cluster.create_session() + ds = DataSet.of(NS, SET_NAME) + + for i in range(1, SIZE + 1): + try: + session.delete(ds.id(key_name(i))).execute() + except Exception: + pass + + for i in range(1, SIZE + 1): + country = "US" if i % 2 == 0 else "CA" + session.upsert(ds.id(key_name(i))).put( + {BIN_AGE: i, BIN_SCORE: i, BIN_COUNTRY: country}, + ).execute() + + for index_name, bin_name in ( + (INDEX_NAME, BIN_AGE), + (SCORE_INDEX_NAME, BIN_SCORE), + ): + try: + client.index(NS, SET_NAME).on_bin(bin_name).named( + index_name, + ).numeric().create() + except Exception: + pass + + _sync_wait_for_index( + client, session, NS, SET_NAME, Filter.range(BIN_AGE, 1, SIZE), + ) + _sync_wait_for_index( + client, session, NS, SET_NAME, Filter.range(BIN_SCORE, 1, SIZE), + ) + + yield QuerySelectionClientFacade(client, session) + + for i in range(1, SIZE + 1): + try: + session.delete(ds.id(key_name(i))).execute() + except Exception: + pass + for index_name in (INDEX_NAME, SCORE_INDEX_NAME): + try: + client.index(NS, SET_NAME).named(index_name).drop() + except Exception: + pass + + +class TestSyncQueryExplain: + def test_range_selects_secondary_index(self, qsel_client): + pac = qsel_client.underlying_client + plan = explain_plan_blocking(pac, "$.age >= 14 and $.age <= 18") + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + def test_non_indexed_predicate_selects_primary(self, qsel_client): + pac = qsel_client.underlying_client + plan = explain_plan_blocking(pac, "$.country == 'US'") + + assert plan.selection == QuerySelection.PRIMARY_INDEX + assert plan.index_name is None + + def test_contradiction_filtered_out(self, qsel_client): + pac = qsel_client.underlying_client + plan = explain_plan_blocking(pac, "$.age > 100 and $.age < 10") + assert plan.selection == QuerySelection.FILTERED_OUT + + def test_for_index_hint(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = explain_plan_blocking( + pac, where, hint=QueryHint(index_name=INDEX_NAME), + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + def test_plan_bytes_stable_across_repeated_probes(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + first = explain_plan_blocking(pac, where) + second = explain_plan_blocking(pac, where) + + assert first.selection == QuerySelection.SECONDARY_INDEX + assert first.index_name == INDEX_NAME + assert second.selection == first.selection + assert second.index_name == first.index_name + assert second.ael == first.ael + + def test_for_index_hint_on_nonexistent_index(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = explain_plan_blocking( + pac, where, hint=QueryHint(index_name=BOGUS_INDEX_NAME), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert plan.index_name != BOGUS_INDEX_NAME + + def test_for_index_hint_on_wrong_existing_index(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(index_name=SCORE_INDEX_NAME) + plan = explain_plan_blocking(pac, where, hint=hint) + + ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute(), + ) + + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert plan.index_name != SCORE_INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + +class TestSyncQueryExecute: + def test_simple_range(self, qsel_client): + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where("$.age >= 14 and $.age <= 18") + .execute() + ) + assert collect_ages_sync(stream) == [14, 15, 16, 17, 18] + + def test_equality_returns_single_record(self, qsel_client): + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where("$.age == 25") + .execute() + ) + assert collect_ages_sync(stream) == [25] + + def test_primary_index_predicate(self, qsel_client): + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_COUNTRY]) + .where("$.country == 'US'") + .execute() + ) + countries = [] + try: + for result in stream: + countries.append(result.record_or_raise().bins[BIN_COUNTRY]) + finally: + stream.close() + assert len(countries) == 25 + assert all(c == "US" for c in countries) + + def test_plan_then_execute_consistency(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + plan = explain_plan_blocking(pac, where) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + assert collect_ages_sync(stream) == [14, 15, 16, 17, 18] + + def test_compound_predicate(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age > 30 and $.country == 'US'" + plan = explain_plan_blocking(pac, where) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE, BIN_COUNTRY]) + .where(where) + .execute() + ) + ages = [] + try: + for result in stream: + rec = result.record_or_raise() + assert rec.bins[BIN_COUNTRY] == "US" + assert rec.bins[BIN_AGE] > 30 + ages.append(rec.bins[BIN_AGE]) + finally: + stream.close() + assert sorted(ages) == [32, 34, 36, 38, 40, 42, 44, 46, 48, 50] + + def test_reading_only_bins_projects_requested_bins(self, qsel_client): + where = "$.age >= 14 and $.age <= 18" + stream = ( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute() + ) + ages = [] + try: + for result in stream: + rec = result.record_or_raise() + ages.append(rec.bins[BIN_AGE]) + assert BIN_COUNTRY not in rec.bins + finally: + stream.close() + assert sorted(ages) == [14, 15, 16, 17, 18] + + def test_contradiction_raises_filtered_out(self, qsel_client): + with pytest.raises(AerospikeError) as exc_info: + qsel_client.query(NS, SET_NAME).where( + "$.age > 100 and $.age < 10", + ).execute() + assert exc_info.value.result_code == ResultCode.FILTERED_OUT + + def test_empty_secondary_index_result(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age == 999" + plan = explain_plan_blocking(pac, where) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + + count = count_records_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute(), + ) + assert count == 0 + + +class TestSyncQuerySelectionRouting: + def test_for_bin_hint_uses_legacy_execute_path(self, qsel_client): + where = "$.age >= 14 and $.age <= 18" + default_ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute(), + ) + for_bin_ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(QueryHint(bin_name=BIN_AGE)) + .execute(), + ) + assert default_ages == for_bin_ages == [14, 15, 16, 17, 18] + + def test_for_index_hint_probes_and_executes(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(index_name=INDEX_NAME) + plan = explain_plan_blocking(pac, where, hint=hint) + ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute(), + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + def test_query_duration_only_hint_still_probes_and_executes(self, qsel_client): + pac = qsel_client.underlying_client + where = "$.age >= 14 and $.age <= 18" + hint = QueryHint(query_duration=QueryDuration.SHORT) + plan = explain_plan_blocking(pac, where, hint=hint) + ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(hint) + .execute(), + ) + assert plan.selection == QuerySelection.SECONDARY_INDEX + assert plan.index_name == INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + + def test_where_exp_uses_non_probe_execute_path(self, qsel_client): + ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where( + Exp.and_([ + Exp.ge(Exp.int_bin(BIN_AGE), val(14)), + Exp.le(Exp.int_bin(BIN_AGE), val(18)), + ]), + ) + .execute(), + ) + assert ages == [14, 15, 16, 17, 18] + + def test_server_led_matches_legacy_for_bin(self, qsel_client): + where = "$.age > 30 and $.country == 'US'" + server_ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .execute(), + ) + legacy_ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(where) + .with_hint(QueryHint(bin_name=BIN_AGE)) + .execute(), + ) + assert server_ages == legacy_ages + assert server_ages == [32, 34, 36, 38, 40, 42, 44, 46, 48, 50] + + def test_multiple_indexes_auto_select(self, qsel_client): + pac = qsel_client.underlying_client + age_where = "$.age >= 14 and $.age <= 18" + score_where = "$.score >= 40 and $.score <= 44" + + age_plan = explain_plan_blocking(pac, age_where) + score_plan = explain_plan_blocking(pac, score_where) + ages = collect_ages_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_AGE]) + .where(age_where) + .execute(), + ) + scores = collect_scores_sync( + qsel_client.query(NS, SET_NAME) + .bins([BIN_SCORE]) + .where(score_where) + .execute(), + ) + + assert age_plan.index_name == INDEX_NAME + assert score_plan.index_name == SCORE_INDEX_NAME + assert ages == [14, 15, 16, 17, 18] + assert scores == [40, 41, 42, 43, 44] + + def test_no_where_scan(self, qsel_client): + count = count_records_sync(qsel_client.query(NS, SET_NAME).execute()) + assert count == SIZE diff --git a/tests/pac_compat.py b/tests/pac_compat.py new file mode 100644 index 0000000..0ed19d5 --- /dev/null +++ b/tests/pac_compat.py @@ -0,0 +1,112 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""PAC capability checks shared by unit and integration tests. + +Integration tests that need server-compiled AEL on the wire can use +:data:`requires_server_compiled_ael`; tests that assume the **client-side** +string-AEL path (no server compilation for ``where(str)``) can use +:data:`requires_client_side_ael` (see ``tests/integration/conftest.py``). +""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, Protocol + +import pytest +from aerospike_async.exceptions import InvalidRequest, ResultCode +from aerospike_sdk.exceptions import AerospikeError +from aerospike_sdk.feature_gates import PSDK_ENABLE_SERVER_COMPILED_AEL + + +class SupportsServerCompiledAel(Protocol): + """Connected client (or stand-in) that reports server-compiled AEL availability.""" + + @property + def supports_server_compiled_ael(self) -> bool: + ... + + +def skip_if_lacks_server_compiled_ael(client: SupportsServerCompiledAel) -> None: + """Skip when server-compiled AEL is not available for this connection/cluster. + + Mirrors :attr:`aerospike_sdk.aio.client.Client.supports_server_compiled_ael`: + PAC must expose ``FilterExpression.from_server_compiled_ael``, and the + **first active** node's ``Version`` must report server-compiled AEL support + (homogeneous cluster: all nodes same build). + """ + if not PSDK_ENABLE_SERVER_COMPILED_AEL: + pytest.skip( + "server-compiled AEL feature gate disabled " + "(PSDK_ENABLE_SERVER_COMPILED_AEL)" + ) + if client.supports_server_compiled_ael: + return + pytest.skip( + "Requires server-compiled AEL: PAC FilterExpression.from_server_compiled_ael " + "and first active node Version.supports_server_compiled_ael " + "(Client.supports_server_compiled_ael; homogeneous cluster assumption)." + ) + + +def skip_if_server_compiled_ael_available(client: SupportsServerCompiledAel) -> None: + """Skip when the SDK would use server-compiled AEL for string ``where()`` predicates. + + Use for integration tests that only apply to the client-side + :func:`~aerospike_sdk.ael.parser.parse_ael` path (``Client.supports_server_compiled_ael`` + is false: missing PAC API, old server build, or pre-connect client). + """ + if not PSDK_ENABLE_SERVER_COMPILED_AEL: + return + if not client.supports_server_compiled_ael: + return + pytest.skip( + "Requires client-side AEL parsing for string predicates: " + "Client.supports_server_compiled_ael is true (server-compiled path in use)." + ) + + +# Integration tests: ``requires_*_ael`` markers are enforced in +# ``tests/integration/conftest.py`` (``pytest_runtest_call`` resolves +# ``client`` / ``cluster*`` / ``session*`` / ``session_with_*`` fixtures). + + +async def assert_dataset_invalid_ael_rejected(execute_coro: Awaitable[Any]) -> None: + """Assert invalid string AEL on a dataset query is rejected by the server. + + With query selection (explain→execute), ``PARAMETER_ERROR`` is raised from + ``execute()``. With server-compiled AEL on field **43**, ``execute()`` may + return a stream and the cluster rejects the filter while reading rows. + """ + stream = None + try: + try: + stream = await execute_coro + except AerospikeError as exc: + assert exc.result_code == ResultCode.PARAMETER_ERROR + return + + with pytest.raises((AerospikeError, InvalidRequest)) as exc_info: + async for _ in stream: + pass + assert exc_info.value.result_code == ResultCode.PARAMETER_ERROR + finally: + if stream is not None: + stream.close() + + +requires_server_compiled_ael = pytest.mark.requires_server_compiled_ael +requires_client_side_ael = pytest.mark.requires_client_side_ael diff --git a/tests/unit/builder_drift_test.py b/tests/unit/builder_drift_test.py index a5333fe..908bea8 100644 --- a/tests/unit/builder_drift_test.py +++ b/tests/unit/builder_drift_test.py @@ -233,6 +233,7 @@ def test_pair_shared_method_signatures_match(async_cls, sync_cls, label, allowed "cached_read_policy_sc", "cached_write_policy_sc", "txn", "namespace_mode_resolver", "namespace_mode_resolver_blocking", "sdk_client", + "supports_server_compiled_ael", "supports_query_selection", ), ), _SingleKeyWriteSegmentBase: ( diff --git a/tests/unit/query_hint_test.py b/tests/unit/query_hint_test.py index f41dd6a..2d5f9b6 100644 --- a/tests/unit/query_hint_test.py +++ b/tests/unit/query_hint_test.py @@ -72,6 +72,19 @@ def test_index_name_and_bin_name_raises(self): with pytest.raises(ValueError, match="mutually exclusive"): QueryHint(index_name="idx", bin_name="b") + def test_hard_hint_without_index_name_raises(self): + with pytest.raises(ValueError, match="hard_hint requires index_name"): + QueryHint(hard_hint=True) + + def test_require_index_and_hard_hint_allowed(self): + hint = QueryHint( + index_name="age_idx", + require_index=True, + hard_hint=True, + ) + assert hint.require_index is True + assert hint.hard_hint is True + def test_frozen(self): hint = QueryHint(index_name="idx") # `setattr` instead of direct `hint.index_name = ...` to bypass static @@ -108,7 +121,8 @@ def test_chains_with_where(self): ) assert result is builder assert builder._query_hint is not None - assert builder._filter_expression is not None + assert builder._where_ael == "$.age > 30" + assert builder._filter_expression is None def test_where_stores_ael_string(self): builder = _query_builder() diff --git a/tests/unit/query_server_selection_test.py b/tests/unit/query_server_selection_test.py new file mode 100644 index 0000000..a4bd3e3 --- /dev/null +++ b/tests/unit/query_server_selection_test.py @@ -0,0 +1,309 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Portions may be licensed to Aerospike, Inc. under one or more contributor +# license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Unit tests for server-led query selection routing in QueryBuilder.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aerospike_async import Filter, QueryPolicy +from aerospike_async.exceptions import ResultCode + +from aerospike_sdk import QueryHint +from aerospike_sdk.aio.operations.query import QueryBuilder +from aerospike_sdk.exceptions import AerospikeError +from aerospike_sdk.sync.operations.query import SyncQueryBuilder + +try: + from aerospike_async import QueryWhereFlags +except ImportError: + QueryWhereFlags = None + + +class _ClientSupportsSelection: + """PAC client stub — capability is threaded via QueryBuilder kwarg.""" + + +class _ClientNoSelection: + """PAC client stub — capability is threaded via QueryBuilder kwarg.""" + + +def _async_builder( + client: object, + *, + supports_query_selection: bool = True, + supports_server_compiled_ael: bool = False, +) -> QueryBuilder: + return QueryBuilder( + client=client, + namespace="test", + set_name="s", + supports_query_selection=supports_query_selection, + supports_server_compiled_ael=supports_server_compiled_ael, + ) + + +def _sync_builder( + client: object, + *, + supports_query_selection: bool = True, + supports_server_compiled_ael: bool = False, +) -> SyncQueryBuilder: + return SyncQueryBuilder( + client=client, + namespace="test", + set_name="s", + supports_query_selection=supports_query_selection, + supports_server_compiled_ael=supports_server_compiled_ael, + ) + + +class TestUseServerQuerySelection: + def test_true_with_string_ael_and_support(self): + qb = _async_builder(_ClientSupportsSelection()).where("$.age > 30") + assert qb._use_server_query_selection(None) is True + + def test_false_without_where_ael(self): + qb = _async_builder(_ClientSupportsSelection()) + assert qb._use_server_query_selection(None) is False + + def test_false_with_bin_name_hint(self): + qb = _async_builder(_ClientSupportsSelection()).where("$.age > 30") + hint = QueryHint(bin_name="alt") + assert qb._use_server_query_selection(hint) is False + + def test_false_with_explicit_filter(self): + qb = _async_builder(_ClientSupportsSelection()).where("$.age > 30") + qb.filter(Filter.equal("age", 30)) + assert qb._use_server_query_selection(None) is False + + def test_false_when_capability_off(self): + qb = _async_builder( + _ClientNoSelection(), + supports_query_selection=False, + ).where("$.age > 30") + assert qb._use_server_query_selection(None) is False + + def test_false_when_capability_not_enabled_on_builder(self): + qb = _async_builder(object(), supports_query_selection=False).where("$.age > 30") + assert qb._use_server_query_selection(None) is False + + def test_index_name_hint_still_uses_server_path(self): + qb = _async_builder(_ClientSupportsSelection()).where("$.age > 30") + hint = QueryHint(index_name="age_idx") + assert qb._use_server_query_selection(hint) is True + + def test_sync_builder_inherits_routing(self): + qb = _sync_builder(_ClientSupportsSelection()).where("$.score >= 10") + assert qb._use_server_query_selection(None) is True + + +@pytest.mark.skipif(QueryWhereFlags is None, reason="PAC lacks QueryWhereFlags") +class TestExplainWhereFlags: + def test_default_none(self): + qb = _async_builder(_ClientSupportsSelection()) + assert qb._query_explain_where_flags(None) is None + + def test_require_index(self): + qb = _async_builder(_ClientSupportsSelection()) + hint = QueryHint(require_index=True) + flags = qb._query_explain_where_flags(hint) + assert flags == (QueryWhereFlags.EXPLAIN | QueryWhereFlags.REQUIRE_INDEX) + + def test_hard_hint_with_index_name(self): + qb = _async_builder(_ClientSupportsSelection()) + hint = QueryHint(index_name="age_idx", hard_hint=True) + flags = qb._query_explain_where_flags(hint) + assert flags == (QueryWhereFlags.EXPLAIN | QueryWhereFlags.HARD_HINT) + + +class TestApplyDatasetQueryPolicyFilter: + def test_skips_filter_expression_on_server_path(self): + qb = _async_builder(_ClientSupportsSelection()).where("$.age > 30") + policy = QueryPolicy() + qb._apply_dataset_query_policy_filter( + policy, use_server_query_selection=True, + ) + assert policy.filter_expression is None + + def test_sets_filter_expression_on_legacy_path(self): + qb = _async_builder( + _ClientNoSelection(), + supports_query_selection=False, + ).where("$.age > 30") + policy = QueryPolicy() + qb._apply_dataset_query_policy_filter( + policy, use_server_query_selection=False, + ) + assert policy.filter_expression is not None + + +@pytest.mark.asyncio +class TestExecuteDatasetQueryRouting: + async def test_server_path_calls_explain_and_with_plan(self): + client = MagicMock() + plan = MagicMock() + plan.is_filtered_out = False + recordset = MagicMock() + client.query_explain = AsyncMock(return_value=plan) + client.query_with_plan = AsyncMock(return_value=recordset) + client.query = AsyncMock() + + qb = _async_builder(client, supports_query_selection=True).where("$.age > 30") + await qb._execute_dataset_query() + + client.query_explain.assert_awaited_once() + client.query_with_plan.assert_awaited_once() + client.query.assert_not_awaited() + + async def test_legacy_path_calls_query_only(self): + client = MagicMock() + recordset = MagicMock() + client.query = AsyncMock(return_value=recordset) + client.query_explain = AsyncMock() + client.query_with_plan = AsyncMock() + + qb = _async_builder(client, supports_query_selection=False).where("$.age > 30") + await qb._execute_dataset_query() + + client.query.assert_awaited_once() + client.query_explain.assert_not_awaited() + client.query_with_plan.assert_not_awaited() + + async def test_filtered_out_plan_skips_execute(self): + client = MagicMock() + plan = MagicMock() + plan.is_filtered_out = True + client.query_explain = AsyncMock(return_value=plan) + client.query_with_plan = AsyncMock() + + qb = _async_builder(client, supports_query_selection=True).where( + "$.age > 100 and $.age < 10", + ) + with pytest.raises(AerospikeError) as exc_info: + await qb._execute_dataset_query() + + assert exc_info.value.result_code == ResultCode.FILTERED_OUT + assert str(exc_info.value) == "Query plan filtered out by server" + client.query_explain.assert_awaited_once() + client.query_with_plan.assert_not_awaited() + + +class TestExecuteDatasetQueryBlockingRouting: + def test_server_path_calls_explain_and_with_plan_blocking(self): + client = MagicMock() + plan = MagicMock() + plan.is_filtered_out = False + recordset = MagicMock() + client.query_explain_blocking.return_value = plan + client.query_with_plan_blocking.return_value = recordset + client.query_blocking = MagicMock() + + qb = _sync_builder(client, supports_query_selection=True).where("$.age > 30") + qb._execute_dataset_query_blocking() + + client.query_explain_blocking.assert_called_once() + client.query_with_plan_blocking.assert_called_once() + client.query_blocking.assert_not_called() + + def test_legacy_path_calls_query_blocking_only(self): + client = MagicMock() + recordset = MagicMock() + client.query_blocking.return_value = recordset + client.query_explain_blocking = MagicMock() + client.query_with_plan_blocking = MagicMock() + + qb = _sync_builder(client, supports_query_selection=False).where("$.age > 30") + qb._execute_dataset_query_blocking() + + client.query_blocking.assert_called_once() + client.query_explain_blocking.assert_not_called() + client.query_with_plan_blocking.assert_not_called() + + def test_filtered_out_plan_skips_execute_blocking(self): + client = MagicMock() + plan = MagicMock() + plan.is_filtered_out = True + client.query_explain_blocking.return_value = plan + client.query_with_plan_blocking = MagicMock() + + qb = _sync_builder(client, supports_query_selection=True).where( + "$.age > 100 and $.age < 10", + ) + with pytest.raises(AerospikeError) as exc_info: + qb._execute_dataset_query_blocking() + + assert exc_info.value.result_code == ResultCode.FILTERED_OUT + assert str(exc_info.value) == "Query plan filtered out by server" + client.query_explain_blocking.assert_called_once() + client.query_with_plan_blocking.assert_not_called() + + +class TestServerCompiledAelWhere: + def test_where_uses_server_filter_helper_when_gate_on(self): + from unittest.mock import patch + + sentinel = object() + with patch( + "aerospike_sdk.query_shared.filter_expression_from_ael_string", + return_value=sentinel, + ) as factory: + qb = _async_builder( + _ClientSupportsSelection(), + supports_server_compiled_ael=True, + ).where("$.age > 30") + qb._resolve_where_filter_expression() + factory.assert_called_once_with( + "$.age > 30", + supports_server_compiled_ael=True, + ) + assert qb._filter_expression is sentinel + + def test_selection_takes_precedence_over_legacy_filter_on_dataset(self): + qb = _async_builder( + _ClientSupportsSelection(), + supports_query_selection=True, + supports_server_compiled_ael=True, + ).where("$.age > 30") + policy = QueryPolicy() + qb._apply_dataset_query_policy_filter( + policy, use_server_query_selection=True, + ) + assert policy.filter_expression is None + assert qb._use_server_query_selection(None) is True + + +class TestAsyncSessionSingleKeyCapabilityFlags: + def test_fast_path_inherits_server_compiled_ael(self): + from unittest.mock import MagicMock + + from aerospike_async import ClientPolicy, Key + + from aerospike_sdk.aio.client import Client + from aerospike_sdk.aio.session import Session + from aerospike_sdk.policy.behavior import Behavior + + sdk_client = Client("127.0.0.1:3000", policy=ClientPolicy()) + sdk_client._client = MagicMock() + sdk_client._connected = True + sdk_client._cached_supports_query_selection = True + sdk_client._cached_supports_server_compiled_ael = True + session = Session(client=sdk_client, behavior=Behavior.DEFAULT) + builder = session.query(Key("test", "users", 1)) + assert builder._supports_server_compiled_ael is True + assert builder._supports_query_selection is True + diff --git a/tests/unit/query_where_test.py b/tests/unit/query_where_test.py index 04c8ecd..e684af2 100644 --- a/tests/unit/query_where_test.py +++ b/tests/unit/query_where_test.py @@ -18,26 +18,41 @@ Tests the two forms: where(str) and where(FilterExpression). """ +import pytest +from aerospike_async import FilterExpression + from aerospike_sdk import Exp, parse_ael from aerospike_sdk.aio.operations.query import QueryBuilder from aerospike_sdk.sync.operations.query import SyncQueryBuilder -def _query_builder(): +def _query_builder(**kwargs): """Return a QueryBuilder with a fake client (no real connection).""" - return QueryBuilder(client=object(), namespace="test", set_name="unit_test") + client = kwargs.pop("client", None) + supports_server_compiled_ael = kwargs.pop("supports_server_compiled_ael", False) + if client is None: + client = object() + return QueryBuilder( + client=client, + namespace="test", + set_name="unit_test", + supports_server_compiled_ael=supports_server_compiled_ael, + **kwargs, + ) class TestQueryBuilderWhere: """Test QueryBuilder.where() overloads.""" def test_where_ael_string_sets_filter_expression(self): - """where(str) parses AEL and sets _filter_expression.""" + """where(str) records AEL and materializes on demand.""" builder = _query_builder() expected = parse_ael("$.age > 20") result = builder.where("$.age > 20") assert result is builder - assert builder._filter_expression == expected + assert builder._where_ael == "$.age > 20" + assert builder._filter_expression is None + assert builder._effective_filter_expression() == expected def test_where_ael_fstring_sets_filter_expression(self): """where(str) with f-string interpolation.""" @@ -46,7 +61,8 @@ def test_where_ael_fstring_sets_filter_expression(self): expected = parse_ael("$.age > 21") result = builder.where(f"$.age > {age}") assert result is builder - assert builder._filter_expression == expected + assert builder._where_ael == f"$.age > {age}" + assert builder._effective_filter_expression() == expected def test_where_filter_expression_sets_filter_expression(self): """where(FilterExpression) stores the expression directly.""" @@ -64,6 +80,16 @@ def test_where_filter_expression_chains(self): assert builder._filter_expression is exp assert builder._bins == ["name"] + def test_where_server_compiled_when_supported(self) -> None: + """where(str) uses server-compiled path when builder flag is set.""" + if not callable(getattr(FilterExpression, "from_server_compiled_ael", None)): + pytest.skip("PAC does not expose FilterExpression.from_server_compiled_ael") + builder = _query_builder(supports_server_compiled_ael=True) + builder.where("$.age > 20") + assert builder._effective_filter_expression() == ( + FilterExpression.from_server_compiled_ael("$.age > 20") + ) + class TestSyncQueryBuilderWhere: """Test SyncQueryBuilder.where() overloads (same behavior as QueryBuilder).""" @@ -77,12 +103,13 @@ def _sync_builder(self): ) def test_where_ael_string_sets_filter_expression(self): - """where(str) parses AEL and sets _filter_expression on the delegate.""" + """where(str) records AEL and materializes on demand.""" builder = self._sync_builder() expected = parse_ael("$.age > 20") result = builder.where("$.age > 20") assert result is builder - assert builder._filter_expression == expected + assert builder._where_ael == "$.age > 20" + assert builder._effective_filter_expression() == expected def test_where_filter_expression_sets_filter_expression(self): """where(FilterExpression) stores the expression directly.""" diff --git a/tests/unit/server_compiled_ael_test.py b/tests/unit/server_compiled_ael_test.py new file mode 100644 index 0000000..0efe563 --- /dev/null +++ b/tests/unit/server_compiled_ael_test.py @@ -0,0 +1,80 @@ +# Copyright 2025-2026 Aerospike, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +"""Unit tests for server-compiled AEL routing helpers.""" + +from unittest.mock import MagicMock, patch + +from aerospike_sdk.ael.server_filter import filter_expression_from_ael_string +from aerospike_sdk.server_compiled_ael import ( + compute_server_compiled_ael_support_blocking, +) + + +class TestFilterExpressionFromAelString: + def test_uses_client_parse_when_gate_off(self): + with patch("aerospike_sdk.ael.server_filter.parse_ael") as parse_ael: + sentinel = object() + parse_ael.return_value = sentinel + result = filter_expression_from_ael_string( + "$.age > 1", + supports_server_compiled_ael=False, + ) + assert result is sentinel + parse_ael.assert_called_once_with("$.age > 1") + + def test_uses_server_compiled_when_gate_on(self): + sentinel = object() + factory = MagicMock(return_value=sentinel) + with patch( + "aerospike_sdk.ael.server_filter._SERVER_COMPILED_FACTORY", + factory, + ): + with patch( + "aerospike_sdk.ael.server_filter._PAC_EXPOSES_SERVER_COMPILED", + True, + ): + with patch("aerospike_sdk.ael.server_filter.parse_ael") as parse_ael: + result = filter_expression_from_ael_string( + "$.age > 1", + supports_server_compiled_ael=True, + ) + assert result is sentinel + factory.assert_called_once_with("$.age > 1") + parse_ael.assert_not_called() + + +class TestComputeServerCompiledAelSupport: + def test_false_when_factory_missing(self): + pac = MagicMock() + pac.nodes_blocking.return_value = [MagicMock(version=MagicMock())] + with patch( + "aerospike_sdk.server_compiled_ael._pac_exposes_server_compiled_factory", + return_value=False, + ): + assert compute_server_compiled_ael_support_blocking(pac) is False + + def test_all_nodes_must_support(self): + pac = MagicMock() + v_ok = MagicMock() + v_ok.supports_server_compiled_ael.return_value = True + v_old = MagicMock() + v_old.supports_server_compiled_ael.return_value = False + pac.nodes_blocking.return_value = [ + MagicMock(version=v_ok), + MagicMock(version=v_old), + ] + with patch( + "aerospike_sdk.server_compiled_ael._pac_exposes_server_compiled_factory", + return_value=True, + ): + assert compute_server_compiled_ael_support_blocking(pac) is False diff --git a/tests/unit/sync_client_inheritance_test.py b/tests/unit/sync_client_inheritance_test.py index cd4300a..e994b9d 100644 --- a/tests/unit/sync_client_inheritance_test.py +++ b/tests/unit/sync_client_inheritance_test.py @@ -40,6 +40,8 @@ def _make_offline_sync_client() -> SyncClient: client = SyncClient("127.0.0.1:3000", policy=ClientPolicy()) client._client = MagicMock() client._connected = True + client._cached_supports_query_selection = True + client._cached_supports_server_compiled_ael = True return client @@ -82,3 +84,20 @@ def test_query_namespace_set(self): session = _make_offline_sync_session() builder = session.query(namespace="test", set_name="users") assert isinstance(builder, SyncQueryBuilder) + + +class TestSyncSessionCapabilityFlags: + """Fast-path QueryBuilder construction must inherit server capability flags.""" + + def test_single_key_fast_path_inherits_server_compiled_ael(self): + session = _make_offline_sync_session() + builder = session.query(Key("test", "users", 1)) + assert builder._supports_server_compiled_ael is True + assert builder._supports_query_selection is True + + def test_multi_key_fast_path_inherits_server_compiled_ael(self): + session = _make_offline_sync_session() + keys = [Key("test", "users", i) for i in range(2)] + builder = session.query(keys) + assert builder._supports_server_compiled_ael is True + assert builder._supports_query_selection is True