Skip to content

Commit 8bb2685

Browse files
committed
Parameterize fulltext SQL runtime values
Stop inlining runtime values into generated full-text SQL: language, tsquery, weights, limit and offset are now passed as bound parameters/placeholders. Postgres tsvector/tsquery calls use CAST(<param> AS regconfig) and weights are provided via a pg_weights_placeholder; ts_rank_cd uses CAST(<weights> AS real[]). Introduced append_limit_offset to consistently parameterize pagination for Postgres/MySQL/SQLite and pg_weights_placeholder helper. Updated build methods (Postgres/MySQL/SQLite) to use the new placeholders and removed direct string interpolation. Added unit tests and a test model to verify parameterization of language, query, weights, and pagination.
1 parent 61f70d1 commit 8bb2685

2 files changed

Lines changed: 226 additions & 76 deletions

File tree

src/fulltext.rs

Lines changed: 120 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -528,61 +528,69 @@ impl<T: Model> FullTextSearchBuilder<T> {
528528

529529
fn build_postgres_sql(&self) -> Result<(String, Vec<Value>)> {
530530
let table = quote_ident(DatabaseType::Postgres, T::table_name());
531-
let language = self.config.language.as_deref().unwrap_or("english");
532531
let mut params = Vec::new();
532+
let language_placeholder = self.push_param(
533+
DatabaseType::Postgres,
534+
&mut params,
535+
Value::String(Some(
536+
self.config
537+
.language
538+
.clone()
539+
.unwrap_or_else(|| "english".to_string()),
540+
)),
541+
);
533542

534543
// Build tsvector expression for columns
535-
let tsvector_expr = self.build_pg_tsvector_expr(language);
544+
let tsvector_expr = self.build_pg_tsvector_expr(&language_placeholder);
536545

537546
// Build tsquery based on search mode
538-
let tsquery_expr = self.build_pg_tsquery_expr(language, &mut params);
547+
let tsquery_expr = self.build_pg_tsquery_expr(&language_placeholder, &mut params);
539548

540549
let mut sql = format!(
541550
"SELECT * FROM {} WHERE {} @@ {}",
542551
table, tsvector_expr, tsquery_expr
543552
);
544553

545554
if self.with_ranking {
546-
let weights = self
547-
.config
548-
.weights
549-
.as_ref()
550-
.map(|w| w.to_pg_array())
551-
.unwrap_or_else(|| "'{0.1,0.2,0.4,1.0}'".to_string());
555+
let weights_placeholder = self.pg_weights_placeholder(&mut params);
552556
sql = format!(
553-
"SELECT *, ts_rank_cd({}, {}, {}) AS _fts_rank FROM {} WHERE {} @@ {} ORDER BY _fts_rank DESC",
554-
weights, tsvector_expr, tsquery_expr, table, tsvector_expr, tsquery_expr
557+
"SELECT *, ts_rank_cd(CAST({} AS real[]), {}, {}) AS _fts_rank FROM {} WHERE {} @@ {} ORDER BY _fts_rank DESC",
558+
weights_placeholder,
559+
tsvector_expr,
560+
tsquery_expr,
561+
table,
562+
tsvector_expr,
563+
tsquery_expr
555564
);
556565
}
557566

558-
if let Some(limit) = self.limit {
559-
sql.push_str(&format!(" LIMIT {}", limit));
560-
}
561-
if let Some(offset) = self.offset {
562-
sql.push_str(&format!(" OFFSET {}", offset));
563-
}
567+
self.append_limit_offset(DatabaseType::Postgres, &mut sql, &mut params)?;
564568

565569
Ok((sql, params))
566570
}
567571

568572
fn build_postgres_ranked_sql(&self) -> Result<(String, Vec<Value>)> {
569573
let table = quote_ident(DatabaseType::Postgres, T::table_name());
570-
let language = self.config.language.as_deref().unwrap_or("english");
571574
let mut params = Vec::new();
575+
let language_placeholder = self.push_param(
576+
DatabaseType::Postgres,
577+
&mut params,
578+
Value::String(Some(
579+
self.config
580+
.language
581+
.clone()
582+
.unwrap_or_else(|| "english".to_string()),
583+
)),
584+
);
572585

573-
let tsvector_expr = self.build_pg_tsvector_expr(language);
574-
let tsquery_expr = self.build_pg_tsquery_expr(language, &mut params);
586+
let tsvector_expr = self.build_pg_tsvector_expr(&language_placeholder);
587+
let tsquery_expr = self.build_pg_tsquery_expr(&language_placeholder, &mut params);
575588

576-
let weights = self
577-
.config
578-
.weights
579-
.as_ref()
580-
.map(|w| w.to_pg_array())
581-
.unwrap_or_else(|| "'{0.1,0.2,0.4,1.0}'".to_string());
589+
let weights_placeholder = self.pg_weights_placeholder(&mut params);
582590

583591
let mut sql = format!(
584-
"SELECT *, ts_rank_cd({}, {}, {}) AS _fts_rank FROM {} WHERE {} @@ {}",
585-
weights, tsvector_expr, tsquery_expr, table, tsvector_expr, tsquery_expr
592+
"SELECT *, ts_rank_cd(CAST({} AS real[]), {}, {}) AS _fts_rank FROM {} WHERE {} @@ {}",
593+
weights_placeholder, tsvector_expr, tsquery_expr, table, tsvector_expr, tsquery_expr
586594
);
587595

588596
if let Some(min_rank) = self.min_rank {
@@ -592,30 +600,34 @@ impl<T: Model> FullTextSearchBuilder<T> {
592600
Value::Double(Some(min_rank)),
593601
);
594602
sql.push_str(&format!(
595-
" AND ts_rank_cd({}, {}, {}) >= {}",
596-
weights, tsvector_expr, tsquery_expr, min_rank_placeholder
603+
" AND ts_rank_cd(CAST({} AS real[]), {}, {}) >= {}",
604+
weights_placeholder, tsvector_expr, tsquery_expr, min_rank_placeholder
597605
));
598606
}
599607

600608
sql.push_str(" ORDER BY _fts_rank DESC");
601609

602-
if let Some(limit) = self.limit {
603-
sql.push_str(&format!(" LIMIT {}", limit));
604-
}
605-
if let Some(offset) = self.offset {
606-
sql.push_str(&format!(" OFFSET {}", offset));
607-
}
610+
self.append_limit_offset(DatabaseType::Postgres, &mut sql, &mut params)?;
608611

609612
Ok((sql, params))
610613
}
611614

612615
fn build_postgres_count_sql(&self) -> Result<(String, Vec<Value>)> {
613616
let table = quote_ident(DatabaseType::Postgres, T::table_name());
614-
let language = self.config.language.as_deref().unwrap_or("english");
615617
let mut params = Vec::new();
618+
let language_placeholder = self.push_param(
619+
DatabaseType::Postgres,
620+
&mut params,
621+
Value::String(Some(
622+
self.config
623+
.language
624+
.clone()
625+
.unwrap_or_else(|| "english".to_string()),
626+
)),
627+
);
616628

617-
let tsvector_expr = self.build_pg_tsvector_expr(language);
618-
let tsquery_expr = self.build_pg_tsquery_expr(language, &mut params);
629+
let tsvector_expr = self.build_pg_tsvector_expr(&language_placeholder);
630+
let tsquery_expr = self.build_pg_tsquery_expr(&language_placeholder, &mut params);
619631

620632
Ok((
621633
format!(
@@ -626,12 +638,11 @@ impl<T: Model> FullTextSearchBuilder<T> {
626638
))
627639
}
628640

629-
fn build_pg_tsvector_expr(&self, language: &str) -> String {
630-
let language = escape_string(language);
641+
fn build_pg_tsvector_expr(&self, language_placeholder: &str) -> String {
631642
if self.columns.len() == 1 {
632643
format!(
633-
"to_tsvector('{}', COALESCE({}, ''))",
634-
language,
644+
"to_tsvector(CAST({} AS regconfig), COALESCE({}, ''))",
645+
language_placeholder,
635646
quote_ident(DatabaseType::Postgres, &self.columns[0])
636647
)
637648
} else {
@@ -640,36 +651,48 @@ impl<T: Model> FullTextSearchBuilder<T> {
640651
.iter()
641652
.map(|c| format!("COALESCE({}, '')", quote_ident(DatabaseType::Postgres, c)))
642653
.collect();
643-
format!("to_tsvector('{}', {})", language, cols.join(" || ' ' || "))
654+
format!(
655+
"to_tsvector(CAST({} AS regconfig), {})",
656+
language_placeholder,
657+
cols.join(" || ' ' || ")
658+
)
644659
}
645660
}
646661

647-
fn build_pg_tsquery_expr(&self, language: &str, params: &mut Vec<Value>) -> String {
648-
let language = escape_string(language);
662+
fn build_pg_tsquery_expr(&self, language_placeholder: &str, params: &mut Vec<Value>) -> String {
649663
match self.config.mode {
650664
SearchMode::Natural => {
651665
let placeholder = self.push_param(
652666
DatabaseType::Postgres,
653667
params,
654668
Value::String(Some(self.query.clone())),
655669
);
656-
format!("plainto_tsquery('{}', {})", language, placeholder)
670+
format!(
671+
"plainto_tsquery(CAST({} AS regconfig), {})",
672+
language_placeholder, placeholder
673+
)
657674
}
658675
SearchMode::Boolean => {
659676
let placeholder = self.push_param(
660677
DatabaseType::Postgres,
661678
params,
662679
Value::String(Some(self.query.clone())),
663680
);
664-
format!("to_tsquery('{}', {})", language, placeholder)
681+
format!(
682+
"to_tsquery(CAST({} AS regconfig), {})",
683+
language_placeholder, placeholder
684+
)
665685
}
666686
SearchMode::Phrase => {
667687
let placeholder = self.push_param(
668688
DatabaseType::Postgres,
669689
params,
670690
Value::String(Some(self.query.clone())),
671691
);
672-
format!("phraseto_tsquery('{}', {})", language, placeholder)
692+
format!(
693+
"phraseto_tsquery(CAST({} AS regconfig), {})",
694+
language_placeholder, placeholder
695+
)
673696
}
674697
SearchMode::Prefix => {
675698
let words: Vec<&str> = self.query.split_whitespace().collect();
@@ -679,15 +702,21 @@ impl<T: Model> FullTextSearchBuilder<T> {
679702
params,
680703
Value::String(Some(prefixed.join(" & "))),
681704
);
682-
format!("to_tsquery('{}', {})", language, placeholder)
705+
format!(
706+
"to_tsquery(CAST({} AS regconfig), {})",
707+
language_placeholder, placeholder
708+
)
683709
}
684710
SearchMode::Fuzzy => {
685711
let placeholder = self.push_param(
686712
DatabaseType::Postgres,
687713
params,
688714
Value::String(Some(self.query.clone())),
689715
);
690-
format!("plainto_tsquery('{}', {})", language, placeholder)
716+
format!(
717+
"plainto_tsquery(CAST({} AS regconfig), {})",
718+
language_placeholder, placeholder
719+
)
691720
}
692721
SearchMode::Proximity(distance) => {
693722
let words: Vec<&str> = self.query.split_whitespace().collect();
@@ -697,7 +726,10 @@ impl<T: Model> FullTextSearchBuilder<T> {
697726
params,
698727
Value::String(Some(proximity.join(&format!(" <{}> ", distance)))),
699728
);
700-
format!("to_tsquery('{}', {})", language, placeholder)
729+
format!(
730+
"to_tsquery(CAST({} AS regconfig), {})",
731+
language_placeholder, placeholder
732+
)
701733
}
702734
}
703735
}
@@ -734,12 +766,7 @@ impl<T: Model> FullTextSearchBuilder<T> {
734766
table, columns_str, query_placeholder, mode_modifier
735767
);
736768

737-
if let Some(limit) = self.limit {
738-
sql.push_str(&format!("LIMIT {} ", limit));
739-
}
740-
if let Some(offset) = self.offset {
741-
sql.push_str(&format!("OFFSET {} ", offset));
742-
}
769+
self.append_limit_offset(DatabaseType::MySQL, &mut sql, &mut params)?;
743770

744771
Ok((sql, params))
745772
}
@@ -803,12 +830,7 @@ impl<T: Model> FullTextSearchBuilder<T> {
803830

804831
sql.push_str("ORDER BY _fts_rank DESC ");
805832

806-
if let Some(limit) = self.limit {
807-
sql.push_str(&format!("LIMIT {} ", limit));
808-
}
809-
if let Some(offset) = self.offset {
810-
sql.push_str(&format!("OFFSET {} ", offset));
811-
}
833+
self.append_limit_offset(DatabaseType::MySQL, &mut sql, &mut params)?;
812834

813835
Ok((sql, params))
814836
}
@@ -869,12 +891,7 @@ impl<T: Model> FullTextSearchBuilder<T> {
869891
table, fts_table, fts_table, query_placeholder
870892
);
871893

872-
if let Some(limit) = self.limit {
873-
sql.push_str(&format!("LIMIT {} ", limit));
874-
}
875-
if let Some(offset) = self.offset {
876-
sql.push_str(&format!("OFFSET {} ", offset));
877-
}
894+
self.append_limit_offset(DatabaseType::SQLite, &mut sql, &mut params)?;
878895

879896
Ok((sql, params))
880897
}
@@ -914,12 +931,7 @@ impl<T: Model> FullTextSearchBuilder<T> {
914931
// BM25 returns negative values, so ORDER BY ASC for best matches
915932
sql.push_str(&format!("ORDER BY bm25({}) ", fts_table));
916933

917-
if let Some(limit) = self.limit {
918-
sql.push_str(&format!("LIMIT {} ", limit));
919-
}
920-
if let Some(offset) = self.offset {
921-
sql.push_str(&format!("OFFSET {} ", offset));
922-
}
934+
self.append_limit_offset(DatabaseType::SQLite, &mut sql, &mut params)?;
923935

924936
Ok((sql, params))
925937
}
@@ -955,6 +967,38 @@ impl<T: Model> FullTextSearchBuilder<T> {
955967
params.push(value);
956968
placeholder
957969
}
970+
971+
fn pg_weights_placeholder(&self, params: &mut Vec<Value>) -> String {
972+
let weights = self
973+
.config
974+
.weights
975+
.as_ref()
976+
.map(|w| w.to_pg_array().trim_matches('\'').to_string())
977+
.unwrap_or_else(|| "{0.1,0.2,0.4,1.0}".to_string());
978+
979+
self.push_param(DatabaseType::Postgres, params, Value::String(Some(weights)))
980+
}
981+
982+
fn append_limit_offset(
983+
&self,
984+
db_type: DatabaseType,
985+
sql: &mut String,
986+
params: &mut Vec<Value>,
987+
) -> Result<()> {
988+
if let Some(limit) = self.limit {
989+
let limit_value = i64::try_from(limit)
990+
.map_err(|_| Error::query("Full-text search limit exceeds i64 range"))?;
991+
let placeholder = self.push_param(db_type, params, Value::BigInt(Some(limit_value)));
992+
sql.push_str(&format!(" LIMIT {}", placeholder));
993+
}
994+
if let Some(offset) = self.offset {
995+
let offset_value = i64::try_from(offset)
996+
.map_err(|_| Error::query("Full-text search offset exceeds i64 range"))?;
997+
let placeholder = self.push_param(db_type, params, Value::BigInt(Some(offset_value)));
998+
sql.push_str(&format!(" OFFSET {}", placeholder));
999+
}
1000+
Ok(())
1001+
}
9581002
}
9591003

9601004
// =============================================================================

0 commit comments

Comments
 (0)