@@ -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.
0 commit comments