1use super::{multi_field_match_shape, MultiFieldMatchShape};
10use crate::{plan::SourcePlan, SQLError, ScalarExpr};
11use uqa_core::Value;
12
13pub trait TextMatchCatalog {
14 fn has_table(&self, table: &str) -> Result<bool, String>;
15 fn has_column(&self, table: &str, column: &str) -> Result<bool, String>;
16 fn column_names(&self, table: &str) -> Result<Vec<String>, String>;
17 fn indexed_fields(&self, table: &str) -> Result<Vec<String>, SQLError>;
18}
19
20const SINGLE_FIELD_TEXT_MATCH_FUNCTIONS: [&str; 4] = [
21 "text_match",
22 "bayesian_match",
23 "fts_match",
24 "bayesian_match_with_prior",
25];
26
27fn walk_text_match_fields(
32 expr: &ScalarExpr,
33 validate: &mut dyn FnMut(&ScalarExpr, &str) -> Result<(), SQLError>,
34) -> Result<(), SQLError> {
35 match expr {
36 ScalarExpr::Func {
37 name, args, filter, ..
38 } => {
39 let lower = name.to_ascii_lowercase();
40 if SINGLE_FIELD_TEXT_MATCH_FUNCTIONS.contains(&lower.as_str()) {
41 if let Some(field_arg) = args.first() {
42 if !(lower == "fts_match" && fts_query_is_jsonpath(args.get(1))) {
43 validate(field_arg, &lower)?;
44 }
45 }
46 } else if lower == "multi_field_match" {
47 match multi_field_match_shape(args)? {
48 MultiFieldMatchShape::FieldsThenQuery { fields, .. }
49 | MultiFieldMatchShape::Pairs { fields } => {
50 for field_arg in fields {
51 validate(field_arg, "multi_field_match")?;
52 }
53 }
54 }
55 }
56 for arg in args {
57 walk_text_match_fields(arg, validate)?;
58 }
59 if let Some(filter) = filter {
60 walk_text_match_fields(filter, validate)?;
61 }
62 Ok(())
63 }
64 ScalarExpr::And(items)
65 | ScalarExpr::Or(items)
66 | ScalarExpr::Array(items)
67 | ScalarExpr::Row(items)
68 | ScalarExpr::CompositeRow { items, .. } => {
69 for item in items {
70 walk_text_match_fields(item, validate)?;
71 }
72 Ok(())
73 }
74 ScalarExpr::Not(inner) | ScalarExpr::UnaryMinus(inner) => {
75 walk_text_match_fields(inner, validate)
76 }
77 ScalarExpr::Binary { lhs, rhs, .. } => {
78 walk_text_match_fields(lhs, validate)?;
79 walk_text_match_fields(rhs, validate)
80 }
81 ScalarExpr::IsNull { expr, .. } => walk_text_match_fields(expr, validate),
82 ScalarExpr::Between { expr, low, high } => {
83 walk_text_match_fields(expr, validate)?;
84 walk_text_match_fields(low, validate)?;
85 walk_text_match_fields(high, validate)
86 }
87 ScalarExpr::InList { expr, list, .. } => {
88 walk_text_match_fields(expr, validate)?;
89 for item in list {
90 walk_text_match_fields(item, validate)?;
91 }
92 Ok(())
93 }
94 _ => Ok(()),
95 }
96}
97
98pub fn validate_expr_text_match_fields(
99 catalog: &dyn TextMatchCatalog,
100 table: &str,
101 expr: &ScalarExpr,
102) -> Result<(), SQLError> {
103 walk_text_match_fields(
104 expr,
105 &mut |field_arg, function_name| match text_match_field_name(field_arg) {
106 Some(TextMatchField::All) => {
107 validate_text_match_all_fields(catalog, table, function_name)
108 }
109 Some(TextMatchField::Named(field)) => {
110 validate_text_match_field(catalog, table, field, function_name)
111 }
112 None => Ok(()),
113 },
114 )
115}
116
117enum TextMatchField<'a> {
118 All,
119 Named(&'a str),
120}
121
122fn text_match_field_name(field_arg: &ScalarExpr) -> Option<TextMatchField<'_>> {
125 match field_arg {
126 ScalarExpr::Column(name) | ScalarExpr::QualifiedColumn { column: name, .. } => {
127 if name.is_empty() || name == "_all" {
128 Some(TextMatchField::All)
129 } else {
130 Some(TextMatchField::Named(name))
131 }
132 }
133 ScalarExpr::Literal(Value::Str(s)) if s.is_empty() || s == "_all" => {
134 Some(TextMatchField::All)
135 }
136 _ => None,
137 }
138}
139
140pub fn validate_joined_expr_text_match_fields(
141 catalog: &dyn TextMatchCatalog,
142 from: &SourcePlan,
143 expr: &ScalarExpr,
144) -> Result<(), SQLError> {
145 let mut tables: Vec<(Option<String>, String, Vec<String>)> = Vec::new();
146 let mut has_opaque_source = false;
147 collect_from_tables(from, &mut tables, &mut has_opaque_source);
148 walk_text_match_fields(expr, &mut |field_arg, function_name| {
149 let (qualifier, column) = match field_arg {
150 ScalarExpr::Column(name) => (None, name.as_str()),
151 ScalarExpr::QualifiedColumn {
152 qualifier, column, ..
153 } => (Some(qualifier.as_str()), column.as_str()),
154 _ => return Ok(()),
155 };
156 if column.is_empty() || column == "_all" {
157 return Ok(());
158 }
159 if let Some(qualifier) = qualifier {
160 let resolved = tables
161 .iter()
162 .find(|(alias, name, _)| alias.as_deref() == Some(qualifier) || name == qualifier);
163 return match resolved {
164 Some((_, table, aliases)) => {
165 let physical = table_source_physical_column(catalog, table, aliases, column)?
166 .unwrap_or_else(|| column.to_string());
167 validate_text_match_field(catalog, table, &physical, function_name)
168 }
169 None => Ok(()),
172 };
173 }
174 let mut containing = Vec::new();
175 for (_, name, aliases) in &tables {
176 if let Some(physical) = table_source_physical_column(catalog, name, aliases, column)? {
177 containing.push((name, physical));
178 }
179 }
180 for (name, physical) in &containing {
181 if catalog
182 .indexed_fields(name)?
183 .iter()
184 .any(|field| field == physical)
185 {
186 return Ok(());
187 }
188 }
189 if let Some((table, physical)) = containing.first() {
190 return validate_text_match_field(catalog, table, physical, function_name);
191 }
192 if has_opaque_source {
193 return Ok(());
194 }
195 Err(SQLError::TypeMismatch(format!(
196 "{function_name}: column `{column}` does not exist on any joined table"
197 )))
198 })
199}
200
201fn table_source_physical_column(
202 catalog: &dyn TextMatchCatalog,
203 table: &str,
204 aliases: &[String],
205 visible: &str,
206) -> Result<Option<String>, SQLError> {
207 let columns = catalog
208 .column_names(table)
209 .map_err(|error| SQLError::Internal(format!("read table schema: {error}")))?;
210 if columns.is_empty() {
211 return Ok(Some(visible.to_string()));
212 }
213 Ok(columns
214 .into_iter()
215 .enumerate()
216 .find_map(|(position, physical)| {
217 aliases
218 .get(position)
219 .map_or_else(
220 || physical.eq_ignore_ascii_case(visible),
221 |alias| alias.eq_ignore_ascii_case(visible),
222 )
223 .then_some(physical)
224 }))
225}
226
227pub use super::fts_query_is_jsonpath;
228
229fn collect_from_tables(
230 from: &SourcePlan,
231 out: &mut Vec<(Option<String>, String, Vec<String>)>,
232 has_opaque_source: &mut bool,
233) {
234 match from {
235 SourcePlan::Table {
236 name,
237 qualifier,
238 alias,
239 column_aliases,
240 ..
241 } => out.push((
242 Some(alias.as_ref().unwrap_or(qualifier).clone()),
243 name.clone(),
244 column_aliases.clone(),
245 )),
246 SourcePlan::Join {
247 left, right, alias, ..
248 } => {
249 if alias.is_some() {
250 *has_opaque_source = true;
251 } else {
252 collect_from_tables(left, out, has_opaque_source);
253 collect_from_tables(right, out, has_opaque_source);
254 }
255 }
256 _ => *has_opaque_source = true,
257 }
258}
259
260pub fn validate_text_match_field(
264 catalog: &dyn TextMatchCatalog,
265 table: &str,
266 field: &str,
267 function_name: &str,
268) -> Result<(), SQLError> {
269 if !catalog
270 .has_table(table)
271 .map_err(|err| SQLError::Internal(format!("read table catalog: {err}")))?
272 {
273 return Err(SQLError::TypeMismatch(format!(
274 "{function_name}: unknown table `{table}`"
275 )));
276 }
277 let indexed = catalog
278 .indexed_fields(table)?
279 .iter()
280 .any(|fts| fts == field);
281 if !indexed {
282 if !catalog
283 .has_column(table, field)
284 .map_err(|err| SQLError::Internal(format!("read table schema: {err}")))?
285 && !catalog
286 .column_names(table)
287 .map_err(|err| SQLError::Internal(format!("read table schema: {err}")))?
288 .is_empty()
289 {
290 return Err(SQLError::TypeMismatch(format!(
291 "{function_name}: column `{field}` does not exist on table `{table}`"
292 )));
293 }
294 return Err(SQLError::TypeMismatch(format!(
295 "{function_name}: column `{table}.{field}` has no text index; \
296 create one with CREATE INDEX ... ON {table} USING gin ({field})"
297 )));
298 }
299 Ok(())
300}
301
302pub fn validate_text_match_all_fields(
303 catalog: &dyn TextMatchCatalog,
304 table: &str,
305 function_name: &str,
306) -> Result<(), SQLError> {
307 if !catalog
308 .has_table(table)
309 .map_err(|err| SQLError::Internal(format!("read table catalog: {err}")))?
310 {
311 return Err(SQLError::TypeMismatch(format!(
312 "{function_name}: unknown table `{table}`"
313 )));
314 }
315 if catalog.indexed_fields(table)?.is_empty() {
316 return Err(SQLError::TypeMismatch(format!(
317 "{function_name}: table `{table}` has no text-indexed columns; \
318 create one with CREATE INDEX ... ON {table} USING gin (...)"
319 )));
320 }
321 Ok(())
322}
323
324pub fn require_physical_text_index(
326 table: &str,
327 field: &str,
328 indexed_fields: &[String],
329 columns: impl FnOnce() -> Vec<crate::ast::ColumnDef>,
330) -> Result<(), SQLError> {
331 if indexed_fields.iter().any(|indexed| indexed == field) {
332 return Ok(());
333 }
334 let columns = columns();
335 if !columns.is_empty() && !columns.iter().any(|column| column.name == field) {
336 return Err(SQLError::UnknownColumn(field.to_string()));
337 }
338 Err(SQLError::TypeMismatch(format!(
339 "text search: column `{table}.{field}` has no text index; create one with CREATE INDEX ... ON {table} USING gin ({field})"
340 )))
341}