blob: 472528d393da280e9b6dd4ff2b38181ec8ac749b [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
"""Strict, evidence-bearing runtime for the read-only Search domain."""
from __future__ import annotations
import json
import math
import re
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from .db import DorisConnectionManager, QueryResult
from .query_runtime import (
DorisQueryRuntime,
QueryRuntimeFailure,
ReadOnlySQLGuard,
)
from .redaction import redact_sensitive_data
from .security import get_current_auth_context
from .sql_security_utils import (
SQLSecurityError,
build_table_reference,
quote_identifier,
validate_identifier,
)
_MAX_BYTES = 2 * 1024 * 1024
_MAX_QUERY_TEXT_BYTES = 16 * 1024
_MAX_PREVIEW_TEXT_BYTES = 64 * 1024
_MAX_TOP_K = 1_000
_DEFAULT_TOP_K = 10
_MAX_VECTOR_DIMENSION = 4_096
_MAX_FIELDS = 32
_MAX_RETURN_FIELDS = 64
_MAX_FILTERS = 32
_MAX_FILTER_VALUES = 100
_MAX_EXPLAIN_ROWS = 2_000
_BUILT_IN_ANALYZERS = frozenset(
{
"none",
"standard",
"english",
"chinese",
"unicode",
"icu",
"basic",
"ik",
}
)
_BACKWARD_COMPATIBLE_PARSERS = frozenset(
{
"english",
"chinese",
"unicode",
}
)
_MATCH_OPERATORS = {
"any": "MATCH_ANY",
"all": "MATCH_ALL",
"phrase": "MATCH_PHRASE",
"phrase_prefix": "MATCH_PHRASE_PREFIX",
}
_FILTER_OPERATORS = {
"eq": "=",
"ne": "!=",
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
}
_NULL_FILTER_OPERATORS = {
"is_null": "IS NULL",
"is_not_null": "IS NOT NULL",
}
_SET_FILTER_OPERATORS = {
"in": "IN",
"not_in": "NOT IN",
}
_VECTOR_METRICS = {
"l2_distance": ("l2_distance_approximate", "ASC"),
"inner_product": ("inner_product_approximate", "DESC"),
}
_PROPERTY_PAIR = re.compile(
r'"(?P<key>[A-Za-z_][A-Za-z0-9_]*)"\s*=\s*"(?P<value>(?:[^"\\]|\\.)*)"'
)
_SIMPLE_SOURCE = re.compile(
r"\bFROM\s+"
r"(?:(?P<database>`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\.\s*)?"
r"(?P<table>`?[A-Za-z_][A-Za-z0-9_]*`?)",
re.IGNORECASE,
)
class SearchRuntimeFailure(RuntimeError):
"""Sanitized Search-domain failure with a stable reason code."""
def __init__(
self,
message: str,
*,
reason_code: str,
status_code: int,
retryable: bool = False,
) -> None:
super().__init__(message)
self.reason_code = reason_code
self.status_code = status_code
self.retryable = retryable
@dataclass(frozen=True, slots=True)
class _SearchIndex:
name: str
index_type: str
columns: tuple[str, ...]
properties: Mapping[str, str]
comment: str | None
@property
def is_inverted(self) -> bool:
return self.index_type == "INVERTED"
@property
def is_ann(self) -> bool:
return self.index_type == "ANN"
def to_wire(self) -> dict[str, Any]:
return {
"name": self.name,
"index_type": self.index_type,
"columns": list(self.columns),
"properties": dict(self.properties),
"comment": self.comment,
"parser": self.properties.get("parser"),
"analyzer": (
self.properties.get("analyzer")
or self.properties.get("built_in_analyzer")
),
"metric_type": self.properties.get("metric_type"),
"dimension": _as_int(self.properties.get("dim")),
}
@dataclass(frozen=True, slots=True)
class _CompiledSearch:
sql: str
params: tuple[Any, ...]
database: str
table: str
mode: str
top_k: int
text_fields: tuple[str, ...]
vector_field: str | None
vector_metric: str | None
vector_dimension: int | None
return_fields: tuple[str, ...]
indexes: tuple[_SearchIndex, ...]
class DorisSearchRuntime:
"""Run bounded Doris-native text, vector, hybrid, and diagnostic reads."""
def __init__(
self,
connection_manager: DorisConnectionManager,
query_runtime: DorisQueryRuntime,
) -> None:
self._connection_manager = connection_manager
self._query_runtime = query_runtime
self._session_prefix = f"formal_search_{uuid.uuid4().hex[:8]}"
async def search_data(
self,
*,
database: str,
table: str,
query: str | None,
mode: str,
fields: Sequence[str] | None,
vector: Sequence[Any] | None,
vector_field: str | None,
text_operator: str | None,
top_k: int | None,
filters: Mapping[str, Any] | None,
return_fields: Sequence[str] | None,
) -> dict[str, Any]:
"""Execute one target-index-validated, bounded search request."""
compiled = await self._compile_search(
{
"database": database,
"table": table,
"query": query,
"mode": mode,
"fields": fields,
"vector": vector,
"vector_field": vector_field,
"text_operator": text_operator,
"top_k": top_k,
"filters": filters,
"return_fields": return_fields,
}
)
result = await self._execute(
compiled.sql,
params=compiled.params,
max_rows=compiled.top_k + 1,
mask_result=True,
)
rows = [
dict(row)
for row in (result.data or ())
if isinstance(row, Mapping)
]
driver_truncated = bool(result.metadata.get("truncated"))
overfetched = len(rows) > compiled.top_k
visible_rows = rows[: compiled.top_k]
columns = [
{"name": str(name)}
for name in result.metadata.get(
"columns",
list(visible_rows[0]) if visible_rows else [],
)
]
warnings: list[str] = []
if driver_truncated:
warnings.append(
"Doris stopped reading at the configured response boundary."
)
evidence: list[Mapping[str, Any]] = [
{
"source": "SHOW INDEX",
"kind": "target_index_metadata",
"indexes": [
index.name
for index in compiled.indexes
if (
index.is_inverted
and set(index.columns) & set(compiled.text_fields)
)
or (
index.is_ann
and compiled.vector_field in index.columns
)
],
},
{
"source": "doris_sql",
"kind": "bounded_search",
"mode": compiled.mode,
},
]
return _result(
{
"columns": columns,
"rows": visible_rows,
"row_count": len(visible_rows),
"truncated": driver_truncated or overfetched,
},
source="doris_native_search",
warnings=warnings,
metadata={
"database": compiled.database,
"table": compiled.table,
"mode": compiled.mode,
"top_k": compiled.top_k,
"text_fields": list(compiled.text_fields),
"vector_field": compiled.vector_field,
"vector_metric": compiled.vector_metric,
"vector_dimension": compiled.vector_dimension,
"return_fields": list(compiled.return_fields),
"invented_scores": False,
"execution_time_seconds": result.execution_time,
},
evidence=evidence,
)
async def preview_text_analysis(
self,
*,
text: str,
analyzer: str | None,
tokenizer: str | None,
token_filters: Sequence[str] | None,
) -> dict[str, Any]:
"""Run Doris ``TOKENIZE`` without creating analyzer components."""
normalized_text = _required_text(
text,
"text",
maximum_bytes=_MAX_PREVIEW_TEXT_BYTES,
)
requested_filters = _identifier_sequence(
token_filters,
"token filter",
maximum=16,
)
properties: dict[str, str]
component_evidence: list[dict[str, Any]] = []
if analyzer is not None:
analyzer_name = _required_identifier(analyzer, "analyzer name")
analyzer_key = analyzer_name.casefold()
if analyzer_key in _BUILT_IN_ANALYZERS:
if tokenizer is not None or requested_filters:
raise _argument_failure(
"Built-in analyzers cannot be combined with custom "
"tokenizer or token-filter names."
)
properties = _built_in_analyzer_properties(analyzer_key)
else:
definition = await self._custom_analyzer_definition(
analyzer_name
)
if definition is None:
raise SearchRuntimeFailure(
"The requested custom Doris analyzer does not exist.",
reason_code="SEARCH_ANALYZER_NOT_FOUND",
status_code=404,
)
observed_tokenizer = _optional_identifier_value(
_value(definition, "tokenizer")
)
observed_filters = _split_component_names(
_value(
definition,
"token_filter",
"token_filters",
"tokenfilters",
)
)
if tokenizer is not None:
tokenizer_name = _required_identifier(
tokenizer,
"tokenizer name",
)
if tokenizer_name.casefold() != (
observed_tokenizer or ""
).casefold():
raise _argument_failure(
"The requested tokenizer does not match the "
"recorded custom analyzer definition."
)
if requested_filters and tuple(
item.casefold() for item in requested_filters
) != tuple(item.casefold() for item in observed_filters):
raise _argument_failure(
"The requested token filters do not match the recorded "
"custom analyzer definition."
)
properties = {"analyzer": analyzer_name}
component_evidence.append(
{
"source": "SHOW INVERTED INDEX ANALYZER",
"analyzer": analyzer_name,
"tokenizer": observed_tokenizer,
"token_filters": list(observed_filters),
}
)
else:
tokenizer_name = (
"unicode"
if tokenizer is None
else _required_identifier(tokenizer, "tokenizer name").casefold()
)
if tokenizer_name not in _BUILT_IN_ANALYZERS:
raise _argument_failure(
"A custom tokenizer must be referenced through an existing "
"custom analyzer."
)
if requested_filters:
raise _argument_failure(
"Token filters require an existing custom analyzer; this "
"read-only tool does not create analyzer components."
)
properties = _built_in_analyzer_properties(tokenizer_name)
property_string = ",".join(
f'"{key}"="{_escape_property_value(value)}"'
for key, value in properties.items()
)
result = await self._execute(
"SELECT TOKENIZE(%s, %s) AS tokens",
params=(normalized_text, property_string),
max_rows=1,
mask_result=False,
)
raw_tokens = (
_value(result.data[0], "tokens")
if result.data and isinstance(result.data[0], Mapping)
else None
)
tokens = _normalize_tokens(raw_tokens)
return _result(
{
"tokens": tokens,
"token_count": len(tokens),
"analysis": {
"properties": properties,
"custom_analyzer": properties.get("analyzer"),
},
},
source="doris_tokenize",
metadata={
"input_bytes": len(normalized_text.encode("utf-8")),
"invented_tokens": False,
},
evidence=[
{
"source": "doris_sql",
"kind": "TOKENIZE",
"properties": properties,
},
*component_evidence,
],
)
async def inspect_search_indexes(
self,
*,
database: str,
table: str,
index: str | None,
) -> dict[str, Any]:
"""Return normalized inverted/ANN metadata and recorded build tasks."""
database_name = _required_identifier(database, "database name")
table_name = _required_identifier(table, "table name")
index_name = (
None
if index is None
else _required_identifier(index, "index name")
)
indexes = await self._read_indexes(database_name, table_name)
selected = tuple(
candidate
for candidate in indexes
if index_name is None
or candidate.name.casefold() == index_name.casefold()
)
if index_name is not None and not selected:
raise SearchRuntimeFailure(
"The requested Doris search index does not exist.",
reason_code="SEARCH_INDEX_NOT_FOUND",
status_code=404,
)
build_tasks: list[dict[str, Any]] = []
warnings: list[str] = []
try:
build_result = await self._execute(
"SHOW BUILD INDEX WHERE TableName = %s",
params=(table_name,),
max_rows=200,
mask_result=False,
database_context=database_name,
)
except SearchRuntimeFailure as exc:
warnings.append(
"Index build-task evidence is unavailable "
f"({exc.reason_code})."
)
else:
build_tasks = [
_normalized_build_task(row)
for row in (build_result.data or ())
if isinstance(row, Mapping)
]
inverted = [candidate for candidate in selected if candidate.is_inverted]
ann = [candidate for candidate in selected if candidate.is_ann]
capabilities = {
"text": bool(inverted),
"vector": bool(ann),
"hybrid": bool(inverted and ann),
"metrics": sorted(
{
metric
for candidate in ann
if (
metric := candidate.properties.get("metric_type")
)
}
),
}
return _result(
{
"items": [candidate.to_wire() for candidate in selected],
"build_tasks": build_tasks,
"capabilities": capabilities,
"truncated": False,
},
source="doris_search_index_metadata",
warnings=warnings,
metadata={
"database": database_name,
"table": table_name,
"index_filter": index_name,
"index_count": len(selected),
"build_task_count": len(build_tasks),
},
evidence=[
{
"source": "SHOW INDEX",
"rows_observed": len(indexes),
},
{
"source": "SHOW BUILD INDEX",
"rows_observed": len(build_tasks),
"available": not warnings,
},
],
)
async def diagnose_search_query(
self,
*,
sql: str | None,
search_request: Mapping[str, Any] | None,
include_profile: bool,
) -> dict[str, Any]:
"""Combine target index metadata, EXPLAIN, and optional profile facts."""
if bool(sql) == bool(search_request):
raise _argument_failure(
"Provide exactly one of sql or search_request."
)
compiled: _CompiledSearch | None = None
database: str | None = None
table: str | None = None
params: tuple[Any, ...] | None = None
warnings: list[str] = []
evidence: list[dict[str, Any]] = []
indexes: tuple[_SearchIndex, ...] = ()
if search_request is not None:
compiled = await self._compile_search(search_request)
query_sql = compiled.sql
params = compiled.params
database = compiled.database
table = compiled.table
indexes = compiled.indexes
else:
try:
statement = ReadOnlySQLGuard.validate(
str(sql),
query_target=True,
)
except QueryRuntimeFailure as exc:
raise SearchRuntimeFailure(
str(exc),
reason_code="SEARCH_ARGUMENT_INVALID",
status_code=400,
) from exc
if statement.operation != "SELECT":
raise _argument_failure(
"Search diagnosis accepts a SELECT query only."
)
query_sql = statement.sql
source = _simple_source(statement.sql)
if source is not None:
database, table = source
if database is not None:
try:
indexes = await self._read_indexes(database, table)
except SearchRuntimeFailure as exc:
warnings.append(
"Target index metadata is unavailable "
f"({exc.reason_code})."
)
if not indexes:
warnings.append(
"Raw SQL diagnosis could not bind authoritative target "
"index metadata; provide search_request for exact coverage."
)
explain = await self._execute(
f"EXPLAIN {query_sql}",
params=params,
max_rows=_MAX_EXPLAIN_ROWS,
mask_result=False,
database_context=database,
)
plan_rows = [
dict(row)
for row in (explain.data or ())
if isinstance(row, Mapping)
]
plan_text = "\n".join(
" | ".join(str(value) for value in row.values())
for row in plan_rows
)
facets = _search_plan_facets(plan_text)
evidence.append(
{
"source": "doris_sql",
"kind": "EXPLAIN",
"plan_rows": len(plan_rows),
}
)
if indexes:
evidence.append(
{
"source": "SHOW INDEX",
"kind": "target_index_metadata",
"indexes": [candidate.name for candidate in indexes],
}
)
findings = _diagnostic_findings(
compiled=compiled,
indexes=indexes,
facets=facets,
query_sql=query_sql,
)
profile: Mapping[str, Any] | None = None
if include_profile:
if search_request is not None:
warnings.append(
"Profile execution is not performed for parameter-bound "
"structured requests; EXPLAIN and index evidence are "
"returned without weakening parameter safety."
)
else:
try:
profile_result = await self._query_runtime.get_query_profile(
sql=query_sql,
database=database,
include_operator_tree=False,
)
except QueryRuntimeFailure as exc:
warnings.append(
"Query-profile evidence is unavailable "
f"({exc.reason_code})."
)
else:
profile = profile_result.get("data")
evidence.extend(profile_result.get("evidence", []))
warnings.extend(profile_result.get("warnings", []))
if not facets["ann_pushdown_observed"] and compiled is not None and (
compiled.mode in {"vector", "hybrid"}
):
warnings.append(
"The plan did not expose Doris ANN SORT INFO for the "
"requested vector path."
)
return _result(
{
"scope": {
"database": database,
"table": table,
"mode": compiled.mode if compiled is not None else "raw_sql",
},
"indexes": [candidate.to_wire() for candidate in indexes],
"explain": {
"facets": facets,
"plan_rows": plan_rows,
"truncated": bool(explain.metadata.get("truncated")),
},
"profile": profile,
"findings": findings,
},
source="deterministic_search_diagnosis",
warnings=warnings,
metadata={
"include_profile": include_profile,
"profile_observed": profile is not None,
"invented_index_hits": False,
"execution_time_seconds": explain.execution_time,
},
evidence=evidence,
)
async def _compile_search(
self,
request: Mapping[str, Any],
) -> _CompiledSearch:
if not isinstance(request, Mapping):
raise _argument_failure("search_request must be an object.")
database = _required_identifier(
request.get("database"),
"database name",
)
table = _required_identifier(request.get("table"), "table name")
mode = str(request.get("mode", "")).casefold()
if mode not in {"text", "vector", "hybrid"}:
raise _argument_failure(
"mode must be text, vector, or hybrid."
)
top_k = _bounded_integer(
request.get("top_k"),
default=_DEFAULT_TOP_K,
minimum=1,
maximum=_MAX_TOP_K,
label="top_k",
)
columns = await self._read_columns(database, table)
column_names = set(columns)
indexes = await self._read_indexes(database, table)
text_fields: tuple[str, ...] = ()
query: str | None = None
match_operator = _MATCH_OPERATORS.get(
str(request.get("text_operator") or "any").casefold()
)
if match_operator is None:
raise _argument_failure("text_operator is invalid.")
if mode in {"text", "hybrid"}:
query = _required_text(
request.get("query"),
"query",
maximum_bytes=_MAX_QUERY_TEXT_BYTES,
)
text_fields = _identifier_sequence(
request.get("fields"),
"search field",
maximum=_MAX_FIELDS,
required=True,
)
_require_known_columns(text_fields, column_names)
indexed_text_fields = {
column
for candidate in indexes
if candidate.is_inverted
for column in candidate.columns
}
missing_indexes = [
field
for field in text_fields
if field not in indexed_text_fields
]
if missing_indexes:
raise SearchRuntimeFailure(
"Every requested text field must have a visible inverted "
"index.",
reason_code="SEARCH_TEXT_INDEX_REQUIRED",
status_code=409,
)
elif request.get("query") not in (None, ""):
raise _argument_failure(
"query is only valid for text or hybrid mode."
)
vector_field: str | None = None
vector_values: tuple[float, ...] = ()
metric: str | None = None
vector_dimension: int | None = None
if mode in {"vector", "hybrid"}:
vector_values = _vector(request.get("vector"))
vector_field = _resolve_vector_field(
request.get("vector_field"),
indexes,
)
if vector_field not in column_names:
raise _argument_failure(
"The requested vector field is not visible on the table."
)
ann_index = _ann_index_for_field(indexes, vector_field)
if ann_index is None:
raise SearchRuntimeFailure(
"The requested vector field must have a visible ANN index.",
reason_code="SEARCH_ANN_INDEX_REQUIRED",
status_code=409,
)
metric = str(
ann_index.properties.get("metric_type", "")
).casefold()
if metric not in _VECTOR_METRICS:
raise SearchRuntimeFailure(
"The ANN index uses an unsupported or missing metric.",
reason_code="SEARCH_ANN_METRIC_UNSUPPORTED",
status_code=501,
)
vector_dimension = _as_int(ann_index.properties.get("dim"))
if vector_dimension is None:
raise SearchRuntimeFailure(
"The ANN index does not expose a valid vector dimension.",
reason_code="SEARCH_ANN_DIMENSION_UNKNOWN",
status_code=409,
)
if len(vector_values) != vector_dimension:
raise _argument_failure(
"The query vector dimension does not match the ANN index."
)
elif request.get("vector") not in (None, []):
raise _argument_failure(
"vector is only valid for vector or hybrid mode."
)
return_fields = _return_fields(
request.get("return_fields"),
columns,
vector_field=vector_field,
)
select_parts = [
quote_identifier(field, "return field")
for field in return_fields
]
where_parts: list[str] = []
select_params: list[Any] = []
where_params: list[Any] = []
if text_fields and query is not None:
field_predicates = [
f"{quote_identifier(field, 'search field')} "
f"{match_operator} %s"
for field in text_fields
]
where_parts.append("(" + " OR ".join(field_predicates) + ")")
where_params.extend(query for _ in field_predicates)
filter_sql, filter_params = _compile_filters(
request.get("filters"),
column_names,
)
where_parts.extend(filter_sql)
where_params.extend(filter_params)
order_sql = ""
if vector_field is not None and metric is not None:
function_name, direction = _VECTOR_METRICS[metric]
distance_alias = "__mcp_vector_distance"
if distance_alias in column_names:
raise SearchRuntimeFailure(
"The table uses a reserved Search result alias.",
reason_code="SEARCH_RESULT_ALIAS_CONFLICT",
status_code=409,
)
vector_expression = "CAST(%s AS ARRAY<FLOAT>)"
# SQL sink audit: field is validated and quoted; function, alias,
# and ordering are selected from fixed local maps; the vector is a
# bound JSON value before DorisConnection.execute.
select_parts.append( # nosec B608
f"{function_name}("
f"{quote_identifier(vector_field, 'vector field')}, "
f"{vector_expression}) AS `{distance_alias}`"
)
select_params.append(
json.dumps(
list(vector_values),
ensure_ascii=True,
separators=(",", ":"),
)
)
order_sql = f" ORDER BY `{distance_alias}` {direction}"
table_reference = build_table_reference(
table,
db_name=database,
)
where_sql = (
" WHERE " + " AND ".join(where_parts)
if where_parts
else ""
)
# SQL sink audit: all identifiers pass quote_identifier or
# build_table_reference; predicates/operators are local allowlists;
# every caller value remains bound at connection.execute; limit is a
# bounded integer.
sql = (
f"SELECT {', '.join(select_parts)} FROM {table_reference}" # nosec B608
f"{where_sql}{order_sql} LIMIT {top_k + 1}"
)
return _CompiledSearch(
sql=sql,
params=(*select_params, *where_params),
database=database,
table=table,
mode=mode,
top_k=top_k,
text_fields=text_fields,
vector_field=vector_field,
vector_metric=metric,
vector_dimension=vector_dimension,
return_fields=return_fields,
indexes=indexes,
)
async def _read_columns(
self,
database: str,
table: str,
) -> dict[str, str]:
result = await self._execute(
(
"SELECT COLUMN_NAME, DATA_TYPE "
"FROM information_schema.columns "
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s "
"ORDER BY ORDINAL_POSITION LIMIT 2048"
),
params=(database, table),
max_rows=2_048,
mask_result=False,
)
columns = {
str(_value(row, "column_name", "COLUMN_NAME")): str(
_value(row, "data_type", "DATA_TYPE") or ""
)
for row in (result.data or ())
if isinstance(row, Mapping)
and _value(row, "column_name", "COLUMN_NAME") not in (None, "")
}
if not columns:
raise SearchRuntimeFailure(
"The requested Doris table does not exist or is not visible.",
reason_code="SEARCH_TABLE_NOT_FOUND",
status_code=404,
)
return columns
async def _read_indexes(
self,
database: str,
table: str,
) -> tuple[_SearchIndex, ...]:
table_reference = build_table_reference(
table,
db_name=database,
)
# SQL sink audit: build_table_reference validates and quotes both
# identifiers before _execute sends this metadata read to
# connection.execute; no caller values are interpolated.
result = await self._execute(
f"SHOW INDEX FROM {table_reference}", # nosec B608
max_rows=512,
mask_result=False,
)
grouped: dict[tuple[str, str], dict[str, Any]] = {}
for row in result.data or ():
index_type = str(
_value(row, "index_type", "Index_type") or ""
).upper()
if index_type not in {"INVERTED", "ANN"}:
continue
name = str(_value(row, "key_name", "Key_name") or "")
column = str(
_value(row, "column_name", "Column_name") or ""
)
if not name or not column:
continue
key = (name, index_type)
state = grouped.setdefault(
key,
{
"columns": [],
"properties": {},
"comment": None,
},
)
if column not in state["columns"]:
state["columns"].append(column)
state["properties"].update(
_parse_properties(
_value(row, "properties", "Properties")
)
)
comment = _value(row, "comment", "Comment")
if comment not in (None, ""):
state["comment"] = str(comment)
return tuple(
_SearchIndex(
name=name,
index_type=index_type,
columns=tuple(state["columns"]),
properties=dict(state["properties"]),
comment=state["comment"],
)
for (name, index_type), state in sorted(grouped.items())
)
async def _custom_analyzer_definition(
self,
analyzer_name: str,
) -> Mapping[str, Any] | None:
result = await self._execute(
"SHOW INVERTED INDEX ANALYZER",
max_rows=512,
mask_result=False,
)
for row in result.data or ():
normalized = _normalized_row(row)
name = _value(
normalized,
"name",
"analyzer_name",
"analyzer",
)
if (
isinstance(name, str)
and name.casefold() == analyzer_name.casefold()
):
properties = _parse_properties(
_value(normalized, "properties")
)
return {**normalized, **properties}
return None
async def _execute(
self,
sql: str,
*,
params: Mapping[str, Any] | tuple[Any, ...] | None = None,
max_rows: int,
mask_result: bool,
database_context: str | None = None,
) -> QueryResult:
auth_context = get_current_auth_context()
session_id = f"{self._session_prefix}:{uuid.uuid4().hex[:8]}"
try:
async with (
self._connection_manager.get_connection_context_for_auth_context(
session_id,
auth_context,
) as connection
):
if database_context is not None:
safe_database = quote_identifier(
database_context,
"database name",
)
await connection.execute(
f"USE {safe_database}",
auth_context=auth_context,
mask_result=False,
max_rows=1,
max_bytes=1_024,
internal_session_control=True,
)
return await connection.execute(
sql,
params=params,
auth_context=auth_context,
mask_result=mask_result,
max_rows=max_rows,
max_bytes=_MAX_BYTES,
)
except Exception as exc:
raise _classify_failure(exc) from exc
def _result(
data: Mapping[str, Any],
*,
source: str,
warnings: Sequence[str] = (),
metadata: Mapping[str, Any] | None = None,
evidence: Sequence[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
unique_warnings = list(dict.fromkeys(str(item) for item in warnings))
response: dict[str, Any] = {
"status": "partial" if unique_warnings else "success",
"data": redact_sensitive_data(dict(data)),
"warnings": unique_warnings,
"metadata": {
"source": source,
**dict(metadata or {}),
},
}
if evidence is not None:
response["evidence"] = [
redact_sensitive_data(dict(item)) for item in evidence
]
return response
def _required_identifier(value: Any, label: str) -> str:
if not isinstance(value, str):
raise _argument_failure(f"{label} must be a non-empty string.")
try:
return validate_identifier(value, label)
except SQLSecurityError as exc:
raise _argument_failure(f"{label} is invalid.") from exc
def _required_text(
value: Any,
label: str,
*,
maximum_bytes: int,
) -> str:
if not isinstance(value, str) or not value.strip():
raise _argument_failure(f"{label} must be a non-empty string.")
normalized = value.strip()
if len(normalized.encode("utf-8")) > maximum_bytes:
raise _argument_failure(f"{label} exceeds the maximum accepted size.")
return normalized
def _bounded_integer(
value: Any,
*,
default: int,
minimum: int,
maximum: int,
label: str,
) -> int:
if value is None:
return default
if (
isinstance(value, bool)
or not isinstance(value, int)
or value < minimum
or value > maximum
):
raise _argument_failure(
f"{label} must be between {minimum} and {maximum}."
)
return int(value)
def _identifier_sequence(
values: Any,
label: str,
*,
maximum: int,
required: bool = False,
) -> tuple[str, ...]:
if values is None:
if required:
raise _argument_failure(f"At least one {label} is required.")
return ()
if (
not isinstance(values, Sequence)
or isinstance(values, str | bytes)
or not values
or len(values) > maximum
):
raise _argument_failure(
f"{label} values must be a non-empty bounded array."
)
normalized = tuple(
_required_identifier(value, label)
for value in values
)
if len(set(normalized)) != len(normalized):
raise _argument_failure(f"{label} values must be unique.")
return normalized
def _vector(value: Any) -> tuple[float, ...]:
if (
not isinstance(value, Sequence)
or isinstance(value, str | bytes)
or not value
or len(value) > _MAX_VECTOR_DIMENSION
):
raise _argument_failure(
"vector must be a non-empty bounded numeric array."
)
normalized: list[float] = []
for item in value:
if isinstance(item, bool) or not isinstance(item, int | float):
raise _argument_failure("vector values must be numbers.")
number = float(item)
if not math.isfinite(number):
raise _argument_failure("vector values must be finite.")
normalized.append(number)
return tuple(normalized)
def _return_fields(
values: Any,
columns: Mapping[str, str],
*,
vector_field: str | None,
) -> tuple[str, ...]:
if values is None:
selected = tuple(
name
for name, data_type in columns.items()
if name != vector_field
and not data_type.casefold().startswith("array<float")
)[:_MAX_RETURN_FIELDS]
if not selected:
raise _argument_failure(
"return_fields is required when no scalar columns are visible."
)
return selected
selected = _identifier_sequence(
values,
"return field",
maximum=_MAX_RETURN_FIELDS,
required=True,
)
_require_known_columns(selected, set(columns))
return selected
def _require_known_columns(
requested: Sequence[str],
available: set[str],
) -> None:
if any(column not in available for column in requested):
raise _argument_failure(
"One or more requested fields are not visible on the table."
)
def _resolve_vector_field(
requested: Any,
indexes: Sequence[_SearchIndex],
) -> str:
if requested is not None:
return _required_identifier(requested, "vector field")
candidates = sorted(
{
column
for index in indexes
if index.is_ann
for column in index.columns
}
)
if len(candidates) != 1:
raise _argument_failure(
"vector_field is required unless exactly one ANN-indexed field "
"is visible."
)
return candidates[0]
def _ann_index_for_field(
indexes: Sequence[_SearchIndex],
field: str,
) -> _SearchIndex | None:
candidates = [
index
for index in indexes
if index.is_ann and field in index.columns
]
if len(candidates) != 1:
return None
return candidates[0]
def _compile_filters(
value: Any,
columns: set[str],
) -> tuple[list[str], list[Any]]:
if value is None:
return [], []
if not isinstance(value, Mapping) or len(value) > _MAX_FILTERS:
raise _argument_failure("filters must be a bounded object.")
predicates: list[str] = []
params: list[Any] = []
for raw_field, raw_rule in value.items():
field = _required_identifier(raw_field, "filter field")
if field not in columns:
raise _argument_failure(
"One or more filter fields are not visible on the table."
)
quoted = quote_identifier(field, "filter field")
if not isinstance(raw_rule, Mapping):
_validate_scalar(raw_rule)
predicates.append(f"{quoted} = %s")
params.append(raw_rule)
continue
unknown = set(raw_rule) - {"operator", "value", "values"}
if unknown:
raise _argument_failure(
"Filter rules contain unsupported properties."
)
operator = str(raw_rule.get("operator", "")).casefold()
if operator in _FILTER_OPERATORS:
if "value" not in raw_rule or "values" in raw_rule:
raise _argument_failure(
"Scalar filter operators require exactly one value."
)
scalar = raw_rule["value"]
_validate_scalar(scalar)
predicates.append(
f"{quoted} {_FILTER_OPERATORS[operator]} %s"
)
params.append(scalar)
continue
if operator in _SET_FILTER_OPERATORS:
values = raw_rule.get("values")
if (
not isinstance(values, Sequence)
or isinstance(values, str | bytes)
or not values
or len(values) > _MAX_FILTER_VALUES
or "value" in raw_rule
):
raise _argument_failure(
"Set filter operators require a bounded values array."
)
for scalar in values:
_validate_scalar(scalar)
placeholders = ", ".join("%s" for _ in values)
predicates.append(
f"{quoted} {_SET_FILTER_OPERATORS[operator]} "
f"({placeholders})"
)
params.extend(values)
continue
if operator in _NULL_FILTER_OPERATORS:
if "value" in raw_rule or "values" in raw_rule:
raise _argument_failure(
"Null filter operators do not accept values."
)
predicates.append(
f"{quoted} {_NULL_FILTER_OPERATORS[operator]}"
)
continue
raise _argument_failure("Filter operator is invalid.")
return predicates, params
def _validate_scalar(value: Any) -> None:
if value is not None and not isinstance(
value,
str | int | float | bool,
):
raise _argument_failure("Filter values must be JSON scalars.")
if isinstance(value, float) and not math.isfinite(value):
raise _argument_failure("Filter numbers must be finite.")
if isinstance(value, str) and len(value.encode("utf-8")) > 8_192:
raise _argument_failure("Filter text exceeds the maximum accepted size.")
def _parse_properties(value: Any) -> dict[str, str]:
if isinstance(value, Mapping):
return {
str(key).casefold(): str(item)
for key, item in value.items()
}
if not isinstance(value, str):
return {}
return {
match.group("key").casefold(): match.group("value")
.replace('\\"', '"')
.replace("\\\\", "\\")
for match in _PROPERTY_PAIR.finditer(value)
}
def _normalized_build_task(row: Mapping[str, Any]) -> dict[str, Any]:
normalized = _normalized_row(row)
return {
"job_id": normalized.get("job_id"),
"table": normalized.get("table_name"),
"partition": normalized.get("partition_name"),
"state": normalized.get("state"),
"progress": normalized.get("progress"),
"message": normalized.get("msg"),
"create_time": _json_value(normalized.get("create_time")),
"finish_time": _json_value(normalized.get("finish_time")),
"alter_indexes": normalized.get("alter_inverted_indexes"),
}
def _normalize_tokens(value: Any) -> list[dict[str, Any]]:
parsed = value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise SearchRuntimeFailure(
"Doris TOKENIZE returned an invalid payload.",
reason_code="SEARCH_TOKENIZE_INVALID_RESPONSE",
status_code=502,
) from exc
if not isinstance(parsed, Sequence) or isinstance(parsed, str | bytes):
raise SearchRuntimeFailure(
"Doris TOKENIZE returned an invalid payload.",
reason_code="SEARCH_TOKENIZE_INVALID_RESPONSE",
status_code=502,
)
tokens: list[dict[str, Any]] = []
for offset, item in enumerate(parsed):
if isinstance(item, Mapping):
token = item.get("token")
if token is None:
continue
normalized: dict[str, Any] = {"term": str(token)}
if item.get("position") is not None:
normalized["position"] = item["position"]
if item.get("type") is not None:
normalized["type"] = item["type"]
else:
normalized = {"term": str(item), "position": offset}
tokens.append(normalized)
if len(tokens) >= 10_000:
break
return tokens
def _built_in_analyzer_properties(name: str) -> dict[str, str]:
key = "parser" if name in _BACKWARD_COMPATIBLE_PARSERS else (
"built_in_analyzer"
)
return {key: name}
def _split_component_names(value: Any) -> tuple[str, ...]:
parsed = value
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith("["):
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
parsed = value
if isinstance(parsed, str):
return tuple(
item.strip()
for item in parsed.split(",")
if item.strip()
)
if isinstance(parsed, Sequence) and not isinstance(parsed, str | bytes):
return tuple(
str(item).strip()
for item in parsed
if str(item).strip()
)
return ()
def _optional_identifier_value(value: Any) -> str | None:
if not isinstance(value, str) or not value:
return None
try:
return validate_identifier(value, "component name")
except SQLSecurityError:
return None
def _escape_property_value(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _search_plan_facets(plan_text: str) -> dict[str, Any]:
upper = plan_text.upper()
match_operators = sorted(
{
operator
for operator in (
"MATCH_ANY",
"MATCH_ALL",
"MATCH_PHRASE",
"MATCH_PHRASE_PREFIX",
"SEARCH",
)
if operator in upper
}
)
return {
"ann_pushdown_observed": "ANN SORT INFO" in upper,
"ann_sort_limit_observed": "ANN SORT LIMIT" in upper,
"text_match_predicate_observed": bool(match_operators),
"match_operators": match_operators,
"olap_scan_observed": "OLAPSCANNODE" in upper,
"profile_required_for_inverted_hit_confirmation": True,
}
def _diagnostic_findings(
*,
compiled: _CompiledSearch | None,
indexes: Sequence[_SearchIndex],
facets: Mapping[str, Any],
query_sql: str,
) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
if compiled is not None and compiled.text_fields:
findings.append(
{
"severity": "info",
"code": "TEXT_INDEX_CONFIGURED_AND_MATCH_PLANNED",
"message": (
"Visible inverted indexes cover the requested text fields "
"and EXPLAIN preserves a MATCH predicate. Query Profile is "
"required to prove runtime inverted-index filtering."
),
}
)
if compiled is not None and compiled.vector_field is not None:
if facets.get("ann_pushdown_observed"):
findings.append(
{
"severity": "info",
"code": "ANN_PUSHDOWN_OBSERVED",
"message": "Doris EXPLAIN reports ANN SORT INFO.",
}
)
else:
findings.append(
{
"severity": "high",
"code": "ANN_PUSHDOWN_NOT_OBSERVED",
"message": (
"The vector query has ANN metadata, but EXPLAIN did "
"not expose ANN SORT INFO."
),
}
)
if compiled is None:
upper = query_sql.upper()
if "MATCH" not in upper and "SEARCH(" not in upper and (
"_DISTANCE" not in upper
):
findings.append(
{
"severity": "medium",
"code": "SEARCH_OPERATOR_NOT_OBSERVED",
"message": (
"The submitted SQL does not expose a recognized Doris "
"text or vector search operator."
),
}
)
if not indexes:
findings.append(
{
"severity": "medium",
"code": "INDEX_METADATA_NOT_BOUND",
"message": (
"No authoritative target index metadata was bound to "
"the raw SQL diagnosis."
),
}
)
return findings
def _simple_source(sql: str) -> tuple[str | None, str] | None:
match = _SIMPLE_SOURCE.search(sql)
if match is None:
return None
database = _unquote_identifier(match.group("database"))
table = _unquote_identifier(match.group("table"))
if table is None:
return None
try:
table = validate_identifier(table, "table name")
if database is not None:
database = validate_identifier(database, "database name")
except SQLSecurityError:
return None
return database, table
def _unquote_identifier(value: str | None) -> str | None:
if value is None:
return None
return value[1:-1] if value.startswith("`") and value.endswith("`") else value
def _normalized_row(row: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key).strip().replace(" ", "_").casefold(): value
for key, value in row.items()
}
def _value(row: Mapping[str, Any], *names: str) -> Any:
normalized = _normalized_row(row)
for name in names:
key = name.strip().replace(" ", "_").casefold()
if key in normalized:
return normalized[key]
return None
def _json_value(value: Any) -> Any:
if value is None or isinstance(value, str | int | float | bool):
return value
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def _as_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _argument_failure(message: str) -> SearchRuntimeFailure:
return SearchRuntimeFailure(
message,
reason_code="SEARCH_ARGUMENT_INVALID",
status_code=400,
)
def _classify_failure(exc: Exception) -> SearchRuntimeFailure:
if isinstance(exc, SearchRuntimeFailure):
return exc
if isinstance(exc, QueryRuntimeFailure | SQLSecurityError):
return _argument_failure("Search arguments are invalid.")
numeric_code = next(
(
value
for value in getattr(exc, "args", ())
if isinstance(value, int)
),
None,
)
message = str(exc).casefold()
if numeric_code in {1044, 1045, 1142, 1227} or any(
marker in message
for marker in ("access denied", "permission denied", "privilege")
):
return SearchRuntimeFailure(
"Doris denied access to Search data or metadata.",
reason_code="SEARCH_PERMISSION_DENIED",
status_code=403,
)
if numeric_code in {1064, 1109, 1146} or any(
marker in message
for marker in (
"doesn't exist",
"does not exist",
"not supported",
"unsupported",
"unknown table",
"unknown function",
"no viable alternative",
)
):
return SearchRuntimeFailure(
"The requested Doris Search capability is unsupported.",
reason_code="SEARCH_CAPABILITY_UNSUPPORTED",
status_code=501,
)
if isinstance(exc, TimeoutError | ConnectionError | OSError):
return SearchRuntimeFailure(
"Doris Search is temporarily unavailable.",
reason_code="SEARCH_BACKEND_UNAVAILABLE",
status_code=503,
retryable=True,
)
return SearchRuntimeFailure(
"Doris Search execution failed.",
reason_code="SEARCH_EXECUTION_FAILED",
status_code=502,
)
__all__ = ["DorisSearchRuntime", "SearchRuntimeFailure"]