@@ -686,7 +686,17 @@ async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> Non
686686 # FTS search (Postgres-specific)
687687 # ------------------------------------------------------------------
688688
689- async def search (
689+ @staticmethod
690+ def _is_tsquery_syntax_error (exc : Exception ) -> bool :
691+ msg = str (exc ).lower ()
692+ return (
693+ "syntax error in tsquery" in msg
694+ or "invalid input syntax for type tsquery" in msg
695+ or "no operand in tsquery" in msg
696+ or "no operator in tsquery" in msg
697+ )
698+
699+ async def _build_fts_query_parts (
690700 self ,
691701 search_text : Optional [str ] = None ,
692702 permalink : Optional [str ] = None ,
@@ -696,31 +706,8 @@ async def search(
696706 after_date : Optional [datetime ] = None ,
697707 search_item_types : Optional [List [SearchItemType ]] = None ,
698708 metadata_filters : Optional [dict ] = None ,
699- retrieval_mode : SearchRetrievalMode = SearchRetrievalMode .FTS ,
700- min_similarity : Optional [float ] = None ,
701- limit : int = 10 ,
702- offset : int = 0 ,
703- ) -> List [SearchIndexRow ]:
704- """Search across all indexed content using PostgreSQL tsvector."""
705- # --- Dispatch vector / hybrid modes (shared logic) ---
706- dispatched = await self ._dispatch_retrieval_mode (
707- search_text = search_text ,
708- permalink = permalink ,
709- permalink_match = permalink_match ,
710- title = title ,
711- note_types = note_types ,
712- after_date = after_date ,
713- search_item_types = search_item_types ,
714- metadata_filters = metadata_filters ,
715- retrieval_mode = retrieval_mode ,
716- min_similarity = min_similarity ,
717- limit = limit ,
718- offset = offset ,
719- )
720- if dispatched is not None :
721- return dispatched
722-
723- # --- FTS mode (Postgres-specific) ---
709+ ) -> tuple [str , str , dict , str , str ]:
710+ """Build Postgres FTS FROM/WHERE params shared by search and count."""
724711 conditions = []
725712 params = {}
726713 order_by_clause = ""
@@ -868,10 +855,6 @@ async def search(
868855 params ["project_id" ] = self .project_id
869856 conditions .append ("search_index.project_id = :project_id" )
870857
871- # set limit and offset
872- params ["limit" ] = limit
873- params ["offset" ] = offset
874-
875858 # Build WHERE clause
876859 where_clause = " AND " .join (conditions ) if conditions else "1=1"
877860
@@ -884,6 +867,64 @@ async def search(
884867 else :
885868 score_expr = "0"
886869
870+ return from_clause , where_clause , params , order_by_clause , score_expr
871+
872+ async def search (
873+ self ,
874+ search_text : Optional [str ] = None ,
875+ permalink : Optional [str ] = None ,
876+ permalink_match : Optional [str ] = None ,
877+ title : Optional [str ] = None ,
878+ note_types : Optional [List [str ]] = None ,
879+ after_date : Optional [datetime ] = None ,
880+ search_item_types : Optional [List [SearchItemType ]] = None ,
881+ metadata_filters : Optional [dict ] = None ,
882+ retrieval_mode : SearchRetrievalMode = SearchRetrievalMode .FTS ,
883+ min_similarity : Optional [float ] = None ,
884+ limit : int = 10 ,
885+ offset : int = 0 ,
886+ ) -> List [SearchIndexRow ]:
887+ """Search across all indexed content using PostgreSQL tsvector."""
888+ # --- Dispatch vector / hybrid modes (shared logic) ---
889+ dispatched = await self ._dispatch_retrieval_mode (
890+ search_text = search_text ,
891+ permalink = permalink ,
892+ permalink_match = permalink_match ,
893+ title = title ,
894+ note_types = note_types ,
895+ after_date = after_date ,
896+ search_item_types = search_item_types ,
897+ metadata_filters = metadata_filters ,
898+ retrieval_mode = retrieval_mode ,
899+ min_similarity = min_similarity ,
900+ limit = limit ,
901+ offset = offset ,
902+ )
903+ if dispatched is not None :
904+ return dispatched
905+
906+ # --- FTS mode (Postgres-specific) ---
907+ (
908+ from_clause ,
909+ where_clause ,
910+ params ,
911+ order_by_clause ,
912+ score_expr ,
913+ ) = await self ._build_fts_query_parts (
914+ search_text = search_text ,
915+ permalink = permalink ,
916+ permalink_match = permalink_match ,
917+ title = title ,
918+ note_types = note_types ,
919+ after_date = after_date ,
920+ search_item_types = search_item_types ,
921+ metadata_filters = metadata_filters ,
922+ )
923+
924+ # set limit and offset
925+ params ["limit" ] = limit
926+ params ["offset" ] = offset
927+
887928 sql = f"""
888929 SELECT
889930 search_index.project_id,
@@ -915,17 +956,7 @@ async def search(
915956 result = await session .execute (text (sql ), params )
916957 rows = result .fetchall ()
917958 except Exception as e :
918- # Handle tsquery syntax errors (and only those).
919- #
920- # Important: Postgres errors for other failures (e.g. missing table) will still mention
921- # `to_tsquery(...)` in the SQL text, so checking for the substring "tsquery" is too broad.
922- msg = str (e ).lower ()
923- if (
924- "syntax error in tsquery" in msg
925- or "invalid input syntax for type tsquery" in msg
926- or "no operand in tsquery" in msg
927- or "no operator in tsquery" in msg
928- ):
959+ if self ._is_tsquery_syntax_error (e ):
929960 logger .warning (f"tsquery syntax error for search term: { search_text } , error: { e } " )
930961 return []
931962
@@ -966,3 +997,65 @@ async def search(
966997 )
967998
968999 return results
1000+
1001+ async def count (
1002+ self ,
1003+ search_text : Optional [str ] = None ,
1004+ permalink : Optional [str ] = None ,
1005+ permalink_match : Optional [str ] = None ,
1006+ title : Optional [str ] = None ,
1007+ note_types : Optional [List [str ]] = None ,
1008+ after_date : Optional [datetime ] = None ,
1009+ search_item_types : Optional [List [SearchItemType ]] = None ,
1010+ metadata_filters : Optional [dict ] = None ,
1011+ retrieval_mode : SearchRetrievalMode = SearchRetrievalMode .FTS ,
1012+ min_similarity : Optional [float ] = None ,
1013+ ) -> int :
1014+ """Count indexed content matching the Postgres FTS query."""
1015+ mode = (
1016+ retrieval_mode .value
1017+ if isinstance (retrieval_mode , SearchRetrievalMode )
1018+ else str (retrieval_mode )
1019+ )
1020+ if mode != SearchRetrievalMode .FTS .value :
1021+ return await super ().count (
1022+ search_text = search_text ,
1023+ permalink = permalink ,
1024+ permalink_match = permalink_match ,
1025+ title = title ,
1026+ note_types = note_types ,
1027+ after_date = after_date ,
1028+ search_item_types = search_item_types ,
1029+ metadata_filters = metadata_filters ,
1030+ retrieval_mode = retrieval_mode ,
1031+ min_similarity = min_similarity ,
1032+ )
1033+
1034+ (
1035+ from_clause ,
1036+ where_clause ,
1037+ params ,
1038+ _order_by_clause ,
1039+ _score_expr ,
1040+ ) = await self ._build_fts_query_parts (
1041+ search_text = search_text ,
1042+ permalink = permalink ,
1043+ permalink_match = permalink_match ,
1044+ title = title ,
1045+ note_types = note_types ,
1046+ after_date = after_date ,
1047+ search_item_types = search_item_types ,
1048+ metadata_filters = metadata_filters ,
1049+ )
1050+ sql = f"SELECT COUNT(*) FROM { from_clause } WHERE { where_clause } "
1051+ logger .trace (f"Count { sql } params: { params } " )
1052+ try :
1053+ async with db .scoped_session (self .session_maker ) as session :
1054+ result = await session .execute (text (sql ), params )
1055+ return int (result .scalar_one ())
1056+ except Exception as e :
1057+ if self ._is_tsquery_syntax_error (e ):
1058+ logger .warning (f"tsquery syntax error for search term: { search_text } , error: { e } " )
1059+ return 0
1060+ logger .error (f"Database error during search count: { e } " )
1061+ raise
0 commit comments