@@ -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,60 @@ 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+ if retrieval_mode != SearchRetrievalMode .FTS :
1016+ return await super ().count (
1017+ search_text = search_text ,
1018+ permalink = permalink ,
1019+ permalink_match = permalink_match ,
1020+ title = title ,
1021+ note_types = note_types ,
1022+ after_date = after_date ,
1023+ search_item_types = search_item_types ,
1024+ metadata_filters = metadata_filters ,
1025+ retrieval_mode = retrieval_mode ,
1026+ min_similarity = min_similarity ,
1027+ )
1028+
1029+ (
1030+ from_clause ,
1031+ where_clause ,
1032+ params ,
1033+ _order_by_clause ,
1034+ _score_expr ,
1035+ ) = await self ._build_fts_query_parts (
1036+ search_text = search_text ,
1037+ permalink = permalink ,
1038+ permalink_match = permalink_match ,
1039+ title = title ,
1040+ note_types = note_types ,
1041+ after_date = after_date ,
1042+ search_item_types = search_item_types ,
1043+ metadata_filters = metadata_filters ,
1044+ )
1045+ sql = f"SELECT COUNT(*) FROM { from_clause } WHERE { where_clause } "
1046+ logger .trace (f"Count { sql } params: { params } " )
1047+ try :
1048+ async with db .scoped_session (self .session_maker ) as session :
1049+ result = await session .execute (text (sql ), params )
1050+ return int (result .scalar_one ())
1051+ except Exception as e :
1052+ if self ._is_tsquery_syntax_error (e ):
1053+ logger .warning (f"tsquery syntax error for search term: { search_text } , error: { e } " )
1054+ return 0
1055+ logger .error (f"Database error during search count: { e } " )
1056+ raise
0 commit comments