Skip to content

Commit e45fc79

Browse files
committed
feat(postgres): fulltext_search capability, benchmark markers, composite_pk mapped provider, partition explain test
- mixins/ddl/index: implement supports_fulltext_search() (tsvector/tsquery via GIN), supports_fulltext_boolean_mode() (tsquery boolean operators), and refine docstrings distinguishing DDL (CREATE FULLTEXT INDEX, False) from query capability (True). - pyproject: register benchmark* pytest markers. - tests/providers/composite_pk: add MappedOrderItem/Async setup helpers. - Add tests/.../feature/backend/test_partition_explain.py covering partition EXPLAIN plan introspection.
1 parent e691dc4 commit e45fc79

4 files changed

Lines changed: 557 additions & 27 deletions

File tree

‎pyproject.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ markers = [
9090
"requires_functions: marks tests requiring specific database functions",
9191
"requires_inner_join: marks tests requiring INNER JOIN support",
9292
"benchmark_fastapi: marks FastAPI benchmark tests",
93+
"benchmark: marks benchmark tests",
94+
"benchmark_backend: marks backend benchmark tests",
95+
"benchmark_async: marks async benchmark tests",
96+
"benchmark_sync: marks sync benchmark tests",
97+
"benchmark_write: marks write benchmark tests",
98+
"benchmark_read: marks read benchmark tests",
9399
]
94100

95101
[tool.coverage.run]

‎src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py‎

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -194,51 +194,134 @@ def get_supported_index_types(self) -> List[str]:
194194
return ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"]
195195

196196
# =========================================================================
197-
# Fulltext methods — PostgreSQL does NOT use MySQL-style FULLTEXT indexes
197+
# Fulltext search — PostgreSQL uses tsvector/tsquery
198198
# =========================================================================
199199

200+
_FT_DEFAULT_CONFIG = 'english'
201+
200202
def supports_fulltext_index(self) -> bool:
201-
"""PostgreSQL uses GIN index + tsvector/tsquery, not MySQL-style FULLTEXT."""
203+
"""Whether MySQL-style FULLTEXT indexes are supported.
204+
205+
PostgreSQL does not use CREATE FULLTEXT INDEX; instead it uses
206+
CREATE INDEX ... USING GIN (to_tsvector(...)).
207+
"""
202208
return False
203209

210+
def supports_fulltext_search(self) -> bool:
211+
"""Whether full-text search is supported.
212+
213+
PostgreSQL supports full-text search via to_tsvector() @@ to_tsquery()
214+
with GIN/GIST indexes, not via MySQL-style MATCH...AGAINST.
215+
"""
216+
return True
217+
204218
def supports_fulltext_parser(self) -> bool:
205219
"""PostgreSQL does not have MySQL-style FULLTEXT parsers."""
206220
return False
207221

208222
def supports_fulltext_boolean_mode(self) -> bool:
209-
"""PostgreSQL does not support MySQL-style FULLTEXT boolean mode."""
210-
return False
223+
"""Whether tsquery-style boolean operators are supported.
224+
225+
PostgreSQL's to_tsquery() supports & (AND), | (OR), ! (NOT)
226+
operators, equivalent to MySQL's BOOLEAN MODE.
227+
"""
228+
return True
211229

212230
def supports_fulltext_query_expansion(self) -> bool:
213-
"""PostgreSQL does not support MySQL-style FULLTEXT query expansion."""
231+
"""Whether query expansion is supported.
232+
233+
PostgreSQL supports this via the thesaurus dictionary or
234+
phraseto_tsquery() for phrase matching.
235+
"""
214236
return False
215237

216-
def format_fulltext_match(self, columns, search_term, mode=None) -> Tuple[str, Tuple]:
217-
"""Not supported — PostgreSQL uses tsvector @@ tsquery instead."""
218-
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
219-
raise UnsupportedFeatureError(
220-
"PostgreSQL",
221-
"FULLTEXT MATCH",
222-
suggestion="Use tsvector @@ tsquery for full-text search.",
223-
)
238+
def format_fulltext_match(
239+
self, columns: List[str], search_term: str, mode: Optional[str] = None
240+
) -> Tuple[str, Tuple]:
241+
"""Format tsvector @@ tsquery expression for PostgreSQL full-text search.
242+
243+
Produces: to_tsvector('config', col1 || ' ' || col2) @@ tsquery_func(search_term)
244+
245+
Args:
246+
columns: Columns to include in the tsvector expression
247+
search_term: Search term to convert to tsquery
248+
mode: Search mode:
249+
- None / 'NATURAL LANGUAGE': plainto_tsquery (plain text)
250+
- 'BOOLEAN': to_tsquery (operators &, |, !)
251+
- 'PHRASE': phraseto_tsquery (phrase matching)
252+
253+
Returns:
254+
Tuple of (SQL string, parameters tuple)
255+
"""
256+
config = self._FT_DEFAULT_CONFIG
257+
ph = self.get_parameter_placeholder()
258+
259+
if len(columns) == 1:
260+
tsvector_expr = f"to_tsvector('{config}', {self.format_identifier(columns[0])})"
261+
else:
262+
parts = [self.format_identifier(c) for c in columns]
263+
tsvector_expr = f"to_tsvector('{config}', {' || ' .join(parts)})"
264+
265+
if mode:
266+
mode_upper = mode.upper()
267+
if mode_upper == "BOOLEAN":
268+
tsquery_expr = f"to_tsquery('{config}', {ph})"
269+
elif mode_upper == "PHRASE":
270+
tsquery_expr = f"phraseto_tsquery('{config}', {ph})"
271+
else:
272+
tsquery_expr = f"plainto_tsquery('{config}', {ph})"
273+
else:
274+
tsquery_expr = f"plainto_tsquery('{config}', {ph})"
275+
276+
return f"{tsvector_expr} @@ {tsquery_expr}", (search_term,)
224277

225278
def format_create_fulltext_index_statement(self, expr) -> Tuple[str, tuple]:
226-
"""Not supported — use CREATE INDEX with GIN and tsvector expression."""
227-
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
228-
raise UnsupportedFeatureError(
229-
"PostgreSQL",
230-
"CREATE FULLTEXT INDEX",
231-
suggestion="Use CREATE INDEX ... ON ... USING GIN (to_tsvector(...))",
232-
)
279+
"""Format CREATE INDEX ... USING GIN for PostgreSQL full-text search.
280+
281+
Converts a MySQL-style CREATE FULLTEXT INDEX expression into a
282+
PostgreSQL GIN index on to_tsvector().
283+
284+
Args:
285+
expr: CreateFulltextIndexExpression object
286+
287+
Returns:
288+
Tuple of (SQL string, parameters tuple)
289+
"""
290+
config = self._FT_DEFAULT_CONFIG
291+
parts = ["CREATE INDEX"]
292+
if expr.if_not_exists:
293+
parts.append("IF NOT EXISTS")
294+
parts.append(self.format_identifier(expr.index_name))
295+
parts.append("ON")
296+
parts.append(self.format_identifier(expr.table_name))
297+
parts.append("USING GIN")
298+
299+
if len(expr.columns) == 1:
300+
tsvector_expr = f"to_tsvector('{config}', {self.format_identifier(expr.columns[0])})"
301+
else:
302+
col_parts = [self.format_identifier(c) for c in expr.columns]
303+
tsvector_expr = f"to_tsvector('{config}', {' || ' .join(col_parts)})"
304+
parts.append(f"({tsvector_expr})")
305+
306+
return " ".join(parts), ()
233307

234308
def format_drop_fulltext_index_statement(self, expr) -> Tuple[str, tuple]:
235-
"""Not supported — use DROP INDEX instead."""
236-
from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError
237-
raise UnsupportedFeatureError(
238-
"PostgreSQL",
239-
"DROP FULLTEXT INDEX",
240-
suggestion="Use DROP INDEX to remove a GIN index.",
241-
)
309+
"""Format DROP INDEX statement for PostgreSQL.
310+
311+
PostgreSQL uses DROP INDEX (without ON table) for all index types,
312+
including GIN indexes used for full-text search.
313+
314+
Args:
315+
expr: DropFulltextIndexExpression object
316+
317+
Returns:
318+
Tuple of (SQL string, parameters tuple)
319+
"""
320+
parts = ["DROP INDEX"]
321+
if expr.if_exists:
322+
parts.append("IF EXISTS")
323+
parts.append(self.format_identifier(expr.index_name))
324+
return " ".join(parts), ()
242325

243326
def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]:
244327
"""Format CREATE INDEX statement with PostgreSQL-specific options.

‎tests/providers/composite_pk.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
OrderItem as OrderItemBase,
99
StoreInventory as StoreInventoryBase,
1010
Order as OrderBase,
11+
MappedOrderItem as MappedOrderItemBase,
1112
)
1213
from rhosocial.activerecord.testsuite.feature.composite_pk.fixtures.models import (
1314
AsyncOrderItem as AsyncOrderItemBase,
1415
AsyncStoreInventory as AsyncStoreInventoryBase,
1516
AsyncOrder as AsyncOrderBase,
17+
AsyncMappedOrderItem as AsyncMappedOrderItemBase,
1618
)
1719

1820
from .scenarios import get_enabled_scenarios, get_scenario
@@ -87,6 +89,12 @@ async def setup_async_store_inventory_model(self, scenario_name: str) -> Type[Ac
8789
async def setup_async_order_model(self, scenario_name: str) -> Type[ActiveRecord]:
8890
return await self._setup_async_model(AsyncOrderBase, scenario_name, "orders")
8991

92+
def setup_mapped_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]:
93+
return self._setup_model(MappedOrderItemBase, scenario_name, "order_items")
94+
95+
async def setup_async_mapped_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]:
96+
return await self._setup_async_model(AsyncMappedOrderItemBase, scenario_name, "order_items")
97+
9098
def cleanup_after_test(self, scenario_name: str):
9199
tables = ["order_items", "store_inventory", "orders"]
92100
for backend in self._active_backends:

0 commit comments

Comments
 (0)