sqlite-err-parser 0.5.0

Deconstruct certain sqlite errors
Documentation
#![allow(clippy::doc_markdown)]
//! Turn certain sqlite errors (received through rusqlite) into structured
//! data.
//!
//! # Caveat Emptor
//! This crate will parse the raw error strings generated by the underlying
//! SQLite library.  There are several potential pitfalls with this:
//!
//! - SQLite does not appear to provide any stability guarantees with regards
//!   to its error messages.
//! - Compliation options may affect what error messages SQLite will generate.
//! - SQLite libraries from third parties may have modified error messages.
//!
//! There are more robust ways to solve the problem this crate attempts to
//! solve -- for instance using triggers.

use std::sync::OnceLock;

use rusqlite::{
  Error,
  ffi::{
    self as sys, SQLITE_CONSTRAINT_CHECK, SQLITE_CONSTRAINT_NOTNULL,
    SQLITE_CONSTRAINT_PRIMARYKEY, SQLITE_CONSTRAINT_TRIGGER,
    SQLITE_CONSTRAINT_UNIQUE
  }
};

/// Translator function used to translate an [`InterpretedError`] to a
/// `String`.
#[allow(clippy::type_complexity)]
static SQLERR_INTERPRETER: OnceLock<
  Box<dyn Fn(&InterpretedError) -> Option<String> + 'static + Send + Sync>
> = OnceLock::new();


/// Register a closure that translates an [`InterpretedError`] into a `String`.
///
/// The closure is called by [`interpret()`].
pub fn register_interpreter(
  f: impl Fn(&InterpretedError) -> Option<String> + 'static + Send + Sync
) {
  let _ = SQLERR_INTERPRETER.set(Box::new(f));
}


/// Interpret an [`rusqlite::Error`] into a `String`.
///
/// ```
/// use rusqlite::Connection;
/// use sqlite_err_parser::{register_interpreter, InterpretedError, interpret};
///
/// fn stringify(ie: &InterpretedError) -> Option<String> {
///   match ie {
///     InterpretedError::NotUnique(ids) => match ids.as_slice() {
///       [("testtbl1", "id")] => Some("id already exists".into()),
///       [("testtbl1", "name")] => Some("name already exists".into()),
///       [("testtbl2", "name1"), ("testtbl2", "name2")] => {
///         Some("name pair already exists".into())
///       }
///       _ => None
///     },
///     InterpretedError::Check(rule) => None,
///     _ => None
///   }
/// }
///
/// // Register translator function.
/// register_interpreter(stringify);
///
/// // Open and initialize database
/// let conn = Connection::open_in_memory().unwrap();
/// conn
///    .execute("CREATE TABLE IF NOT EXISTS testtbl1 (
///   id   INTEGER PRIMARY KEY,
///   name TEXT UNIQUE NOT NULL
/// );", []).unwrap();
///  conn
///    .execute("CREATE TABLE IF NOT EXISTS testtbl2 (
///   id    INTEGER PRIMARY KEY,
///   name1 TEXT NOT NULL,
///   name2 TEXT NOT NULL,
///   UNIQUE(name1, name2)
/// );", []).unwrap();
///
/// // Populate with some test data
/// conn.execute(
///   "INSERT INTO testtbl1 (id, name) VALUES (1, 'frank');", []
/// ).unwrap();
/// conn.execute(
///   "INSERT INTO testtbl2 (id, name1, name2) VALUES (1, 'bill', 'frank');",
///   []
/// ).unwrap();
///
/// // testtbl1.id uniqueness violation
/// let err = conn.execute(
///   "INSERT INTO testtbl1 (id, name) VALUES (1, 'bill');", []
/// ).unwrap_err();
/// assert_eq!(interpret(&err), Some("id already exists".into()));
///
/// // testtbl1.name uniqueness violation
/// let err = conn.execute(
///   "INSERT INTO testtbl1 (name) VALUES ('frank');", []
/// ).unwrap_err();
/// assert_eq!(interpret(&err), Some("name already exists".into()));
///
/// // (testtbl2.name1, testtbl2.name2) uniqueness violation
/// let err = conn.execute(
///   "INSERT INTO testtbl2 (name1, name2) VALUES ('bill', 'frank');", []
/// ).unwrap_err();
/// assert_eq!(interpret(&err), Some("name pair already exists".into()));
/// ```
#[must_use]
pub fn interpret(err: &rusqlite::Error) -> Option<String> {
  deconstruct_error(err).as_ref().and_then(|ie| {
    SQLERR_INTERPRETER.get().and_then(|stringify| stringify(ie))
  })
}


/// A structured interpretation of certain rusqlite errors.
#[derive(Debug)]
#[non_exhaustive]
pub enum InterpretedError<'e> {
  /// A uniqueness violation.
  ///
  /// Sorted list of `(<table>, <column>)` pairs, specifying which columns
  /// caused the uniqueness constraints violation.
  NotUnique(Vec<(&'e str, &'e str)>),

  /// A "not null" violation.
  NotNull(Vec<(&'e str, &'e str)>),

  /// A `CHECK` constraint violation.
  ///
  /// The field data contains the specific rule that caused the error.
  Check(&'e str),

  /// A foreign key constraint failed.
  ForeignKey
}


/// Attempt to interpret a [`rusqlite::Error`], and turn it into an
/// [`InterpretedError`].
#[must_use]
pub fn deconstruct_error(err: &Error) -> Option<InterpretedError<'_>> {
  //println!("deconstruct: {err:?}");

  match err {
    Error::SqliteFailure(
      sys::Error {
        code,
        extended_code
      },
      Some(msg)
    ) if *code == sys::ErrorCode::ConstraintViolation => {
      match *extended_code {
        SQLITE_CONSTRAINT_UNIQUE | SQLITE_CONSTRAINT_PRIMARYKEY => {
          //println!("uniqueness");
          deconstruct_unique(msg)
        }
        SQLITE_CONSTRAINT_NOTNULL => {
          //println!("notnull");
          deconstruct_notnull(msg)
        }
        SQLITE_CONSTRAINT_CHECK => {
          //println!("check");
          deconstruct_check(msg)
        }
        SQLITE_CONSTRAINT_TRIGGER => deconstruct_constraint(msg),
        _ => {
          //panic!("{err}");
          None
        }
      }
    }
    _e => {
      //panic!("Unexpected error: {e}");
      None
    }
  }
}

fn deconstruct_unique(msg: &str) -> Option<InterpretedError<'_>> {
  //println!("{msg}");

  // Extract the table and column name from
  // s: "UNIQUE constraint failed: table.col"
  // s: "UNIQUE constraint failed: table.col1, table.col2"

  // Extract the part after "UNIQUE constraint failed:", which is assumed
  // to be the list of columns where the violation occurred.
  let Some((_, ns)) = msg.rsplit_once(':') else {
    //panic!("No ':' in constraint violation");

    // Bail
    return None;
  };

  // Generate a list of  <table>.<column>
  let mut lst: Vec<(&str, &str)> = ns
    .trim()
    .split(',')
    .map(str::trim)
    .filter_map(|e| e.split_once('.'))
    .collect();

  // Sort table/column entries
  lst.sort_unstable();

  //println!("{lst:?}");

  Some(InterpretedError::NotUnique(lst))
}

fn deconstruct_notnull(msg: &str) -> Option<InterpretedError<'_>> {
  //println!("{msg}");

  // Extract the table and column name from
  // "NOT NULL constraint failed: agents.role_id"

  let Some((_, ns)) = msg.rsplit_once(':') else {
    //panic!("No ':' in constraint violation");
    // Bail
    return None;
  };

  // Generate a list of <table>.<column>
  let mut lst: Vec<(&str, &str)> = ns
    .trim()
    .split(',')
    .map(str::trim)
    .filter_map(|e| e.split_once('.'))
    .collect();

  // Sort table/column entries
  lst.sort_unstable();

  //println!("{lst:?}");

  Some(InterpretedError::NotNull(lst))
}

fn deconstruct_check(msg: &str) -> Option<InterpretedError<'_>> {
  //println!("{msg}");

  // Extract the table and column name from
  // CHECK constraint failed: length(name)<4"

  // Extract the part after "UNIQUE constraint failed:", which is assumed
  // to be the list of columns where the violation occurred.
  let Some((_, rule)) = msg.rsplit_once(':') else {
    //panic!("No ':' in constraint violation");
    return None;
  };

  Some(InterpretedError::Check(rule.trim()))
}

fn deconstruct_constraint(msg: &str) -> Option<InterpretedError<'_>> {
  if msg == "FOREIGN KEY constraint failed" {
    Some(InterpretedError::ForeignKey)
  } else {
    None
  }
}

// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 :