@@ -25,14 +25,19 @@ use crate::spec::{
2525 CoreOptions , DataField , FileKind , GlobalIndexSearchMode , IndexFileMeta , IndexManifest ,
2626 IndexManifestEntry , ROW_ID_FIELD_NAME ,
2727} ;
28+ use crate :: table:: data_file_reader:: DataFileReader ;
2829use crate :: table:: full_text_index_adapter:: { search_full_text_file, search_full_text_index} ;
2930use crate :: table:: global_index_scanner:: {
3031 deleted_row_ranges_for_data_evolution_dvs, search_limit_with_deleted_rows,
3132 unindexed_ranges_for_global_index_entries, RowRangeIndex ,
3233} ;
33- use crate :: table:: { find_field_id_by_name, merge_row_ranges, RowRange , Table } ;
34+ use crate :: table:: pk_full_text_read:: PrimaryKeyFullTextRead ;
35+ use crate :: table:: pk_full_text_scan:: PrimaryKeyFullTextScan ;
36+ use crate :: table:: {
37+ find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream , RowRange , Table ,
38+ } ;
3439use arrow_array:: { Array , Int64Array , LargeStringArray , RecordBatch , StringArray } ;
35- use futures:: { StreamExt , TryStreamExt } ;
40+ use futures:: { stream , StreamExt , TryStreamExt } ;
3641use paimon_ftindex_core:: io:: PosWriter ;
3742use paimon_ftindex_core:: { FullTextIndexConfig , FullTextIndexWriter } ;
3843use roaring:: RoaringTreemap ;
@@ -111,7 +116,8 @@ impl<'a> FullTextSearchBuilder<'a> {
111116
112117 pub async fn execute_scored ( & self ) -> crate :: Result < SearchResult > {
113118 // Fail closed: returns data-derived row ranges outside `TableScan`/`TableRead`.
114- CoreOptions :: new ( self . table . schema ( ) . options ( ) ) . ensure_read_authorized ( ) ?;
119+ let core = CoreOptions :: new ( self . table . schema ( ) . options ( ) ) ;
120+ core. ensure_read_authorized ( ) ?;
115121 let text_column =
116122 self . text_column
117123 . as_deref ( )
@@ -128,6 +134,21 @@ impl<'a> FullTextSearchBuilder<'a> {
128134 message : "Limit must be set via with_limit()" . to_string ( ) ,
129135 } ) ?;
130136
137+ // Primary-key full-text search does not produce global row-ids: it maps
138+ // hits to physical `(data file, row position)` pairs. A scored/row-range
139+ // search is therefore unsupported on the PK path — callers must use
140+ // `execute_read`. Fail loud rather than fall through to the append/DE
141+ // global-index path (which would search the wrong index and could return
142+ // an empty or wrong result). Mirrors the vector builder.
143+ if resolves_to_pk_full_text_path ( & core, text_column) {
144+ return Err ( crate :: Error :: DataInvalid {
145+ message : "primary-key full-text search does not produce global row ids; use the \
146+ materialized read (execute_read) instead"
147+ . to_string ( ) ,
148+ source : None ,
149+ } ) ;
150+ }
151+
131152 let mut search = FullTextSearch :: new (
132153 normalize_query_text ( query_text, text_column) ?,
133154 limit,
@@ -166,6 +187,113 @@ impl<'a> FullTextSearchBuilder<'a> {
166187 )
167188 . await
168189 }
190+
191+ /// Run the full-text search and materialize the matching rows as Arrow batches,
192+ /// ordered best-score-first with a `__paimon_search_score` column appended (the
193+ /// internal `_PKEY_VECTOR_POSITION` column is never exposed).
194+ ///
195+ /// Only the primary-key full-text path can materialize rows: it produces
196+ /// physical `(data file, row position)` hits that a subsequent read turns into
197+ /// table rows. Dispatch mirrors Java `primaryKeyFullTextDefinition` — the PK
198+ /// path is taken only when data-evolution is DISABLED and the queried column is
199+ /// a configured `pk-full-text.index.columns` entry. The PK full-text read is
200+ /// FAST-mode only; `FULL`/`DETAIL` fail loud (no silent degrade). A query that
201+ /// does not resolve to the PK path also fails loud rather than silently
202+ /// returning nothing, since the append/data-evolution materialized read is not
203+ /// supported here.
204+ pub async fn execute_read ( & self ) -> crate :: Result < ArrowRecordBatchStream > {
205+ // Fail closed: returns data outside `TableScan`/`TableRead`.
206+ let core = CoreOptions :: new ( self . table . schema ( ) . options ( ) ) ;
207+ core. ensure_read_authorized ( ) ?;
208+ let text_column =
209+ self . text_column
210+ . as_deref ( )
211+ . ok_or_else ( || crate :: Error :: ConfigInvalid {
212+ message : "Text column must be set via with_text_column()" . to_string ( ) ,
213+ } ) ?;
214+ let query_text = self
215+ . query_text
216+ . as_deref ( )
217+ . ok_or_else ( || crate :: Error :: ConfigInvalid {
218+ message : "Query text must be set via with_query_text()" . to_string ( ) ,
219+ } ) ?;
220+ let limit = self . limit . ok_or_else ( || crate :: Error :: ConfigInvalid {
221+ message : "Limit must be set via with_limit()" . to_string ( ) ,
222+ } ) ?;
223+
224+ if !resolves_to_pk_full_text_path ( & core, text_column) {
225+ return Err ( crate :: Error :: Unsupported {
226+ message : "materialized full-text read (execute_read) is only supported on the \
227+ primary-key full-text path (data-evolution disabled and the column \
228+ configured in pk-full-text.index.columns)"
229+ . to_string ( ) ,
230+ } ) ;
231+ }
232+
233+ // FAST-only: reject FULL/DETAIL loud rather than silently degrading.
234+ if core. global_index_search_mode ( ) ? != GlobalIndexSearchMode :: Fast {
235+ return Err ( crate :: Error :: DataInvalid {
236+ message : "primary-key full-text search supports only the FAST global-index search \
237+ mode"
238+ . to_string ( ) ,
239+ source : None ,
240+ } ) ;
241+ }
242+
243+ // Reject a non-positive limit at construction, before the empty-plan fast
244+ // path — an empty table must not mask an invalid limit (mirrors Java
245+ // `PrimaryKeyFullTextRead`, and matches `search_bucket`'s own guard).
246+ if limit == 0 {
247+ return Err ( crate :: Error :: ConfigInvalid {
248+ message : "Limit must be positive" . to_string ( ) ,
249+ } ) ;
250+ }
251+
252+ // Resolve the queried column's schema field id for the scan/field-id guard.
253+ let field_id = find_field_id_by_name ( self . table . schema ( ) . fields ( ) , text_column)
254+ . ok_or_else ( || crate :: Error :: DataInvalid {
255+ message : format ! ( "full-text search column '{text_column}' does not exist" ) ,
256+ source : None ,
257+ } ) ?;
258+
259+ let plan = PrimaryKeyFullTextScan :: new ( self . table , field_id, None )
260+ . plan ( )
261+ . await ?;
262+ if plan. splits . is_empty ( ) {
263+ return Ok ( Box :: pin ( stream:: empty ( ) ) ) ;
264+ }
265+
266+ // A predicate-free materialization reader projecting the user table
267+ // columns (mirrors `table_read.rs::new_data_file_reader` with an empty
268+ // predicate list). The PK read appends the score column itself.
269+ let materialize_reader = DataFileReader :: new (
270+ self . table . file_io ( ) . clone ( ) ,
271+ self . table . schema_manager ( ) . clone ( ) ,
272+ self . table . schema ( ) . id ( ) ,
273+ self . table . schema ( ) . fields ( ) . to_vec ( ) ,
274+ self . table . schema ( ) . fields ( ) . to_vec ( ) ,
275+ Vec :: new ( ) ,
276+ ) ;
277+ let read = PrimaryKeyFullTextRead :: new (
278+ self . table . file_io ( ) . clone ( ) ,
279+ materialize_reader,
280+ self . table . location ( ) . trim_end_matches ( '/' ) . to_string ( ) ,
281+ ) ;
282+ read. read ( & plan, query_text, limit) . await
283+ }
284+ }
285+
286+ /// Whether a query on `text_column` resolves to the primary-key full-text read
287+ /// path. Mirrors Java `primaryKeyFullTextDefinition`: taken only when
288+ /// data-evolution is DISABLED and the column is a configured
289+ /// `pk-full-text.index.columns` entry (membership via the non-erroring accessor so
290+ /// a malformed config cannot abort an unrelated append/DE query).
291+ fn resolves_to_pk_full_text_path ( core : & CoreOptions < ' _ > , text_column : & str ) -> bool {
292+ !core. data_evolution_enabled ( )
293+ && core
294+ . primary_key_full_text_index_columns ( )
295+ . iter ( )
296+ . any ( |c| c == text_column)
169297}
170298
171299/// Evaluate a full-text search query against full-text indexes found in the index manifest.
@@ -1203,4 +1331,176 @@ mod tests {
12031331 "full-text search must fail closed for a query-auth table"
12041332 ) ;
12051333 }
1334+
1335+ /// A primary-key full-text table: data-evolution off, `body` configured as a
1336+ /// `pk-full-text.index.columns` entry, so a `body` query resolves to the PK
1337+ /// full-text path. `extra` appends/overrides options (e.g. the search mode).
1338+ fn pk_full_text_table ( name : & str , extra : & [ ( & str , & str ) ] ) -> Table {
1339+ let mut builder = Schema :: builder ( )
1340+ . column ( "id" , DataType :: Int ( IntType :: new ( ) ) )
1341+ . column ( "body" , DataType :: VarChar ( VarCharType :: string_type ( ) ) )
1342+ . primary_key ( [ "id" ] )
1343+ . option ( "bucket" , "1" )
1344+ . option ( "pk-full-text.index.columns" , "body" ) ;
1345+ for ( k, v) in extra {
1346+ builder = builder. option ( * k, * v) ;
1347+ }
1348+ let schema = builder. build ( ) . unwrap ( ) ;
1349+ Table :: new (
1350+ FileIOBuilder :: new ( "memory" ) . build ( ) . unwrap ( ) ,
1351+ Identifier :: new ( "default" , name) ,
1352+ format ! ( "memory:/{name}" ) ,
1353+ TableSchema :: new ( 0 , & schema) ,
1354+ None ,
1355+ )
1356+ }
1357+
1358+ // (d) The PK full-text path produces physical positions, not global row-ids:
1359+ // `execute` / `execute_scored` must fail loud and point callers at execute_read.
1360+ #[ tokio:: test]
1361+ async fn pk_full_text_execute_fails_loud_use_execute_read ( ) {
1362+ let table = pk_full_text_table ( "pk_ft_execute_loud" , & [ ] ) ;
1363+ let err = table
1364+ . new_full_text_search_builder ( )
1365+ . with_text_column ( "body" )
1366+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1367+ . with_limit ( 10 )
1368+ . execute ( )
1369+ . await
1370+ . unwrap_err ( ) ;
1371+ assert ! (
1372+ matches!( & err, crate :: Error :: DataInvalid { message, .. } if message. contains( "execute_read" ) ) ,
1373+ "PK full-text execute must fail loud pointing at execute_read, got: {err}"
1374+ ) ;
1375+ }
1376+
1377+ #[ tokio:: test]
1378+ async fn pk_full_text_execute_scored_fails_loud_use_execute_read ( ) {
1379+ let table = pk_full_text_table ( "pk_ft_scored_loud" , & [ ] ) ;
1380+ let err = table
1381+ . new_full_text_search_builder ( )
1382+ . with_text_column ( "body" )
1383+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1384+ . with_limit ( 10 )
1385+ . execute_scored ( )
1386+ . await
1387+ . unwrap_err ( ) ;
1388+ assert ! (
1389+ matches!( & err, crate :: Error :: DataInvalid { message, .. } if message. contains( "global row ids" ) ) ,
1390+ "PK full-text execute_scored must fail loud, got: {err}"
1391+ ) ;
1392+ }
1393+
1394+ // (c) FAST-only: FULL / DETAIL global-index search modes must fail loud on the
1395+ // PK full-text read rather than silently degrade.
1396+ #[ tokio:: test]
1397+ async fn pk_full_text_execute_read_rejects_full_mode ( ) {
1398+ let table = pk_full_text_table ( "pk_ft_full_mode" , & [ ( "global-index.search-mode" , "full" ) ] ) ;
1399+ let err = match table
1400+ . new_full_text_search_builder ( )
1401+ . with_text_column ( "body" )
1402+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1403+ . with_limit ( 10 )
1404+ . execute_read ( )
1405+ . await
1406+ {
1407+ Ok ( _) => panic ! ( "FULL mode PK full-text read must fail loud" ) ,
1408+ Err ( e) => e,
1409+ } ;
1410+ assert ! (
1411+ matches!( & err, crate :: Error :: DataInvalid { message, .. } if message. contains( "FAST" ) ) ,
1412+ "FULL mode PK full-text read must fail loud, got: {err}"
1413+ ) ;
1414+ }
1415+
1416+ #[ tokio:: test]
1417+ async fn pk_full_text_execute_read_rejects_detail_mode ( ) {
1418+ let table = pk_full_text_table (
1419+ "pk_ft_detail_mode" ,
1420+ & [ ( "global-index.search-mode" , "detail" ) ] ,
1421+ ) ;
1422+ let err = match table
1423+ . new_full_text_search_builder ( )
1424+ . with_text_column ( "body" )
1425+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1426+ . with_limit ( 10 )
1427+ . execute_read ( )
1428+ . await
1429+ {
1430+ Ok ( _) => panic ! ( "DETAIL mode PK full-text read must fail loud" ) ,
1431+ Err ( e) => e,
1432+ } ;
1433+ assert ! (
1434+ matches!( & err, crate :: Error :: DataInvalid { message, .. } if message. contains( "FAST" ) ) ,
1435+ "DETAIL mode PK full-text read must fail loud, got: {err}"
1436+ ) ;
1437+ }
1438+
1439+ // (e) A non-PK (append) table: execute_read is unsupported and must fail loud
1440+ // rather than silently return nothing; the append execute/execute_scored path
1441+ // stays unaffected (exercised by the raw-search tests above).
1442+ #[ tokio:: test]
1443+ async fn append_execute_read_fails_loud_unsupported ( ) {
1444+ let file_io = FileIOBuilder :: new ( "memory" ) . build ( ) . unwrap ( ) ;
1445+ let table = full_text_raw_table ( & file_io, "memory:/append_ft_execute_read" ) ;
1446+ let err = match table
1447+ . new_full_text_search_builder ( )
1448+ . with_text_column ( "body" )
1449+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1450+ . with_limit ( 10 )
1451+ . execute_read ( )
1452+ . await
1453+ {
1454+ Ok ( _) => panic ! ( "append full-text execute_read must fail loud" ) ,
1455+ Err ( e) => e,
1456+ } ;
1457+ assert ! (
1458+ matches!( & err, crate :: Error :: Unsupported { message } if message. contains( "primary-key full-text path" ) ) ,
1459+ "append full-text execute_read must fail loud as unsupported, got: {err}"
1460+ ) ;
1461+ }
1462+
1463+ // Dispatch: with data-evolution ENABLED, a configured pk-full-text column must
1464+ // NOT resolve to the PK path (mirrors Java `primaryKeyFullTextDefinition`), so
1465+ // execute_scored takes the append/DE path instead of the PK fail-loud guard.
1466+ #[ tokio:: test]
1467+ async fn data_evolution_enabled_does_not_take_pk_path ( ) {
1468+ let de_on = HashMap :: from ( [
1469+ ( "pk-full-text.index.columns" . to_string ( ) , "body" . to_string ( ) ) ,
1470+ ( "data-evolution.enabled" . to_string ( ) , "true" . to_string ( ) ) ,
1471+ ] ) ;
1472+ let core = CoreOptions :: new ( & de_on) ;
1473+ assert ! (
1474+ !resolves_to_pk_full_text_path( & core, "body" ) ,
1475+ "data-evolution on must not resolve to the PK full-text path"
1476+ ) ;
1477+ // Off + configured column -> PK path; a non-configured column -> not PK.
1478+ let de_off =
1479+ HashMap :: from ( [ ( "pk-full-text.index.columns" . to_string ( ) , "body" . to_string ( ) ) ] ) ;
1480+ let core_off = CoreOptions :: new ( & de_off) ;
1481+ assert ! ( resolves_to_pk_full_text_path( & core_off, "body" ) ) ;
1482+ assert ! ( !resolves_to_pk_full_text_path( & core_off, "other" ) ) ;
1483+ }
1484+
1485+ // A non-positive limit must fail loud at construction, even on an empty table
1486+ // (before the empty-plan fast path).
1487+ #[ tokio:: test]
1488+ async fn pk_full_text_execute_read_rejects_zero_limit ( ) {
1489+ let table = pk_full_text_table ( "pk_ft_zero_limit" , & [ ] ) ;
1490+ let err = match table
1491+ . new_full_text_search_builder ( )
1492+ . with_text_column ( "body" )
1493+ . with_query_text ( r#"{"match":{"query":"alpha"}}"# )
1494+ . with_limit ( 0 )
1495+ . execute_read ( )
1496+ . await
1497+ {
1498+ Ok ( _) => panic ! ( "zero limit PK full-text read must fail loud" ) ,
1499+ Err ( e) => e,
1500+ } ;
1501+ assert ! (
1502+ matches!( & err, crate :: Error :: ConfigInvalid { message } if message. contains( "positive" ) ) ,
1503+ "zero limit must fail loud, got: {err}"
1504+ ) ;
1505+ }
12061506}
0 commit comments