use std::fmt::Display;
use std::hash::{Hash, Hasher};
use std::num::TryFromIntError;
use indoc::formatdoc;
use postgres_types::ToSql;
use tokio_postgres::Row;
use twox_hash::XxHash3_64;
use crate::schema::{TableShape, TicketStatus};
pub(crate) const GLOBAL: &str = "global";
pub(crate) const FOOTPRINT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy)]
pub struct SchemaPrefix<'a>(pub Option<&'a str>);
#[derive(Debug, Clone)]
pub struct SchemaPrefixOwned(pub Option<String>);
impl SchemaPrefix<'_> {
pub fn into_owned(self) -> SchemaPrefixOwned {
SchemaPrefixOwned(self.0.map(|s| s.to_owned()))
}
}
impl Display for SchemaPrefix<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(schema) = self.0 {
write!(f, "{schema}.")
} else {
Ok(())
}
}
}
impl Display for SchemaPrefixOwned {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(schema) = &self.0 {
write!(f, "{schema}.")
} else {
Ok(())
}
}
}
pub(crate) trait SqlParam: ToSql + Display + Send + Sync + 'static {
fn as_param(&self) -> &(dyn ToSql + Sync + 'static);
}
impl SqlParam for String {
fn as_param(&self) -> &(dyn ToSql + Sync + 'static) {
self
}
}
impl SqlParam for i64 {
fn as_param(&self) -> &(dyn ToSql + Sync + 'static) {
self
}
}
impl SqlParam for serde_json::Value {
fn as_param(&self) -> &(dyn ToSql + Sync + 'static) {
self
}
}
impl SqlParam for TicketStatus {
fn as_param(&self) -> &(dyn ToSql + Sync + 'static) {
self
}
}
#[repr(transparent)]
pub(crate) struct SqlParams(Vec<Box<dyn SqlParam>>);
impl SqlParams {
pub(crate) fn new(params: Vec<Box<dyn SqlParam>>) -> Self {
Self(params)
}
pub(crate) fn from_usize(
items: impl IntoIterator<Item = usize>,
) -> Result<Self, TryFromIntError> {
let items = items
.into_iter()
.map(|item| i64::try_from(item).map(box_sql))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self(items))
}
pub(crate) fn extend(mut self, params: Vec<Box<dyn SqlParam>>) -> Self {
self.0.extend(params);
self
}
pub(crate) fn borrow(&self) -> Vec<&(dyn ToSql + Sync + 'static)> {
self.0.iter().map(|x| x.as_param()).collect()
}
pub(crate) fn to_copy_string(&self) -> String {
let mut out = self
.0
.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(",");
out.push('\n');
out
}
}
pub(crate) fn box_sql<T: SqlParam>(value: T) -> Box<dyn SqlParam> {
Box::new(value)
}
pub(crate) fn sql_value_list<T: Display>(values: impl IntoIterator<Item = T>) -> String {
values
.into_iter()
.map(|value| format!("'{value}'"))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn hash_metadata<T: Hash>(metadata: &T) -> String {
let mut hasher = XxHash3_64::new();
metadata.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
const PSQL_MAX_IDENTIFIER_LENGTH: usize = 63;
const PSQL_ID_PREFIX_LENGTH: usize = 4;
const PSQL_ID_CLAMP_THRESHOLD: usize = 20;
pub(crate) fn psql_identifier(prefix: &str, id: &str) -> String {
let id = if id.len() <= PSQL_ID_CLAMP_THRESHOLD {
id.to_owned()
} else {
let id_prefix: String = id.chars().take(PSQL_ID_PREFIX_LENGTH).collect();
let hash = hash_str(id);
format!("{id_prefix}_{hash}")
};
let result = format!("{prefix}_{id}");
debug_assert!(
result.len() <= PSQL_MAX_IDENTIFIER_LENGTH,
"PSQL identifier may exceed 63 bytes: {result} (length: {})",
result.len(),
);
result
}
fn hash_str(s: &str) -> String {
let mut hasher = XxHash3_64::new();
s.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ShapeTable<'a> {
pub table: &'a str,
pub column: &'a str,
}
impl<'a> ShapeTable<'a> {
pub(crate) const fn record(self, id: &'a str) -> ShapeRecord<'a> {
ShapeRecord { table: self, id }
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ShapeRecord<'a> {
pub table: ShapeTable<'a>,
pub id: &'a str,
}
pub(crate) const FOOTPRINT_SHAPES: ShapeTable<'static> = ShapeTable {
table: "_footprint_version",
column: "version",
};
pub(crate) fn init_shape_table_query(
shape_table: ShapeTable<'_>,
schema_prefix: SchemaPrefix<'_>,
) -> String {
let ShapeTable { table, column } = shape_table;
formatdoc! {"
CREATE TABLE IF NOT EXISTS {schema_prefix}{table} (
id TEXT PRIMARY KEY,
{column} TEXT NOT NULL
);"
}
}
fn tables_present(tables: &[&str], schema_prefix: SchemaPrefix<'_>, connective: &str) -> String {
tables
.iter()
.map(|table| format!("to_regclass('{schema_prefix}{table}') IS NOT NULL"))
.collect::<Vec<_>>()
.join(connective)
}
fn any_table_present(tables: &[&str], schema_prefix: SchemaPrefix<'_>) -> String {
tables_present(tables, schema_prefix, " OR ")
}
fn all_tables_present(tables: &[&str], schema_prefix: SchemaPrefix<'_>) -> String {
tables_present(tables, schema_prefix, " AND ")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TablesPresent {
None,
Partial,
All,
}
impl TablesPresent {
fn from_row(row: &Row) -> Self {
let any: bool = row.get("any_present");
let all: bool = row.get("all_present");
match (any, all) {
(_, true) => Self::All,
(true, false) => Self::Partial,
(false, false) => Self::None,
}
}
}
pub(crate) fn shape_query(
record: ShapeRecord<'_>,
tables: &[&str],
schema_prefix: SchemaPrefix<'_>,
) -> String {
let ShapeRecord {
table: ShapeTable { table, column },
id,
} = record;
let any_present = any_table_present(tables, schema_prefix);
let all_present = all_tables_present(tables, schema_prefix);
formatdoc! {"
SELECT
(SELECT {column} FROM {schema_prefix}{table} WHERE id = '{id}') AS shape_id,
({any_present}) AS any_present,
({all_present}) AS all_present;"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShapeAction {
Keep,
Build,
Rebuild,
}
impl ShapeAction {
pub fn new(recorded: Option<&str>, present: TablesPresent, shape_id: &str) -> Self {
match (recorded, present) {
(_, TablesPresent::None) => Self::Build,
(Some(recorded), TablesPresent::All) if recorded == shape_id => Self::Keep,
_ => Self::Rebuild,
}
}
pub fn from_row(row: Option<&Row>, shape_id: &str) -> Self {
row.map_or(Self::Build, |row| {
Self::new(row.get("shape_id"), TablesPresent::from_row(row), shape_id)
})
}
}
impl From<ShapeAction> for TableShape {
fn from(action: ShapeAction) -> Self {
match action {
ShapeAction::Rebuild => Self::STALE,
ShapeAction::Keep | ShapeAction::Build => Self::CURRENT,
}
}
}
pub(crate) fn build_tables(
record: ShapeRecord<'_>,
tables: &[&str],
shape_id: &str,
schema_prefix: SchemaPrefix<'_>,
action: ShapeAction,
init_query: impl Display,
) -> Option<String> {
let ShapeRecord {
table: ShapeTable { table, column },
id,
} = record;
let qualified = tables
.iter()
.map(|table| format!("{schema_prefix}{table}"))
.collect::<Vec<_>>()
.join(", ");
let drop_tables = format!("DROP TABLE IF EXISTS {qualified};");
let record_shape = formatdoc! {"
INSERT INTO {schema_prefix}{table} (id, {column})
VALUES ('{id}', '{shape_id}')
ON CONFLICT (id) DO UPDATE SET {column} = EXCLUDED.{column};"
};
match action {
ShapeAction::Keep => None,
ShapeAction::Build => Some(format!("{init_query}\n\n{record_shape}")),
ShapeAction::Rebuild => Some(format!("{drop_tables}\n\n{init_query}\n\n{record_shape}")),
}
}
#[cfg(test)]
pub(crate) mod fixtures {
use std::fmt::Display;
use std::path::{Path, PathBuf};
use pretty_assertions::assert_eq;
use super::FOOTPRINT_VERSION;
const BLESS: &str = "BLESS_FOOTPRINT_SHAPE";
#[derive(Debug, Clone, Copy)]
pub(crate) enum FootprintStore {
Data,
Meta,
}
impl FootprintStore {
const ALL: [Self; 2] = [Self::Data, Self::Meta];
}
impl Display for FootprintStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FootprintStore::Data => write!(f, "data"),
FootprintStore::Meta => write!(f, "meta"),
}
}
}
pub(crate) fn assert_footprint_shape(store: FootprintStore, stmt: &str) {
let path = fixture_path(FOOTPRINT_VERSION, store);
let blessing = std::env::var_os(BLESS).is_some();
let recorded = match std::fs::read_to_string(&path) {
Ok(recorded) => recorded,
Err(e) if blessing && e.kind() == std::io::ErrorKind::NotFound => {
return record(&path, stmt);
}
Err(e) => panic!(
"Could not read {}: {e}\nRun the tests with {BLESS}=1 to record it.",
path.display()
),
};
assert_eq!(
tokens(stmt),
tokens(&recorded),
"The {store} footprint no longer matches the shape recorded for \
v{FOOTPRINT_VERSION}. Raise FOOTPRINT_VERSION to {next} and re-run the tests with \
{BLESS}=1 to record the new shape. If v{FOOTPRINT_VERSION} has not shipped, delete \
{} and re-run with {BLESS}=1 instead.",
path.display(),
next = FOOTPRINT_VERSION + 1,
);
}
fn record(path: &Path, stmt: &str) {
let dir = path
.parent()
.expect("the fixture sits in a version directory");
std::fs::create_dir_all(dir)
.unwrap_or_else(|e| panic!("Could not create {}: {e}", dir.display()));
std::fs::write(path, format!("{stmt}\n"))
.unwrap_or_else(|e| panic!("Could not write {}: {e}", path.display()));
}
fn tokens(stmt: &str) -> Vec<&str> {
stmt.split_whitespace().collect()
}
fn fixture_path(version: u32, store: FootprintStore) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("fixtures/footprint")
.join(format!("v{version}"))
.join(format!("{store}.sql"))
}
#[test]
fn test_all_fixtures_present() {
if std::env::var_os(BLESS).is_some() {
return;
}
for version in 1..=FOOTPRINT_VERSION {
for store in FootprintStore::ALL {
let path = fixture_path(version, store);
assert!(
path.exists(),
"Missing footprint fixture for v{version} {store}: {}",
path.display()
);
}
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::short("short_id", "ticket_short_id")]
#[case::long(
"this_is_a_very_long_task_name_that_would_exceed_postgresql_limits",
"ticket_this_738f27982fd1f340"
)]
fn test_psql_identifier_long_id_fits(#[case] id: &str, #[case] expected: &str) {
let result = psql_identifier("ticket", id);
assert_eq!(result, expected);
}
#[rstest]
#[case::unbuilt(None, TablesPresent::None, ShapeAction::Build)]
#[case::unrecorded(None, TablesPresent::All, ShapeAction::Rebuild)]
#[case::matching(Some("1"), TablesPresent::All, ShapeAction::Keep)]
#[case::diverged(Some("2"), TablesPresent::All, ShapeAction::Rebuild)]
#[case::matching_but_dropped(Some("1"), TablesPresent::None, ShapeAction::Build)]
#[case::diverged_and_dropped(Some("2"), TablesPresent::None, ShapeAction::Build)]
#[case::matching_but_partial(Some("1"), TablesPresent::Partial, ShapeAction::Rebuild)]
#[case::unrecorded_and_partial(None, TablesPresent::Partial, ShapeAction::Rebuild)]
#[case::diverged_and_partial(Some("2"), TablesPresent::Partial, ShapeAction::Rebuild)]
fn test_shape_action_new(
#[case] recorded: Option<&str>,
#[case] present: TablesPresent,
#[case] expected: ShapeAction,
) {
assert_eq!(ShapeAction::new(recorded, present, "1"), expected);
}
#[rstest]
#[case::build(ShapeAction::Build, false)]
#[case::rebuild(ShapeAction::Rebuild, true)]
fn test_build_tables_drops_named_tables_together(
#[case] action: ShapeAction,
#[case] drops: bool,
) {
let stmt = build_tables(
FOOTPRINT_SHAPES.record("runs"),
&["run_executions", "runs"],
"1",
SchemaPrefix(Some("test_meta")),
action,
"CREATE TABLE test_meta.runs ();",
)
.expect("a statement to run");
assert_eq!(
stmt.contains("DROP TABLE IF EXISTS test_meta.run_executions, test_meta.runs;"),
drops,
"{stmt}"
);
}
}