criterium 3.1.3

Lightweigt dynamic database queries for rusqlite.
Documentation
// SPDX-FileCopyrightText: 2025 Slatian
//
// SPDX-License-Identifier: LGPL-3.0-only

use rusqlite::types::Value;

use crate::rusqlite::RusqliteQuery;
use crate::search::CriteriumSearchChain;

use crate::sql::Field;
use crate::sql::Prefix;
use crate::sql::Table;

impl CriteriumSearchChain {
	/// Generates a string that can be used as an argument in
	/// an SQLite `MATCH` statement with a restriction on used columns.
	///
	/// **Warning:** Only use known values for the columns, the'll be used as is.
	/// Unlike elsewhere in criterium the columns should be given as just the column name
	/// without the `table.` prefix. This is because the `MATCH` operator already
	/// has the table context.
	///
	/// Because of an SQLite FTS5 quirk this will make use of an `any` column.
	/// See [would_require_an_any_column()][Self::would_require_an_any_column]
	/// for details on this.
	///
	/// Example output:
	/// * `{title text} : ("test" AND "query")`
	/// * `(any: any) NOT ("inverted" OR "query")`
	pub fn to_sqlite_match_token<F: Field>(&self, columns: &Vec<&F>) -> String {
		self.to_sqlite_match_token_inner(columns, false)
	}

	fn to_sqlite_match_token_inner<F: Field>(
		&self,
		columns: &Vec<&F>,
		cols_already_applied: bool,
	) -> String {
		let specifier: String;
		let sub_columns: &Vec<&F>;
		let empty_vec: Vec<&F> = vec![];
		let cols_applied: bool;
		if cols_already_applied || self.would_require_an_any_column() {
			specifier = "".to_string();
			sub_columns = columns;
			cols_applied = cols_already_applied;
		} else {
			specifier = format_sqlite_column_specifier(columns);
			sub_columns = &empty_vec;
			cols_applied = true;
		}
		match self {
			Self::And(children) => {
				let content = children
					.iter()
					.map(|c| c.to_sqlite_match_token_inner(sub_columns, cols_applied))
					.collect::<Vec<String>>()
					.join(" AND ");
				return format!("{specifier}({content})");
			}
			Self::Or(children) => {
				let content = children
					.iter()
					.map(|c| c.to_sqlite_match_token_inner(sub_columns, cols_applied))
					.collect::<Vec<String>>()
					.join(" OR ");
				return format!("{specifier}({content})");
			}
			Self::PhraseChain(phrases) => {
				let content = phrases
					.iter()
					.map(|phrase| phrase.to_sqlite_match_token())
					.collect::<Vec<String>>()
					.join(" + ");
				return format!("{specifier}({content})");
			}
			Self::NotChain(child) => {
				return format!(
					"((any: any) NOT {})",
					child.to_sqlite_match_token_inner(sub_columns, cols_applied)
				);
			}
			Self::Phrase(phrase) => {
				return specifier + &phrase.to_sqlite_match_token();
			}
		}
	}

	/// The SQLite FTS5 match `NOT` operater is a binary operator as opposed to being
	/// a unary like in most cases. So `<a> NOT <b>` is really `<a> AND (NOT <b>)`.
	/// In some cases to model a unary `NOT`, a matcher is required that is guaranteed
	/// to always match is required in place of `<a>`.
	///
	/// To fill this need criterium needs a column called `any`
	/// that always contains the word `any`. This way the matcher can be tricked.
	/// You have to add such a column to your fts5 tables yourself.
	///
	/// Currently the mechanism is kind of naive and uses the any column whenever a
	/// `NOT` is needed. When updating make sure to also update the
	/// `to_sqlite_match_token()` function.
	pub fn would_require_an_any_column(&self) -> bool {
		match self {
			Self::And(children) => {
				for child in children {
					if child.would_require_an_any_column() {
						return true;
					}
				}
				false
			}
			Self::Or(children) => {
				for child in children {
					if child.would_require_an_any_column() {
						return true;
					}
				}
				false
			}
			Self::NotChain(_) => true,
			Self::PhraseChain(_) => false,
			Self::Phrase(_) => false,
		}
	}

	/// Convenience wrapper around `to_sqlite_match_token_with_columns()`.
	///
	/// Use this when implementing the [AssembleRusqliteQuery] trait
	/// for your own criteria that use a full text search for rusqlite.
	///
	/// **SQLite Limitations:**
	/// * Because of a implementation quirk of SQLite FTS5 the prefix won't be
	///   actually used. Make sure you only have one inctance of each full text search.
	/// * The `MATCH` operator used by this doesn't like being combined
	///   with `NOT` and `OR` operators.
	///
	///
	/// This struct also implements [ToRusqliteSingleField]
	/// if you don't need the multi-column functionality.
	/// **Warning:** The single column breaks the `any`-workaraound
	/// needed for the `NOT` operator.
	///
	/// [AssembleRusqliteQuery]: crate::rusqlite::AssembleRusqliteQuery
	/// [ToRusqliteSingleField]: crate::rusqlite::ToRusqliteSingleField
	pub fn to_rusqlite_query<F: Field>(
		&self,
		prefix: &Prefix,
		table: F::TableType,
		columns: &Vec<&F>,
	) -> RusqliteQuery<F> {
		return RusqliteQuery {
			used_prefix: prefix.to_string(),
			// Prefix is not added here, intentionally!
			sql_where_clause: format!("{} MATCH ?", table.sql_safe_table_name()),
			sql_joins: Vec::new(),
			where_values: vec![Value::Text(self.to_sqlite_match_token(columns))],
		};
	}
}

fn format_sqlite_column_specifier<F: Field>(columns: &[&F]) -> String {
	if columns.is_empty() {
		// Exclude the any helper column when matching against "all" columns.
		return "-any : ".to_string();
	} else if columns.len() == 1 {
		return format!("{} : ", columns[0].sql_safe_field_name());
	} else {
		return format!(
			"{{{}}} : ",
			columns
				.iter()
				.map(|c| c.sql_safe_field_name())
				.collect::<Vec<_>>()
				.join(" ")
		);
	}
}