use std::borrow::Cow;
use super::database_config::DatabaseType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Dialect {
Sqlite,
Postgres,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Migration {
pub name: &'static str,
pub sql: &'static str,
pub tolerates_existing_column: bool,
}
impl Dialect {
pub(super) fn of(database_type: DatabaseType) -> Option<Self> {
match database_type {
DatabaseType::Sqlite => Some(Self::Sqlite),
DatabaseType::Postgres => Some(Self::Postgres),
DatabaseType::Mysql => None,
}
}
pub(super) fn bind_params<'a>(self, sql: &'a str) -> Cow<'a, str> {
match self {
Self::Sqlite => Cow::Borrowed(sql),
Self::Postgres => {
let mut out = String::with_capacity(sql.len() + 8);
let mut next = 1;
for ch in sql.chars() {
if ch == '?' {
out.push('$');
out.push_str(&next.to_string());
next += 1;
} else {
out.push(ch);
}
}
Cow::Owned(out)
}
}
}
pub(super) fn insert_context_if_absent(self) -> &'static str {
match self {
Self::Sqlite => "INSERT OR IGNORE INTO contexts (id, owner) VALUES (?, ?)",
Self::Postgres => {
"INSERT INTO contexts (id, owner) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING"
}
}
}
pub(super) fn upsert_push_config(self) -> &'static str {
match self {
Self::Sqlite => {
"INSERT OR REPLACE INTO push_notification_configs \
(id, task_id, url, token, authentication) VALUES (?, ?, ?, ?, ?)"
}
Self::Postgres => {
"INSERT INTO push_notification_configs \
(id, task_id, url, token, authentication) VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (id) DO UPDATE SET \
task_id = EXCLUDED.task_id, url = EXCLUDED.url, token = EXCLUDED.token, \
authentication = EXCLUDED.authentication"
}
}
}
pub(super) fn upsert_context_state(self) -> &'static str {
match self {
Self::Sqlite => {
"INSERT INTO context_state (scope, scope_key, name, value, updated_at) \
VALUES (?, ?, ?, ?, datetime('now')) \
ON CONFLICT (scope, scope_key, name) DO UPDATE SET \
value = excluded.value, updated_at = datetime('now')"
}
Self::Postgres => {
"INSERT INTO context_state (scope, scope_key, name, value, updated_at) \
VALUES ($1, $2, $3, $4, now()) \
ON CONFLICT (scope, scope_key, name) DO UPDATE SET \
value = EXCLUDED.value, updated_at = now()"
}
}
}
pub(super) fn refresh_user_state(self) -> &'static str {
match self {
Self::Sqlite => {
"UPDATE context_state SET updated_at = datetime('now') \
WHERE scope = 'user' AND scope_key = ? AND updated_at <= ?"
}
Self::Postgres => {
"UPDATE context_state SET updated_at = now() \
WHERE scope = 'user' AND scope_key = $1 AND updated_at <= $2::timestamptz"
}
}
}
pub(super) fn insert_task_event(self) -> &'static str {
match self {
Self::Sqlite => {
"INSERT INTO task_events (task_id, id, kind, payload) SELECT ?, COALESCE(MAX(id), 0) + 1, ?, ? FROM task_events WHERE task_id = ? RETURNING id"
}
Self::Postgres => {
"INSERT INTO task_events (task_id, id, kind, payload) SELECT $1, COALESCE(MAX(id), 0) + 1, $2, $3 FROM task_events WHERE task_id = $4 RETURNING id"
}
}
}
pub(super) fn dead_context_state_column_probe(self) -> &'static str {
match self {
Self::Sqlite => {
"SELECT 1 AS found FROM pragma_table_info('contexts') WHERE name = 'state'"
}
Self::Postgres => {
"SELECT 1 AS found FROM information_schema.columns \
WHERE table_name = 'contexts' AND column_name = 'state'"
}
}
}
pub(super) fn updated_since_predicate(self) -> &'static str {
match self {
Self::Sqlite => "updated_at >= ?",
Self::Postgres => "updated_at >= ?::timestamptz",
}
}
pub(super) fn format_timestamp(self, at: chrono::DateTime<chrono::Utc>) -> String {
match self {
Self::Sqlite => at.format("%Y-%m-%d %H:%M:%S").to_string(),
Self::Postgres => at.to_rfc3339(),
}
}
pub(super) fn idle_contexts(self) -> &'static str {
match self {
Self::Sqlite => concat!(
"SELECT ctx FROM (",
"SELECT id AS ctx, updated_at AS last_write FROM contexts",
" UNION ALL SELECT context_id, updated_at FROM tasks",
" UNION ALL SELECT context_id, \"timestamp\" FROM task_history",
" WHERE context_id IS NOT NULL",
" UNION ALL SELECT context_id, created_at FROM context_digests",
" UNION ALL SELECT scope_key, updated_at FROM context_state",
" WHERE scope = 'context'",
") AS activity GROUP BY ctx HAVING MAX(last_write) < ?",
" EXCEPT SELECT context_id FROM tasks",
" WHERE status_state IN ('submitted', 'working', 'unknown')",
),
Self::Postgres => concat!(
"SELECT ctx FROM (",
"SELECT id AS ctx, updated_at AS last_write FROM contexts",
" UNION ALL SELECT context_id, updated_at FROM tasks",
" UNION ALL SELECT context_id, \"timestamp\" FROM task_history",
" WHERE context_id IS NOT NULL",
" UNION ALL SELECT context_id, created_at FROM context_digests",
" UNION ALL SELECT scope_key, updated_at FROM context_state",
" WHERE scope = 'context'",
") AS activity GROUP BY ctx HAVING MAX(last_write) < $1::timestamptz",
" EXCEPT SELECT context_id FROM tasks",
" WHERE status_state IN ('submitted', 'working', 'unknown')",
),
}
}
pub(super) fn idle_principals(self) -> &'static str {
match self {
Self::Sqlite => {
"SELECT scope_key FROM context_state WHERE scope = 'user' GROUP BY scope_key HAVING MAX(updated_at) < ?"
}
Self::Postgres => {
"SELECT scope_key FROM context_state WHERE scope = 'user' GROUP BY scope_key HAVING MAX(updated_at) < $1::timestamptz"
}
}
}
pub(super) fn migration_lock(self) -> Option<&'static str> {
match self {
Self::Sqlite => None,
Self::Postgres => Some("SELECT pg_advisory_lock(7723510643218)"),
}
}
pub(super) fn legacy_push_config_probe(self) -> &'static str {
match self {
Self::Sqlite => {
"SELECT 1 AS found FROM pragma_table_info('push_notification_configs') \
WHERE name = 'webhook_url'"
}
Self::Postgres => {
"SELECT 1 AS found FROM information_schema.columns \
WHERE table_name = 'push_notification_configs' AND column_name = 'webhook_url'"
}
}
}
pub(super) fn migrations(self) -> [Migration; 7] {
match self {
Self::Sqlite => [
Migration {
name: "001_initial_schema",
sql: include_str!("../../../migrations/sqlite/001_initial_schema.sql"),
tolerates_existing_column: false,
},
Migration {
name: "002_v030_push_configs",
sql: include_str!("../../../migrations/sqlite/002_v030_push_configs.sql"),
tolerates_existing_column: false,
},
Migration {
name: "003_task_version",
sql: include_str!("../../../migrations/sqlite/003_task_version.sql"),
tolerates_existing_column: true,
},
Migration {
name: "004_task_history_context",
sql: include_str!("../../../migrations/sqlite/004_task_history_context.sql"),
tolerates_existing_column: true,
},
Migration {
name: "005_context_memory",
sql: include_str!("../../../migrations/sqlite/005_context_memory.sql"),
tolerates_existing_column: false,
},
Migration {
name: "006_context_state",
sql: include_str!("../../../migrations/sqlite/006_context_state.sql"),
tolerates_existing_column: false,
},
Migration {
name: "007_task_events",
sql: include_str!("../../../migrations/sqlite/007_task_events.sql"),
tolerates_existing_column: false,
},
],
Self::Postgres => [
Migration {
name: "001_initial_schema",
sql: include_str!("../../../migrations/postgres/001_initial_schema.sql"),
tolerates_existing_column: false,
},
Migration {
name: "002_v030_push_configs",
sql: include_str!("../../../migrations/postgres/002_v030_push_configs.sql"),
tolerates_existing_column: false,
},
Migration {
name: "003_task_version",
sql: include_str!("../../../migrations/postgres/003_task_version.sql"),
tolerates_existing_column: false,
},
Migration {
name: "004_task_history_context",
sql: include_str!("../../../migrations/postgres/004_task_history_context.sql"),
tolerates_existing_column: false,
},
Migration {
name: "005_context_memory",
sql: include_str!("../../../migrations/postgres/005_context_memory.sql"),
tolerates_existing_column: false,
},
Migration {
name: "006_context_state",
sql: include_str!("../../../migrations/postgres/006_context_state.sql"),
tolerates_existing_column: false,
},
Migration {
name: "007_task_events",
sql: include_str!("../../../migrations/postgres/007_task_events.sql"),
tolerates_existing_column: false,
},
],
}
}
}
pub(super) fn is_concurrent_ddl_conflict(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(|db| db.code())
.is_some_and(|code| {
matches!(
&*code,
"23505" | "42P07" | "42P06" | "42710" | "42P16"
| "40P01" | "40001"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sqlite_keeps_its_own_placeholders() {
let sql = "SELECT id FROM tasks WHERE id = ? AND version = ?";
assert_eq!(Dialect::Sqlite.bind_params(sql), sql);
}
#[test]
fn postgres_placeholders_are_numbered_in_order() {
assert_eq!(
Dialect::Postgres.bind_params("SELECT id FROM tasks WHERE id = ? AND version = ?"),
"SELECT id FROM tasks WHERE id = $1 AND version = $2"
);
}
#[test]
fn placeholders_inside_a_subquery_are_numbered_with_the_rest() {
assert_eq!(
Dialect::Postgres.bind_params(
"INSERT INTO task_history (task_id, context_id, status_state) \
VALUES (?, (SELECT context_id FROM tasks WHERE id = ?), ?)"
),
"INSERT INTO task_history (task_id, context_id, status_state) \
VALUES ($1, (SELECT context_id FROM tasks WHERE id = $2), $3)"
);
}
#[test]
fn a_cast_survives_the_rewrite() {
assert_eq!(
Dialect::Postgres.bind_params(Dialect::Postgres.updated_since_predicate()),
"updated_at >= $1::timestamptz"
);
}
#[test]
fn the_retention_queries_bind_one_cutoff_each() {
for query in [
Dialect::Sqlite.idle_contexts(),
Dialect::Sqlite.idle_principals(),
] {
assert_eq!(query.matches('?').count(), 1, "{query}");
}
for query in [
Dialect::Postgres.idle_contexts(),
Dialect::Postgres.idle_principals(),
] {
assert_eq!(query.matches('?').count(), 0, "{query}");
assert_eq!(query.matches("$1::timestamptz").count(), 1, "{query}");
}
}
#[test]
fn the_read_refresh_binds_the_principal_and_the_cutoff() {
let sqlite = Dialect::Sqlite.refresh_user_state();
assert_eq!(sqlite.matches('?').count(), 2, "{sqlite}");
let postgres = Dialect::Postgres.refresh_user_state();
assert_eq!(postgres.matches('?').count(), 0, "{postgres}");
assert_eq!(postgres.matches("$1").count(), 1, "{postgres}");
assert_eq!(postgres.matches("$2::timestamptz").count(), 1, "{postgres}");
for query in [sqlite, postgres] {
assert!(query.contains("updated_at <="), "{query}");
assert!(query.contains("scope = 'user'"), "{query}");
}
}
#[test]
fn only_unfinished_states_hold_a_context_back() {
for dialect in [Dialect::Sqlite, Dialect::Postgres] {
let query = dialect.idle_contexts();
assert!(
query.contains("'submitted', 'working', 'unknown'"),
"{query}"
);
assert!(!query.contains("input-required"), "{query}");
}
}
#[test]
fn mysql_has_no_dialect() {
assert_eq!(Dialect::of(DatabaseType::Mysql), None);
}
}