use bytes::BytesMut;
use postgres_types::{FromSql, IsNull, ToSql, Type, to_sql_checked};
use std::error::Error;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TsVector(pub String);
impl TsVector {
pub fn new(s: impl Into<String>) -> Self {
TsVector(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for TsVector {
fn from(s: String) -> Self {
TsVector(s)
}
}
impl From<TsVector> for String {
fn from(v: TsVector) -> Self {
v.0
}
}
impl std::fmt::Display for TsVector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl crate::descriptor::DjogiSqlType for TsVector {
const SQL_TYPE: &'static str = "TSVECTOR";
}
impl ToSql for TsVector {
fn to_sql(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
self.0.to_sql(ty, out)
}
fn accepts(ty: &Type) -> bool {
matches!(ty.name(), "tsvector" | "text")
}
to_sql_checked!();
}
impl<'a> FromSql<'a> for TsVector {
fn from_sql(ty: &Type, raw: &'a [u8]) -> Result<Self, Box<dyn Error + Sync + Send>> {
let s = <String as FromSql>::from_sql(ty, raw)?;
Ok(TsVector(s))
}
fn accepts(ty: &Type) -> bool {
matches!(ty.name(), "tsvector" | "text")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TsQuery(pub String);
impl TsQuery {
pub fn new(s: impl Into<String>) -> Self {
TsQuery(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for TsQuery {
fn from(s: String) -> Self {
TsQuery(s)
}
}
impl From<TsQuery> for String {
fn from(q: TsQuery) -> Self {
q.0
}
}
impl std::fmt::Display for TsQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl crate::descriptor::DjogiSqlType for TsQuery {
const SQL_TYPE: &'static str = "TSQUERY";
}
impl ToSql for TsQuery {
fn to_sql(
&self,
ty: &Type,
out: &mut BytesMut,
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
self.0.to_sql(ty, out)
}
fn accepts(ty: &Type) -> bool {
matches!(ty.name(), "tsquery" | "text")
}
to_sql_checked!();
}
impl<'a> FromSql<'a> for TsQuery {
fn from_sql(ty: &Type, raw: &'a [u8]) -> Result<Self, Box<dyn Error + Sync + Send>> {
let s = <String as FromSql>::from_sql(ty, raw)?;
Ok(TsQuery(s))
}
fn accepts(ty: &Type) -> bool {
matches!(ty.name(), "tsquery" | "text")
}
}
pub fn validate_dictionary_name(name: &str) -> Result<(), String> {
use crate::ident::IdentError;
crate::ident::check_user_supplied_ident(name, false).map_err(|e| match e {
IdentError::Empty => "dictionary name must not be empty".to_owned(),
IdentError::TooLong { len } => format!(
"dictionary name `{name}` is {len} bytes; Postgres caps identifiers at 63 bytes"
),
IdentError::BadFirst { .. } => {
format!("dictionary name `{name}` must start with an ASCII letter or underscore")
}
IdentError::BadByte { idx, byte } => format!(
"dictionary name `{name}` contains invalid character `{}` at position {idx} — \
only ASCII letters, digits, and underscores are allowed",
byte as char
),
IdentError::Reserved => {
unreachable!("check_user_supplied_ident(reserved=false) cannot return Reserved")
}
IdentError::ReservedDjogiPrefix => format!(
"dictionary name `{name}` starts with the framework-reserved `__djogi_` prefix; \
choose a different name"
),
})
}
pub fn validate_source_column(col: &str) -> Result<(), String> {
use crate::ident::IdentError;
crate::ident::check_user_supplied_ident(col, false).map_err(|e| match e {
IdentError::Empty => "source column name must not be empty".to_owned(),
IdentError::TooLong { len } => {
format!("source column `{col}` is {len} bytes; Postgres caps identifiers at 63 bytes")
}
IdentError::BadFirst { .. } => {
format!("source column `{col}` must start with an ASCII letter or underscore")
}
IdentError::BadByte { idx, byte } => format!(
"source column `{col}` contains invalid character `{}` at position {idx}",
byte as char
),
IdentError::Reserved => {
unreachable!("check_user_supplied_ident(reserved=false) cannot return Reserved")
}
IdentError::ReservedDjogiPrefix => format!(
"source column `{col}` starts with the framework-reserved `__djogi_` prefix; \
rename the column or use `#[field(renamed_from = \"…\")]` to map to a non-\
reserved name"
),
})
}
pub fn parse_source_columns(source: &str) -> Result<Vec<String>, String> {
let cols: Vec<String> = source.split(',').map(|s| s.trim().to_owned()).collect();
if cols.is_empty() {
return Err("`source` must name at least one column".to_owned());
}
for col in &cols {
validate_source_column(col)?;
}
Ok(cols)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FtsDescriptor {
pub column: &'static str,
pub source: &'static str,
pub dictionary: &'static str,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_dictionary_name_valid() {
assert!(validate_dictionary_name("english").is_ok());
assert!(validate_dictionary_name("spanish").is_ok());
assert!(validate_dictionary_name("pg_catalog_english").is_ok());
assert!(validate_dictionary_name("_private").is_ok());
assert!(validate_dictionary_name("dict123").is_ok());
}
#[test]
fn validate_dictionary_name_empty() {
assert!(validate_dictionary_name("").is_err());
}
#[test]
fn validate_dictionary_name_too_long() {
let long = "a".repeat(64);
let result = validate_dictionary_name(&long);
assert!(result.is_err(), "expected error for 64-byte name");
assert!(result.unwrap_err().contains("63 bytes"));
}
#[test]
fn validate_dictionary_name_starts_with_digit() {
let result = validate_dictionary_name("1english");
assert!(result.is_err());
assert!(result.unwrap_err().contains("must start with"));
}
#[test]
fn validate_dictionary_name_contains_hyphen() {
let result = validate_dictionary_name("english-us");
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid character"));
}
#[test]
fn validate_dictionary_name_contains_space() {
let result = validate_dictionary_name("my dict");
assert!(result.is_err());
}
#[test]
fn validate_dictionary_name_exactly_63_bytes() {
let name = "a".repeat(63);
assert!(validate_dictionary_name(&name).is_ok());
}
#[test]
fn validate_dictionary_name_rejects_djogi_reserved_prefix() {
let err = validate_dictionary_name("__djogi_english")
.expect_err("must reject framework-reserved prefix");
assert!(err.contains("`__djogi_` prefix"), "got: {err}");
assert!(validate_dictionary_name("__djogi_").is_err());
assert!(validate_dictionary_name("djogi_english").is_ok());
assert!(validate_dictionary_name("_djogi_english").is_ok());
}
#[test]
fn validate_source_column_rejects_djogi_reserved_prefix() {
let err = validate_source_column("__djogi_search")
.expect_err("must reject framework-reserved prefix");
assert!(err.contains("`__djogi_` prefix"), "got: {err}");
assert!(validate_source_column("__djogi_").is_err());
assert!(validate_source_column("djogi_title").is_ok());
}
#[test]
fn parse_source_columns_single() {
let cols = parse_source_columns("title").unwrap();
assert_eq!(cols, vec!["title"]);
}
#[test]
fn parse_source_columns_multiple() {
let cols = parse_source_columns("title, body").unwrap();
assert_eq!(cols, vec!["title", "body"]);
}
#[test]
fn parse_source_columns_trims_whitespace() {
let cols = parse_source_columns(" title , body ").unwrap();
assert_eq!(cols, vec!["title", "body"]);
}
#[test]
fn parse_source_columns_invalid_col() {
let result = parse_source_columns("1col, body");
assert!(result.is_err());
}
#[test]
fn ts_vector_roundtrip_string() {
let v = TsVector::new("'earth':2 'planet':1");
assert_eq!(v.as_str(), "'earth':2 'planet':1");
let s: String = v.clone().into();
assert_eq!(s, "'earth':2 'planet':1");
}
#[test]
fn ts_query_roundtrip_string() {
let q = TsQuery::new("planet & earth");
assert_eq!(q.as_str(), "planet & earth");
let s: String = q.clone().into();
assert_eq!(s, "planet & earth");
}
#[test]
fn fts_descriptor_fields() {
let desc = FtsDescriptor {
column: "search",
source: "title, body",
dictionary: "english",
};
assert_eq!(desc.column, "search");
assert_eq!(desc.source, "title, body");
assert_eq!(desc.dictionary, "english");
}
#[test]
fn fts_descriptor_dictionary_change_detected() {
let d1 = FtsDescriptor {
column: "search",
source: "title, body",
dictionary: "english",
};
let d2 = FtsDescriptor {
column: "search",
source: "title, body",
dictionary: "spanish",
};
assert_ne!(
d1, d2,
"different dictionaries must be detected as a change"
);
}
}