diff --git a/README.md b/README.md index 13ab2ea..30bfc5e 100644 --- a/README.md +++ b/README.md @@ -399,16 +399,16 @@ client.insert( "source_id": "3b767712b57211f09c170242ac130008", "enabled": 1, "vector": [1, 1, 1], - "title": "企业版和社区版的功能差异", - "content": "OceanBase 数据库提供企业版和社区版两种形态。", + "title": "Differences between enterprise and community editions", + "content": "OceanBase database provides both enterprise and community editions.", }, { "id": 2, "vector": [1, 2, 3], "enabled": 1, "source_id": "3b791472b57211f09c170242ac130008", - "title": "快速体验 OceanBase 社区版", - "content": "本文根据使用场景详细介绍如何快速部署 OceanBase 数据库。", + "title": "Quick start with OceanBase community edition", + "content": "This article introduces how to quickly deploy the OceanBase database in different scenarios.", }, # ... more data ] @@ -426,7 +426,7 @@ query = { "query_string": { "fields": ["title^10", "content"], # field weights "type": "best_fields", - "query": "oceanbase 数据 迁移", + "query": "oceanbase database migration", "minimum_should_match": "30%", "boost": 1 } @@ -501,3 +501,103 @@ You can also get the actual SQL that will be executed: sql = client.get_sql(index=test_table_name, body=body) print(sql) # prints the SQL query ``` + +#### SQL-level Hybrid Search (OceanBase >= 4.6.0) + +Since OceanBase 4.6.0, hybrid search can be performed with the SQL-level `HYBRID_SEARCH` table function: + +```sql +SELECT column, expr, ... FROM HYBRID_SEARCH(TABLE table_name, DSL_STRING); +``` + +Unlike the `DBMS_HYBRID_SEARCH` package interface (which composes a union SQL internally), the SQL-level syntax builds a logical fusion plan at plan stage, providing better hybrid search performance. `pyobvector` exposes it through `HybridSearch.sql_search`: + +```python +from pyobvector.client.hybrid_search import HybridSearch + +client = HybridSearch(uri="127.0.0.1:2881", user="test@test") + +rows = client.sql_search( + table_name=test_table_name, + dsl={ + # full-text route + "query": {"match": {"content": {"query": "oceanbase database", "boost": 0.3}}}, + # vector route + "knn": { + "field": "vector", + "k": 5, + "query_vector": "[1, 2, 3]", + "boost": 0.7, + }, + # fusion algorithm + "rank": {"rrf": {"rank_constant": 60, "rank_window_size": 10}}, + "size": 10, + }, +) +# rows is a list of dict, the relevance score of each row is in the `__score` field +for row in rows: + print(row["id"], row["__score"]) +``` + +**Note**: `sql_search` requires OceanBase version >= 4.6.0.0. The table must be a heap table (`ORGANIZATION = HEAP`, partitioned tables are supported). The `__score` relevance column is always included in the returned rows, even when only a subset of columns is requested via `columns`. + +##### DSL Reference + +The DSL string is a JSON document whose syntax is mostly compatible with Elasticsearch: + +- **Top-level keys**: + - `query`: full-text/scalar/json/array query route (scored) + - `knn`: vector search route, a single object or an array of objects (multi-path vector search) + - `rank`: fusion algorithm, `weighted_sum` (default) or `rrf` + - `min_score`: filter results whose final `__score` is below the threshold + - `from` / `size`: pagination (`from + size` must be in `[0, 10000]`, default `size` is 10) +- **Full-text queries** (require full-text index on the searched columns): + - `match`: single field, multiple keywords. Supports `operator` (`OR`/`AND`), `minimum_should_match`, `boost` + - `match_phrase`: phrase search. Supports `slop`, `boost` + - `multi_match`: multiple fields. Supports `fields` (with weights like `title^0.3`), `type` (`best_fields`/`most_fields`), `operator`, `minimum_should_match`, `boost` + - `query_string`: like `multi_match`, plus keyword weights (e.g. `"query": "gatsby^0.2 dream"`) and `default_operator` +- **Scalar queries** (non-scoring, cannot appear in scoring `must`/`should` of `bool`): + - `term` / `terms`: exact match(es) + - `range`: range conditions with `gt`/`gte`/`lt`/`lte` +- **JSON queries**: `json_contains`, `json_overlaps`, `json_member_of` (with `candidate` and optional `path`); a dotted field name such as `doc_json.name` in scalar queries works like `json_extract` +- **Array queries**: `array_contains`, `array_contains_all`, `array_overlaps` +- **`bool` query**: combine sub-queries with `must` (scored), `should` (scored), `filter` (non-scored), `must_not`, plus `minimum_should_match` and `boost`. At least one positive clause (`must`/`should`/`filter`) is required +- **`knn` parameters**: + - `field` (required): vector column name + - `k` (required, `[1, 16384]`): return top-K results + - `query_vector` (required): string form like `"[0.1, 0.2, 0.3]"` is recommended + - `similarity` (optional, `[0, 1]`): similarity threshold of this route (not supported for inner product) + - `boost` (optional): weight of this route in fusion + - `filter` (optional): per-route filter conditions, same syntax as `query` (non-scoring) + - `search_options` (optional): vector search tuning - `ef_search` (`[1, 1000]`), `refine_k` (`[1.0, 1000.0]`), `filter_mode` (`pre`, `pre-knn`, `pre-brute`, `post`, `post-index-merge`) +- **`rank` fusion**: + - `weighted_sum` (default): sum of per-route scores weighted by outer `boost`, with optional `normalizer: "minmax"` to normalize per-route scores into `[0, 1]` first, and `rank_window_size` + - `rrf`: Reciprocal Rank Fusion, score `1 / (rank + rank_constant)` per route (`rank_constant` defaults to 60, `rank_window_size` must be >= `size`); setting per-route `boost` makes it weighted RRF + +Each `query`/`knn` route is an independent query: filters are not shared between routes, results are unioned and re-ranked by the fusion algorithm, and `size` rows are returned. Field names in the DSL are case-insensitive. + +##### Restrictions + +- Only heap tables are supported; partitioned tables are supported +- Vector search requires a vector index (currently HNSW series only); full-text search requires a full-text index (a multi-column full-text index is not effective for hybrid search) +- Scalar/JSON/ARRAY filter conditions work with or without indexes (indexes recommended) +- `WHERE` / `ORDER BY` / `LIMIT` are not allowed at the same level as `HYBRID_SEARCH`. Filter or sort on the result with the `where` / `order_by` arguments of `sql_search` (the query is wrapped in a subquery automatically). **Security**: `where` and `order_by` are interpolated into the generated SQL verbatim, so they must be trusted SQL fragments and must never contain untrusted user input (SQL injection risk); prefer the DSL `filter` clauses for user-provided values: + +```python +rows = client.sql_search( + table_name=test_table_name, + dsl={ + "knn": { + "field": "vector", + "k": 10, + "query_vector": "[1, 2, 3]", + "filter": [{"range": {"id": {"gte": 5}}}], # per-route filter + } + }, + columns=["id", "title"], + where="enabled = 1", # extra filter on the hybrid search result + order_by="id DESC", # extra sorting on the hybrid search result +) +``` + +- Multi-path vector search does not support sparse vectors; generated columns cannot be used in the DSL diff --git a/pyobvector/client/hybrid_search.py b/pyobvector/client/hybrid_search.py index 048c3f5..314d0f6 100644 --- a/pyobvector/client/hybrid_search.py +++ b/pyobvector/client/hybrid_search.py @@ -14,6 +14,11 @@ logger.setLevel(logging.DEBUG) +def _quote_identifier(identifier: str) -> str: + """Quote a MySQL/OceanBase identifier with backticks.""" + return "`" + identifier.replace("`", "``") + "`" + + class HybridSearch(Client): """The OceanBase Hybrid Search Client""" @@ -95,3 +100,110 @@ def get_sql( if res[0] is None: return "" return res[0] + + def _check_sql_search_version(self): + min_required_version = ObVersion.from_db_version_nums(4, 6, 0, 0) + if self.ob_version < min_required_version and not self._is_seekdb(): + raise ClusterVersionException( + code=ErrorCode.NOT_SUPPORTED, + message=ExceptionsMessage.ClusterVersionIsLow + % ("Hybrid Search SQL syntax (HYBRID_SEARCH)", "4.6.0.0"), + ) + + def sql_search( + self, + table_name: str, + dsl: dict[str, Any] | str, + columns: list[str] | None = None, + where: str | None = None, + order_by: str | None = None, + ) -> list[dict[str, Any]]: + """Execute hybrid search with the SQL-level `HYBRID_SEARCH` syntax. + + This uses the `HYBRID_SEARCH` table function introduced in OceanBase 4.6.0: + `SELECT ... FROM HYBRID_SEARCH(TABLE table_name, DSL_STRING)`. + + Compared with `search` (based on the `DBMS_HYBRID_SEARCH` package), the + SQL-level syntax builds a logical fusion plan at plan stage and provides + better hybrid search performance. The DSL string is a JSON document whose + syntax is mostly compatible with Elasticsearch, e.g.:: + + { + "query": {"match": {"content": "python javascript"}}, + "knn": { + "field": "vector_col", + "k": 5, + "query_vector": "[0.1, 0.2, 0.3, 0.4]", + "boost": 0.7, + }, + "rank": {"rrf": {"rank_constant": 60, "rank_window_size": 10}}, + "size": 10, + } + + Args: + table_name: The name of the table to search. Only heap tables are + supported (partitioned tables included). + dsl: The hybrid search DSL, either a dict (serialized to JSON) or a + JSON string. + columns: Plain column names to select. If None, all columns of the + table plus the `__score` relevance column are returned. The + `__score` column is always included in the result, even when + it is not listed in `columns`. + where: Extra filter condition applied on the hybrid search result. + OceanBase does not allow WHERE/ORDER BY/LIMIT at the same level + as `HYBRID_SEARCH`, so the query is wrapped in a subquery + automatically when `where` or `order_by` is set. This fragment + is interpolated into the SQL statement verbatim: it must be a + trusted SQL fragment and must never contain untrusted user + input, otherwise it becomes a SQL injection risk. Prefer the + DSL `filter` clauses for user-provided values. + order_by: Sort expression applied on the hybrid search result, e.g. + `id DESC` (wrapped in a subquery automatically). Like `where`, + this fragment is interpolated verbatim and must be trusted SQL + that never contains untrusted user input. + + Returns: + A list of rows (dict). The relevance score of each row is in the + `__score` field. + + Raises: + ClusterVersionException: If the OceanBase cluster version is below + 4.6.0.0. + """ + self._check_sql_search_version() + + if isinstance(dsl, dict): + dsl_str = json.dumps(dsl, ensure_ascii=False) + else: + dsl_str = dsl + + if columns is None: + col_expr = "*" + else: + # `HYBRID_SEARCH` always produces a `__score` relevance column. + # Keep it in the projection so that every returned row carries + # its score even when a column subset is requested. + selected = [_quote_identifier(c) for c in columns] + if all(c.lower() != "__score" for c in columns): + selected.append(_quote_identifier("__score")) + col_expr = ", ".join(selected) + hybrid_search_from = ( + f"HYBRID_SEARCH(TABLE {_quote_identifier(table_name)}, :dsl)" + ) + + if where is None and order_by is None: + stmt = f"SELECT {col_expr} FROM {hybrid_search_from}" + else: + stmt = ( + f"SELECT {col_expr} FROM " + f"(SELECT * FROM {hybrid_search_from}) AS __hybrid_search_result" + ) + if where is not None: + stmt += f" WHERE {where}" + if order_by is not None: + stmt += f" ORDER BY {order_by}" + + with self.engine.connect() as conn: + with conn.begin(): + res = conn.execute(text(stmt), {"dsl": dsl_str}) + return [dict(row) for row in res.mappings().fetchall()] diff --git a/pyobvector/json_table/virtual_data_type.py b/pyobvector/json_table/virtual_data_type.py index 937c715..50386de 100644 --- a/pyobvector/json_table/virtual_data_type.py +++ b/pyobvector/json_table/virtual_data_type.py @@ -78,7 +78,9 @@ def check_float(v): else: integer_part, decimal_part = decimal_str, "" - integer_count = len(integer_part.lstrip("-")) # 去掉负号的长度 + integer_count = len( + integer_part.lstrip("-") + ) # length without the negative sign decimal_count = len(decimal_part) if integer_count + min(decimal_count, y) > x: diff --git a/source/pyobvector.client.rst b/source/pyobvector.client.rst index f8f10de..4ee04d4 100644 --- a/source/pyobvector.client.rst +++ b/source/pyobvector.client.rst @@ -28,6 +28,14 @@ pyobvector.client.exceptions module :undoc-members: :show-inheritance: +pyobvector.client.hybrid\_search module +--------------------------------------- + +.. automodule:: pyobvector.client.hybrid_search + :members: + :undoc-members: + :show-inheritance: + pyobvector.client.index\_param module ------------------------------------- diff --git a/tests/test_fts_index.py b/tests/test_fts_index.py index 98245f4..987ab10 100644 --- a/tests/test_fts_index.py +++ b/tests/test_fts_index.py @@ -165,8 +165,8 @@ def test_fts_parser_types(self): # Insert test data test_data = [ {"id": 1, "doc": "OceanBase is a distributed database"}, - {"id": 2, "doc": "全文索引测试 Full text search test"}, - {"id": 3, "doc": "我喜欢编程 I like coding"}, + {"id": 2, "doc": "Full text search index test"}, + {"id": 3, "doc": "I like coding and programming"}, ] self.client.insert(test_collection_name, data=test_data) @@ -276,10 +276,10 @@ def test_fts_multi_fields(self): test_data = [ { "id": 1, - "title": "OceanBase 数据库", + "title": "OceanBase database", "content": "OceanBase is a distributed database", }, - {"id": 2, "title": "全文索引", "content": "Full text search index"}, + {"id": 2, "title": "Full text search", "content": "Full text search index"}, ] self.client.insert(test_collection_name, data=test_data) @@ -287,7 +287,7 @@ def test_fts_multi_fields(self): res_title = self.client.get( test_collection_name, ids=None, - where_clause=[MatchAgainst("数据库", "title")], + where_clause=[MatchAgainst("database", "title")], output_column_name=["id", "title"], n_limits=10, ) @@ -315,9 +315,9 @@ def test_fts_multi_fields(self): self.client.drop_table_if_exist(test_collection_name) - def test_fts_chinese_search(self): - """Test Chinese full-text search""" - test_collection_name = "fts_chinese_test" + def test_fts_ik_parser_search(self): + """Test full-text search with the IK parser""" + test_collection_name = "fts_ik_parser_test" self.client.drop_table_if_exist(test_collection_name) cols = [ @@ -325,9 +325,9 @@ def test_fts_chinese_search(self): Column("doc", TEXT), ] - # Use IK parser, suitable for Chinese + # Use IK parser fts_index_param = FtsIndexParam( - index_name="fts_idx_chinese", + index_name="fts_idx_ik", field_names=["doc"], parser_type=FtsParser.IK, ) @@ -339,27 +339,27 @@ def test_fts_chinese_search(self): ) test_data = [ - {"id": 1, "doc": "海洋数据库 OceanBase"}, - {"id": 2, "doc": "全文索引功能测试"}, - {"id": 3, "doc": "我喜欢使用 OceanBase 数据库"}, - {"id": 4, "doc": "测试数据 test data"}, + {"id": 1, "doc": "Ocean database OceanBase"}, + {"id": 2, "doc": "Full text search feature test"}, + {"id": 3, "doc": "I like using OceanBase database"}, + {"id": 4, "doc": "sample test data"}, ] self.client.insert(test_collection_name, data=test_data) - # Test Chinese search + # Test search with the IK parser res = self.client.get( test_collection_name, ids=None, - where_clause=[MatchAgainst("数据库", "doc")], + where_clause=[MatchAgainst("database", "doc")], output_column_name=["id", "doc"], n_limits=10, ) rows = res.fetchall() - # Verify that Chinese full-text search works by checking at least one result + # Verify that full-text search works by checking at least one result self.assertGreater( len(rows), 0, - "Chinese search should return at least one result for '数据库'", + "IK parser search should return at least one result for 'database'", ) self.client.drop_table_if_exist(test_collection_name) diff --git a/tests/test_hybrid_search.py b/tests/test_hybrid_search.py index b587941..fbcebd8 100644 --- a/tests/test_hybrid_search.py +++ b/tests/test_hybrid_search.py @@ -5,6 +5,7 @@ from pyobvector import VECTOR, VectorIndex, FtsIndexParam, FtsParser from pyobvector.client.hybrid_search import HybridSearch +from pyobvector.util import ObVersion logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -14,6 +15,13 @@ class HybridSearchTest(unittest.TestCase): def setUp(self) -> None: self.client = HybridSearch() + def _skip_if_sql_search_not_supported(self): + if ( + self.client.ob_version < ObVersion.from_db_version_nums(4, 6, 0, 0) + and not self.client._is_seekdb() + ): + self.skipTest("HYBRID_SEARCH SQL syntax requires OceanBase >= 4.6.0.0") + def _create_test_table(self, test_table_name: str): self.client.create_table( table_name=test_table_name, @@ -53,32 +61,32 @@ def _create_test_table(self, test_table_name: str): "source_id": "3b767712b57211f09c170242ac130008", "enabled": 1, "vector": [1, 1, 1], - "title": "企业版和社区版的功能差异", - "content": "OceanBase 数据库提供企业版和社区版两种形态。", + "title": "Differences between enterprise and community editions", + "content": "OceanBase database provides both enterprise and community editions.", }, { "id": 2, "vector": [1, 2, 3], "enabled": 1, "source_id": "3b791472b57211f09c170242ac130008", - "title": "快速体验 OceanBase 社区版", - "content": "本文根据使用场景详细介绍如何快速部署 OceanBase 数据库,旨在帮助您快速掌握并成功使用 OceanBase 数据库。", + "title": "Quick start with OceanBase community edition", + "content": "This article introduces how to quickly deploy the OceanBase database in different scenarios, helping you get started with the OceanBase database quickly.", }, { "id": 3, "source_id": "3b7af31eb57211f09c170242ac130008", "enabled": 1, "vector": [3, 2, 1], - "title": "配置最佳实践", - "content": "为了确保用户在各种业务场景下,能够基于 OceanBase 数据库获得比较好的性能,OceanBase 基于过往大量真实场景的调优经验总结了各类业务场景下一些核心配置项和变量的推荐配置。", + "title": "Configuration best practices", + "content": "To ensure good performance across various business scenarios, OceanBase summarizes recommended settings for core configuration items and variables based on tuning experience from real-world scenarios.", }, { "id": 4, "source_id": "3b7cb9ceb57211f09c170242ac130008", "enabled": 1, "vector": [2, 2, 2], - "title": "OceanBase 实时分析能力白皮书", - "content": "重点解读 OceanBase 实时分析能力的 8 大核心特性,以及在 HTAP 混合负载场景、实时数据分析场景,和 PL/SQL 批处理场景的应用实践与案例。", + "title": "OceanBase real-time analytics white paper", + "content": "An in-depth interpretation of the 8 core features of OceanBase real-time analytics, with practices and cases in HTAP hybrid workloads, real-time data analysis, and PL/SQL batch processing.", }, ], ) @@ -91,7 +99,7 @@ def _search_param(self): "query_string": { "fields": ["title^10", "content"], "type": "best_fields", - "query": '((数据)^0.5106318299637825 (迁移)^0.2651122588583924 (oceanbase)^0.22425591117782506 ("oceanbase 数据 迁移"~2)^1.5)', + "query": '((database)^0.5106318299637825 (migration)^0.2651122588583924 (oceanbase)^0.22425591117782506 ("oceanbase database migration"~2)^1.5)', "minimum_should_match": "30%", "boost": 1, } @@ -143,3 +151,104 @@ def test_get_sql(self): sql = self.client.get_sql(index=test_table_name, body=body) res = self.client.perform_raw_text_sql(sql).fetchall() assert len(res) > 0 + + def test_sql_search_knn(self): + self._skip_if_sql_search_not_supported() + test_table_name = "sql_search_knn_test" + self._create_test_table(test_table_name) + + res = self.client.sql_search( + table_name=test_table_name, + dsl={ + "knn": { + "field": "vector", + "k": 2, + "query_vector": "[1, 2, 3]", + } + }, + ) + assert len(res) == 2 + assert res[0]["id"] == 2 + assert "__score" in res[0] + + def test_sql_search_match(self): + self._skip_if_sql_search_not_supported() + test_table_name = "sql_search_match_test" + self._create_test_table(test_table_name) + + res = self.client.sql_search( + table_name=test_table_name, + dsl={"query": {"match": {"content": "OceanBase database"}}}, + columns=["id", "title"], + ) + assert len(res) > 0 + assert set(res[0].keys()) == {"id", "title", "__score"} + + def test_sql_search_hybrid_with_rrf(self): + self._skip_if_sql_search_not_supported() + test_table_name = "sql_search_hybrid_test" + self._create_test_table(test_table_name) + + res = self.client.sql_search( + table_name=test_table_name, + dsl={ + "query": { + "match": {"content": {"query": "OceanBase database", "boost": 0.3}} + }, + "knn": { + "field": "vector", + "k": 4, + "query_vector": "[1, 2, 3]", + "boost": 0.7, + }, + "rank": { + "rrf": { + "rank_constant": 60, + "rank_window_size": 10, + } + }, + "size": 10, + }, + ) + assert len(res) > 0 + assert "__score" in res[0] + + def test_sql_search_multi_knn(self): + self._skip_if_sql_search_not_supported() + test_table_name = "sql_search_multi_knn_test" + self._create_test_table(test_table_name) + + res = self.client.sql_search( + table_name=test_table_name, + dsl={ + "knn": [ + {"field": "vector", "k": 2, "query_vector": "[1, 2, 3]"}, + {"field": "vector", "k": 2, "query_vector": "[1, 1, 1]"}, + ] + }, + ) + assert len(res) > 0 + assert "__score" in res[0] + + def test_sql_search_with_filter_and_outer_where(self): + self._skip_if_sql_search_not_supported() + test_table_name = "sql_search_filter_test" + self._create_test_table(test_table_name) + + res = self.client.sql_search( + table_name=test_table_name, + dsl={ + "knn": { + "field": "vector", + "k": 4, + "query_vector": "[1, 2, 3]", + "filter": [{"range": {"id": {"gte": 2}}}], + } + }, + where="enabled = 1", + order_by="id DESC", + ) + assert len(res) > 0 + ids = [row["id"] for row in res] + assert all(i >= 2 for i in ids) + assert ids == sorted(ids, reverse=True) diff --git a/tests/test_json_table.py b/tests/test_json_table.py index 656f68f..638740c 100644 --- a/tests/test_json_table.py +++ b/tests/test_json_table.py @@ -910,55 +910,69 @@ def test_online_cases(self): ], ) self.client.perform_json_table_sql( - "INSERT INTO `table_unit_test` (id, field0, field1, field2, field4) VALUES (1, '汽车保养', 1, 1000.00, '4S 店')" + "INSERT INTO `table_unit_test` (id, field0, field1, field2, field4) VALUES (1, 'car maintenance', 1, 1000.00, '4S store')" ) self.client.perform_json_table_sql( - "INSERT INTO `table_unit_test` (id, field0, field1, field2, field3, field4) VALUES (2, '奶茶', 2, 15.00, CURRENT_TIMESTAMP(), '商场'), (3, '书', 2, 40.00, NOW() - INTERVAL '1' DAY, '商场'), (4, '手机', 2, 6000.00, '2025-03-09', '商场')" + "INSERT INTO `table_unit_test` (id, field0, field1, field2, field3, field4) VALUES (2, 'milk tea', 2, 15.00, CURRENT_TIMESTAMP(), 'mall'), (3, 'book', 2, 40.00, NOW() - INTERVAL '1' DAY, 'mall'), (4, 'mobile phone', 2, 6000.00, '2025-03-09', 'mall')" ) res = self.client.perform_json_table_sql( - "SELECT field0 AS 消费内容, field1 AS 消费类型, field2 AS 消费金额, field4 AS 消费地点 FROM `table_unit_test` LIMIT 2" + "SELECT field0 AS consume_item, field1 AS consume_type, field2 AS consume_amount, field4 AS consume_location FROM `table_unit_test` LIMIT 2" ) self.assertEqual( get_all_rows(res), [ - ("汽车保养", 1, Decimal("1000.00"), "4S 店"), - ("奶茶", 2, Decimal("15.00"), "商场"), + ("car maintenance", 1, Decimal("1000.00"), "4S store"), + ("milk tea", 2, Decimal("15.00"), "mall"), ], ) res = self.client.perform_json_table_sql( - "SELECT field2 FROM `table_unit_test` WHERE field0 like '%汽车%' AND field4 like '%店%' ORDER BY field2 DESC LIMIT 2" + "SELECT field2 FROM `table_unit_test` WHERE field0 like '%car%' AND field4 like '%store%' ORDER BY field2 DESC LIMIT 2" ) self.assertEqual(get_all_rows(res), [(Decimal("1000.00"),)]) res = self.client.perform_json_table_sql( - "SELECT field0 AS 消费内容, `table_unit_test`.field1 AS 消费类型, field2 AS 消费金额 FROM `table_unit_test` WHERE DATE(`table_unit_test`.field3)='2025-03-09' ORDER BY 消费金额 DESC LIMIT 2" + "SELECT field0 AS consume_item, `table_unit_test`.field1 AS consume_type, field2 AS consume_amount FROM `table_unit_test` WHERE DATE(`table_unit_test`.field3)='2025-03-09' ORDER BY consume_amount DESC LIMIT 2" ) # logger.info(get_all_rows(res)) - self.assertEqual(get_all_rows(res), [("手机", 2, Decimal("6000.00"))]) + self.assertEqual(get_all_rows(res), [("mobile phone", 2, Decimal("6000.00"))]) res = self.client.perform_json_table_sql( - "SELECT field0, field1, field2, field3 FROM `table_unit_test` WHERE field4 like '%商场%' AND field3 <= CAST('2025-03-10' AS DATE) LIMIT 20" + "SELECT field0, field1, field2, field3 FROM `table_unit_test` WHERE field4 like '%mall%' AND field3 <= CAST('2025-03-10' AS DATE) LIMIT 20" ) self.assertEqual( get_all_rows(res), - [("手机", 2, Decimal("6000.00"), datetime.datetime(2025, 3, 9, 0, 0))], + [ + ( + "mobile phone", + 2, + Decimal("6000.00"), + datetime.datetime(2025, 3, 9, 0, 0), + ) + ], ) self.client.perform_json_table_sql( - "UPDATE `table_unit_test` SET field3 = '2025-03-14' WHERE field0 = '汽车保养'" + "UPDATE `table_unit_test` SET field3 = '2025-03-14' WHERE field0 = 'car maintenance'" ) res = self.client.perform_json_table_sql( - "SELECT field0, field1, field2, field3 FROM `table_unit_test` WHERE DATE(field3) = '2025-03-14' AND field0 = '汽车保养'" + "SELECT field0, field1, field2, field3 FROM `table_unit_test` WHERE DATE(field3) = '2025-03-14' AND field0 = 'car maintenance'" ) self.assertEqual( get_all_rows(res), - [("汽车保养", 1, Decimal("1000.00"), datetime.datetime(2025, 3, 14, 0, 0))], + [ + ( + "car maintenance", + 1, + Decimal("1000.00"), + datetime.datetime(2025, 3, 14, 0, 0), + ) + ], ) self.client.perform_json_table_sql( - "UPDATE `table_unit_test` SET field0 = CONCAT(field0, '代办') WHERE DATE(field3) = '2025-03-14'" + "UPDATE `table_unit_test` SET field0 = CONCAT(field0, ' agency') WHERE DATE(field3) = '2025-03-14'" ) res = self.client.perform_json_table_sql( "SELECT field0, field1, field2, field3 FROM `table_unit_test` WHERE DATE(field3) = '2025-03-14'" @@ -967,7 +981,7 @@ def test_online_cases(self): get_all_rows(res), [ ( - "汽车保养代办", + "car maintenance agency", 1, Decimal("1000.00"), datetime.datetime(2025, 3, 14, 0, 0), diff --git a/tests/test_ob_vec_client.py b/tests/test_ob_vec_client.py index 91aa9f9..b96ecd9 100644 --- a/tests/test_ob_vec_client.py +++ b/tests/test_ob_vec_client.py @@ -13,7 +13,7 @@ def setUp(self) -> None: self.client = ObVecClient() # Set your link string. def test_ann_search(self): - test_collection_name = "OB文档_ann_test" + test_collection_name = "OB_doc_ann_test" self.client.drop_table_if_exist(test_collection_name) # create partitioned table