1use hexeract_outbox::OutboxError;
2
3use crate::validate::validate_table_name;
4
5const POSTGRES_SCHEMA_SQL: &str = r"
10CREATE TABLE IF NOT EXISTS {{table}} (
11 id BIGSERIAL PRIMARY KEY,
12 event_id UUID NOT NULL UNIQUE,
13 event_type VARCHAR(64) NOT NULL,
14 payload JSONB NOT NULL,
15 subject_id UUID NULL,
16 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
17 attempts INTEGER NOT NULL DEFAULT 0,
18 last_error TEXT NULL,
19 next_retry_at TIMESTAMPTZ NULL,
20 delivered_at TIMESTAMPTZ NULL
21);
22CREATE INDEX IF NOT EXISTS idx_{{table}}_pending
23 ON {{table}} (created_at)
24 WHERE delivered_at IS NULL;
25CREATE INDEX IF NOT EXISTS idx_{{table}}_subject
26 ON {{table}} (subject_id, id)
27 WHERE subject_id IS NOT NULL;
28";
29
30const MYSQL_SCHEMA_SQL: &str = r"
38CREATE TABLE IF NOT EXISTS {{table}} (
39 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
40 event_id BINARY(16) NOT NULL UNIQUE,
41 event_type VARCHAR(64) NOT NULL,
42 payload JSON NOT NULL,
43 subject_id BINARY(16) NULL,
44 created_at DATETIME(6) NOT NULL DEFAULT (UTC_TIMESTAMP(6)),
45 attempts INT NOT NULL DEFAULT 0,
46 last_error TEXT NULL,
47 next_retry_at DATETIME(6) NULL,
48 delivered_at DATETIME(6) NULL,
49 INDEX idx_{{table}}_pending (delivered_at, created_at),
50 INDEX idx_{{table}}_subject (subject_id, id)
51);
52";
53
54const POSTGRES_DLQ_SCHEMA_SQL: &str = r"
64CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
65 id BIGSERIAL PRIMARY KEY,
66 event_id UUID NOT NULL UNIQUE,
67 event_type VARCHAR(64) NOT NULL,
68 payload JSONB NOT NULL,
69 subject_id UUID NULL,
70 created_at TIMESTAMPTZ NOT NULL,
71 attempts INTEGER NOT NULL,
72 last_error TEXT NOT NULL,
73 exhausted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
74);
75CREATE INDEX IF NOT EXISTS idx_{{table}}_dead_letter_exhausted_at
76 ON {{table}}_dead_letter (exhausted_at);
77";
78
79const MYSQL_DLQ_SCHEMA_SQL: &str = r"
88CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
89 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
90 event_id BINARY(16) NOT NULL UNIQUE,
91 event_type VARCHAR(64) NOT NULL,
92 payload JSON NOT NULL,
93 subject_id BINARY(16) NULL,
94 created_at DATETIME(6) NOT NULL,
95 attempts INT NOT NULL,
96 last_error TEXT NOT NULL,
97 exhausted_at DATETIME(6) NOT NULL DEFAULT (UTC_TIMESTAMP(6)),
98 INDEX idx_{{table}}_dead_letter_exhausted_at (exhausted_at)
99);
100";
101
102const SQLITE_DLQ_SCHEMA_SQL: &str = r"
111CREATE TABLE IF NOT EXISTS {{table}}_dead_letter (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 event_id BLOB NOT NULL UNIQUE,
114 event_type TEXT NOT NULL,
115 payload TEXT NOT NULL,
116 subject_id BLOB,
117 created_at TEXT NOT NULL,
118 attempts INTEGER NOT NULL,
119 last_error TEXT NOT NULL,
120 exhausted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
121);
122CREATE INDEX IF NOT EXISTS idx_{{table}}_dead_letter_exhausted_at
123 ON {{table}}_dead_letter (exhausted_at);
124";
125
126const SQLITE_SCHEMA_SQL: &str = r"
132CREATE TABLE IF NOT EXISTS {{table}} (
133 id INTEGER PRIMARY KEY AUTOINCREMENT,
134 event_id BLOB NOT NULL UNIQUE,
135 event_type TEXT NOT NULL,
136 payload TEXT NOT NULL,
137 subject_id BLOB,
138 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
139 attempts INTEGER NOT NULL DEFAULT 0,
140 last_error TEXT,
141 next_retry_at TEXT,
142 delivered_at TEXT
143);
144CREATE INDEX IF NOT EXISTS idx_{{table}}_pending
145 ON {{table}} (created_at)
146 WHERE delivered_at IS NULL;
147CREATE INDEX IF NOT EXISTS idx_{{table}}_subject
148 ON {{table}} (subject_id, id)
149 WHERE subject_id IS NOT NULL;
150";
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum Dialect {
164 Postgres,
166 MySql,
168 Sqlite,
170}
171
172impl Dialect {
173 #[must_use]
178 pub fn supports_skip_locked(self) -> bool {
179 matches!(self, Self::Postgres | Self::MySql)
180 }
181
182 #[doc(hidden)]
190 #[must_use]
191 pub fn placeholder(self, index: usize) -> String {
192 match self {
193 Self::Postgres => format!("${index}"),
194 Self::MySql | Self::Sqlite => "?".to_owned(),
195 }
196 }
197
198 #[doc(hidden)]
214 #[must_use]
215 pub fn quote_identifier(self, name: &str) -> String {
216 match self {
217 Self::Postgres | Self::Sqlite => format!("\"{}\"", name.replace('"', "\"\"")),
218 Self::MySql => format!("`{}`", name.replace('`', "``")),
219 }
220 }
221
222 #[doc(hidden)]
228 #[must_use]
229 pub fn now_expr(self) -> &'static str {
230 match self {
231 Self::Postgres => "NOW()",
232 Self::MySql => "UTC_TIMESTAMP(6)",
236 Self::Sqlite => "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
237 }
238 }
239
240 #[doc(hidden)]
258 #[must_use]
259 pub fn now_plus_interval_expr(self, index: usize) -> String {
260 let ph = self.placeholder(index);
261 match self {
262 Self::Postgres => {
263 format!("(NOW() + (CAST({ph} AS DOUBLE PRECISION) * INTERVAL '1 second'))")
264 }
265 Self::MySql => format!("(UTC_TIMESTAMP(6) + INTERVAL {ph} MICROSECOND)"),
266 Self::Sqlite => format!("strftime('%Y-%m-%dT%H:%M:%fZ', 'now', {ph})"),
267 }
268 }
269
270 pub(crate) fn poll_sql(self, table: &str) -> String {
272 let qtable = self.quote_identifier(table);
273 let max_attempts = self.placeholder(1);
274 let limit = self.placeholder(2);
275 let now = self.now_expr();
276 let lock = if self.supports_skip_locked() {
277 " FOR UPDATE SKIP LOCKED"
278 } else {
279 ""
280 };
281 format!(
282 "SELECT event_id, event_type, payload, subject_id, created_at, \
283 attempts, last_error, next_retry_at \
284 FROM {qtable} \
285 WHERE delivered_at IS NULL \
286 AND attempts < {max_attempts} \
287 AND (next_retry_at IS NULL OR next_retry_at <= {now}) \
288 ORDER BY id \
289 LIMIT {limit}{lock}"
290 )
291 }
292
293 pub(crate) fn mark_delivered_sql(self, table: &str) -> String {
295 let qtable = self.quote_identifier(table);
296 let event_id = self.placeholder(1);
297 let now = self.now_expr();
298 format!("UPDATE {qtable} SET delivered_at = {now} WHERE event_id = {event_id}")
299 }
300
301 pub(crate) fn mark_failed_sql(self, table: &str) -> String {
312 let qtable = self.quote_identifier(table);
313 let last_error = self.placeholder(1);
314 let next_retry_at = self.now_plus_interval_expr(2);
315 let event_id = self.placeholder(3);
316 format!(
317 "UPDATE {qtable} \
318 SET last_error = {last_error}, next_retry_at = {next_retry_at} \
319 WHERE event_id = {event_id}"
320 )
321 }
322
323 pub(crate) fn insert_sql(self, table: &str) -> String {
325 let qtable = self.quote_identifier(table);
326 let p1 = self.placeholder(1);
327 let p2 = self.placeholder(2);
328 let p3 = self.placeholder(3);
329 let p4 = self.placeholder(4);
330 format!(
331 "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
332 VALUES ({p1}, {p2}, {p3}, {p4})"
333 )
334 }
335
336 pub(crate) fn insert_idempotent_sql(self, table: &str) -> String {
358 let qtable = self.quote_identifier(table);
359 let p1 = self.placeholder(1);
360 let p2 = self.placeholder(2);
361 let p3 = self.placeholder(3);
362 match self {
363 Self::Postgres | Self::Sqlite => {
364 format!(
365 "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
366 VALUES ({p1}, {p2}, {p3}, NULL) ON CONFLICT (event_id) DO NOTHING"
367 )
368 }
369 Self::MySql => {
370 let p4 = self.placeholder(4);
371 format!(
372 "INSERT INTO {qtable} (event_id, event_type, payload, subject_id) \
373 SELECT {p1}, {p2}, {p3}, NULL FROM DUAL \
374 WHERE NOT EXISTS (SELECT 1 FROM {qtable} WHERE event_id = {p4})"
375 )
376 }
377 }
378 }
379
380 pub fn schema_ddl(self, table: &str) -> Result<String, OutboxError> {
388 validate_table_name(table)?;
389 let template = match self {
390 Self::Postgres => POSTGRES_SCHEMA_SQL,
391 Self::MySql => MYSQL_SCHEMA_SQL,
392 Self::Sqlite => SQLITE_SCHEMA_SQL,
393 };
394 Ok(template.replace("{{table}}", table))
395 }
396
397 pub fn dead_letter_schema_ddl(self, table: &str) -> Result<String, OutboxError> {
408 validate_table_name(table)?;
409 let template = match self {
410 Self::Postgres => POSTGRES_DLQ_SCHEMA_SQL,
411 Self::MySql => MYSQL_DLQ_SCHEMA_SQL,
412 Self::Sqlite => SQLITE_DLQ_SCHEMA_SQL,
413 };
414 Ok(template.replace("{{table}}", table))
415 }
416
417 pub(crate) fn insert_dead_letter_sql(self, main: &str, dlq: &str) -> String {
423 let qmain = self.quote_identifier(main);
424 let qdlq = self.quote_identifier(dlq);
425 let event_id = self.placeholder(1);
426 format!(
427 "INSERT INTO {qdlq} \
428 (event_id, event_type, payload, subject_id, created_at, attempts, last_error) \
429 SELECT event_id, event_type, payload, subject_id, created_at, attempts, last_error \
430 FROM {qmain} \
431 WHERE event_id = {event_id}"
432 )
433 }
434
435 pub(crate) fn delete_from_main_sql(self, table: &str) -> String {
441 let qtable = self.quote_identifier(table);
442 let event_id = self.placeholder(1);
443 format!("DELETE FROM {qtable} WHERE event_id = {event_id}")
444 }
445
446 #[cfg_attr(
469 not(any(feature = "postgres", feature = "mysql", feature = "sqlite")),
470 allow(dead_code)
471 )]
472 pub(crate) fn claim_sql(self, table: &str, n: usize) -> String {
473 let qtable = self.quote_identifier(table);
474 let lease = self.now_plus_interval_expr(1);
475 match self {
476 Self::Postgres => {
477 format!(
480 "UPDATE {qtable} SET next_retry_at = {lease}, attempts = attempts + 1 \
481 WHERE event_id = ANY($2)"
482 )
483 }
484 Self::MySql | Self::Sqlite => {
485 let placeholders = (2..=n + 1)
486 .map(|i| self.placeholder(i))
487 .collect::<Vec<_>>()
488 .join(", ");
489 format!(
490 "UPDATE {qtable} SET next_retry_at = {lease}, attempts = attempts + 1 \
491 WHERE event_id IN ({placeholders})"
492 )
493 }
494 }
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[test]
503 fn skip_locked_support_matches_engine_capabilities() {
504 assert!(Dialect::Postgres.supports_skip_locked());
505 assert!(Dialect::MySql.supports_skip_locked());
506 assert!(!Dialect::Sqlite.supports_skip_locked());
507 }
508
509 #[test]
510 fn postgres_uses_positional_placeholders() {
511 assert_eq!(Dialect::Postgres.placeholder(1), "$1");
512 assert_eq!(Dialect::Postgres.placeholder(4), "$4");
513 }
514
515 #[test]
516 fn mysql_and_sqlite_use_question_mark_placeholders() {
517 assert_eq!(Dialect::MySql.placeholder(1), "?");
518 assert_eq!(Dialect::Sqlite.placeholder(3), "?");
519 }
520
521 #[test]
522 fn postgres_poll_sql_locks_rows_and_binds_positionally() {
523 let sql = Dialect::Postgres.poll_sql("audit_outbox");
524 assert!(sql.contains("FROM \"audit_outbox\""));
525 assert!(sql.contains("$1"));
526 assert!(sql.contains("$2"));
527 assert!(sql.contains("ORDER BY id"));
528 assert!(sql.contains("FOR UPDATE SKIP LOCKED"));
529 assert!(sql.contains("NOW()"));
530 }
531
532 #[test]
533 fn poll_sql_quotes_reserved_word_table_name() {
534 let sql = Dialect::Postgres.poll_sql("user");
537 assert!(sql.contains("FROM \"user\""));
538 }
539
540 #[test]
541 fn mysql_poll_sql_locks_rows_with_question_marks() {
542 let sql = Dialect::MySql.poll_sql("audit_outbox");
543 assert!(sql.contains("FROM `audit_outbox`"));
546 assert!(!sql.contains('"'));
547 assert!(sql.contains('?'));
548 assert!(sql.contains("FOR UPDATE SKIP LOCKED"));
549 }
550
551 #[test]
552 fn sqlite_poll_sql_omits_skip_locked() {
553 let sql = Dialect::Sqlite.poll_sql("audit_outbox");
554 assert!(sql.contains("FROM \"audit_outbox\""));
555 assert!(sql.contains('?'));
556 assert!(!sql.contains("FOR UPDATE SKIP LOCKED"));
557 assert!(sql.contains("strftime"));
558 }
559
560 #[test]
561 fn postgres_mark_delivered_sets_timestamp_by_event_id() {
562 let sql = Dialect::Postgres.mark_delivered_sql("audit_outbox");
563 assert!(sql.contains("UPDATE \"audit_outbox\""));
564 assert!(sql.contains("delivered_at"));
565 assert!(sql.contains("$1"));
566 }
567
568 #[test]
569 fn postgres_mark_failed_does_not_increment_attempts_with_three_binds() {
570 let sql = Dialect::Postgres.mark_failed_sql("audit_outbox");
571 assert!(!sql.contains("attempts = attempts + 1"));
575 assert!(sql.contains("last_error = $1"));
576 assert!(sql.contains("next_retry_at = (NOW() +"));
579 assert!(sql.contains("$2"));
580 assert!(sql.contains("WHERE event_id = $3"));
581 }
582
583 #[test]
584 fn postgres_insert_sql_binds_four_columns() {
585 let sql = Dialect::Postgres.insert_sql("audit_outbox");
586 assert!(sql.contains("INSERT INTO \"audit_outbox\""));
587 assert!(sql.contains("event_id, event_type, payload, subject_id"));
588 assert!(sql.contains("$1, $2, $3, $4"));
589 }
590
591 #[test]
592 fn sqlite_insert_sql_uses_question_marks() {
593 let sql = Dialect::Sqlite.insert_sql("audit_outbox");
594 assert!(sql.contains("INSERT INTO \"audit_outbox\""));
595 assert!(sql.contains("?, ?, ?, ?"));
596 }
597
598 #[test]
599 fn mysql_insert_sql_quotes_table_with_backticks() {
600 let sql = Dialect::MySql.insert_sql("audit_outbox");
604 assert!(sql.contains("INSERT INTO `audit_outbox`"));
605 assert!(!sql.contains('"'));
606 assert!(sql.contains("?, ?, ?, ?"));
607 }
608
609 #[test]
610 fn postgres_insert_idempotent_sql_uses_on_conflict_do_nothing() {
611 let sql = Dialect::Postgres.insert_idempotent_sql("audit_outbox");
612 assert!(sql.contains("INSERT INTO \"audit_outbox\""));
613 assert!(sql.contains("event_id, event_type, payload, subject_id"));
614 assert!(sql.contains("$1, $2, $3, NULL"));
615 assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
616 }
617
618 #[test]
619 fn sqlite_insert_idempotent_sql_uses_on_conflict_do_nothing() {
620 let sql = Dialect::Sqlite.insert_idempotent_sql("audit_outbox");
621 assert!(sql.contains("INSERT INTO \"audit_outbox\""));
622 assert!(sql.contains("?, ?, ?, NULL"));
623 assert!(sql.contains("ON CONFLICT (event_id) DO NOTHING"));
624 }
625
626 #[test]
627 fn mysql_insert_idempotent_sql_uses_where_not_exists_and_backticks() {
628 let sql = Dialect::MySql.insert_idempotent_sql("audit_outbox");
629 assert!(sql.contains("INSERT INTO `audit_outbox`"));
630 assert!(!sql.contains('"'));
631 assert!(sql.contains("FROM DUAL"));
633 assert!(sql.contains("WHERE NOT EXISTS"));
634 assert!(sql.contains("SELECT 1 FROM `audit_outbox` WHERE event_id = ?"));
635 assert!(!sql.contains("INSERT IGNORE"));
636 assert!(!sql.contains("ON DUPLICATE KEY"));
637 }
638
639 #[test]
640 fn quote_identifier_is_dialect_specific() {
641 assert_eq!(Dialect::Postgres.quote_identifier("t"), "\"t\"");
642 assert_eq!(Dialect::Sqlite.quote_identifier("t"), "\"t\"");
643 assert_eq!(Dialect::MySql.quote_identifier("t"), "`t`");
644 }
645
646 #[test]
647 fn quote_identifier_doubles_an_embedded_double_quote_for_postgres_and_sqlite() {
648 assert_eq!(Dialect::Postgres.quote_identifier("a\"b"), "\"a\"\"b\"");
653 assert_eq!(Dialect::Sqlite.quote_identifier("a\"b"), "\"a\"\"b\"");
654 }
655
656 #[test]
657 fn quote_identifier_doubles_an_embedded_backtick_for_mysql() {
658 assert_eq!(Dialect::MySql.quote_identifier("a`b"), "`a``b`");
660 }
661
662 #[test]
663 fn quote_identifier_is_unchanged_for_names_without_a_delimiter() {
664 assert_eq!(
668 Dialect::Postgres.quote_identifier("audit_outbox"),
669 "\"audit_outbox\""
670 );
671 assert_eq!(
672 Dialect::Sqlite.quote_identifier("audit_outbox"),
673 "\"audit_outbox\""
674 );
675 assert_eq!(
676 Dialect::MySql.quote_identifier("audit_outbox"),
677 "`audit_outbox`"
678 );
679 }
680
681 #[test]
682 fn postgres_schema_ddl_matches_current_canonical_schema() {
683 let ddl = Dialect::Postgres.schema_ddl("audit_outbox").unwrap();
684 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
685 assert!(ddl.contains("BIGSERIAL"));
686 assert!(ddl.contains("UUID"));
687 assert!(ddl.contains("JSONB"));
688 assert!(ddl.contains("TIMESTAMPTZ"));
689 assert!(ddl.contains("idx_audit_outbox_pending"));
690 assert!(ddl.contains("idx_audit_outbox_subject"));
691 assert!(!ddl.contains("{{table}}"));
692 }
693
694 #[test]
695 fn mysql_schema_ddl_uses_native_types() {
696 let ddl = Dialect::MySql.schema_ddl("audit_outbox").unwrap();
697 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
698 assert!(ddl.contains("AUTO_INCREMENT"));
699 assert!(ddl.contains("BINARY(16)"));
700 assert!(ddl.contains("JSON"));
701 assert!(!ddl.contains("{{table}}"));
702 }
703
704 #[test]
705 fn sqlite_schema_ddl_uses_portable_text_types() {
706 let ddl = Dialect::Sqlite.schema_ddl("audit_outbox").unwrap();
707 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox"));
708 assert!(ddl.contains("AUTOINCREMENT"));
709 assert!(ddl.contains("BLOB"));
710 assert!(ddl.contains("strftime"));
711 assert!(!ddl.contains("{{table}}"));
712 }
713
714 #[test]
715 fn schema_ddl_rejects_invalid_table_name() {
716 let err = Dialect::Postgres.schema_ddl("bad name; DROP").unwrap_err();
717 assert!(matches!(err, OutboxError::Internal(_)));
718 }
719
720 #[test]
721 fn mysql_poll_compares_against_microsecond_utc() {
722 let sql = Dialect::MySql.poll_sql("audit_outbox");
723 assert!(sql.contains("UTC_TIMESTAMP(6)"));
724 assert!(!sql.contains("UTC_TIMESTAMP()"));
725 }
726
727 #[test]
728 fn mysql_mark_delivered_uses_microsecond_utc() {
729 let sql = Dialect::MySql.mark_delivered_sql("audit_outbox");
730 assert!(sql.contains("delivered_at = UTC_TIMESTAMP(6)"));
731 }
732
733 #[test]
734 fn mysql_schema_ddl_defaults_created_at_to_utc() {
735 let ddl = Dialect::MySql.schema_ddl("audit_outbox").unwrap();
736 assert!(ddl.contains("UTC_TIMESTAMP(6)"));
737 }
738
739 #[test]
740 fn postgres_dead_letter_schema_ddl_substitutes_table_name() {
741 let ddl = Dialect::Postgres
742 .dead_letter_schema_ddl("audit_outbox")
743 .unwrap();
744 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
745 assert!(ddl.contains("exhausted_at"));
746 assert!(!ddl.contains("idx_audit_outbox_dead_letter_event_id"));
749 assert!(ddl.contains("idx_audit_outbox_dead_letter_exhausted_at"));
750 assert!(!ddl.contains("{{table}}"));
751 }
752
753 #[test]
754 fn mysql_dead_letter_schema_ddl_uses_native_types() {
755 let ddl = Dialect::MySql
756 .dead_letter_schema_ddl("audit_outbox")
757 .unwrap();
758 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
759 assert!(ddl.contains("BINARY(16)"));
760 assert!(ddl.contains("UTC_TIMESTAMP(6)"));
761 assert!(!ddl.contains("{{table}}"));
762 }
763
764 #[test]
765 fn sqlite_dead_letter_schema_ddl_uses_portable_text_types() {
766 let ddl = Dialect::Sqlite
767 .dead_letter_schema_ddl("audit_outbox")
768 .unwrap();
769 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS audit_outbox_dead_letter"));
770 assert!(ddl.contains("strftime"));
771 assert!(!ddl.contains("{{table}}"));
772 }
773
774 #[test]
775 fn dead_letter_schema_ddl_rejects_invalid_table_name() {
776 let err = Dialect::Postgres
777 .dead_letter_schema_ddl("bad name; DROP")
778 .unwrap_err();
779 assert!(matches!(err, OutboxError::Internal(_)));
780 }
781
782 #[test]
783 fn postgres_insert_dead_letter_sql_selects_from_main() {
784 let sql =
785 Dialect::Postgres.insert_dead_letter_sql("audit_outbox", "audit_outbox_dead_letter");
786 assert!(sql.contains("INSERT INTO \"audit_outbox_dead_letter\""));
787 assert!(sql.contains("SELECT"));
788 assert!(sql.contains("FROM \"audit_outbox\""));
789 assert!(sql.contains("$1"));
790 assert!(!sql.contains("exhausted_at"));
791 }
792
793 #[test]
794 fn sqlite_insert_dead_letter_sql_uses_question_mark() {
795 let sql = Dialect::Sqlite.insert_dead_letter_sql("audit_outbox", "audit_outbox_dlq");
796 assert!(sql.contains("INSERT INTO \"audit_outbox_dlq\""));
797 assert!(sql.contains("FROM \"audit_outbox\""));
798 assert!(sql.contains('?'));
799 }
800
801 #[test]
802 fn postgres_delete_from_main_sql_binds_positionally() {
803 let sql = Dialect::Postgres.delete_from_main_sql("audit_outbox");
804 assert!(sql.contains("DELETE FROM \"audit_outbox\""));
805 assert!(sql.contains("$1"));
806 }
807
808 #[test]
809 fn sqlite_delete_from_main_sql_uses_question_mark() {
810 let sql = Dialect::Sqlite.delete_from_main_sql("audit_outbox");
811 assert!(sql.contains("DELETE FROM \"audit_outbox\""));
812 assert!(sql.contains('?'));
813 }
814
815 #[test]
816 fn postgres_claim_sql_uses_any_array_instead_of_in_list() {
817 let sql = Dialect::Postgres.claim_sql("audit_outbox", 3);
820 assert!(sql.contains("UPDATE \"audit_outbox\""));
821 assert!(sql.contains("SET next_retry_at = (NOW() +"));
823 assert!(sql.contains("$1"));
824 assert!(sql.contains("WHERE event_id = ANY($2)"));
826 assert!(!sql.contains("$3"));
827 assert!(!sql.contains("$4"));
828 assert!(!sql.contains("WHERE event_id IN"));
829 }
830
831 #[test]
832 fn postgres_claim_sql_any_bind_count_is_independent_of_batch_size() {
833 for n in [1, 10, 1000] {
836 let sql = Dialect::Postgres.claim_sql("audit_outbox", n);
837 assert!(
838 sql.contains("ANY($2)"),
839 "n={n}: expected ANY($2), got: {sql}"
840 );
841 assert!(
842 !sql.contains("$3"),
843 "n={n}: unexpected $3 placeholder, got: {sql}"
844 );
845 }
846 }
847
848 #[test]
849 fn mysql_claim_sql_uses_question_marks() {
850 let sql = Dialect::MySql.claim_sql("audit_outbox", 2);
851 assert!(sql.contains("UPDATE `audit_outbox`"));
852 assert!(sql.contains("SET next_retry_at = (UTC_TIMESTAMP(6) + INTERVAL ? MICROSECOND)"));
853 assert!(sql.contains("WHERE event_id IN (?, ?)"));
854 }
855
856 #[test]
857 fn sqlite_claim_sql_uses_question_marks() {
858 let sql = Dialect::Sqlite.claim_sql("audit_outbox", 1);
859 assert!(sql.contains("UPDATE \"audit_outbox\""));
860 assert!(sql.contains("SET next_retry_at = strftime("));
861 assert!(sql.contains("'now', ?)"));
862 assert!(sql.contains("WHERE event_id IN (?)"));
863 }
864
865 #[test]
866 fn now_plus_interval_uses_db_clock_per_dialect() {
867 assert!(
870 Dialect::Postgres
871 .now_plus_interval_expr(1)
872 .contains("NOW()")
873 );
874 assert!(
875 Dialect::MySql
876 .now_plus_interval_expr(1)
877 .contains("UTC_TIMESTAMP(6)")
878 );
879 assert!(Dialect::Sqlite.now_plus_interval_expr(1).contains("'now'"));
880 }
881
882 #[test]
883 fn claim_sql_increments_attempts_for_every_dialect() {
884 for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
888 let sql = dialect.claim_sql("audit_outbox", 2);
889 assert!(
890 sql.contains("attempts = attempts + 1"),
891 "{dialect:?} claim_sql must increment attempts, got: {sql}"
892 );
893 }
894 }
895
896 #[test]
897 fn schema_ddl_rejects_overlength_table_name() {
898 let long_name = "a".repeat(64);
901 for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
902 let err = dialect.schema_ddl(&long_name).unwrap_err();
903 assert!(
904 matches!(err, OutboxError::Internal(_)),
905 "{dialect:?} must reject a 64-byte table name"
906 );
907 }
908 }
909
910 #[test]
911 fn dlq_schema_ddl_does_not_create_redundant_event_id_index() {
912 for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
915 let ddl = dialect.dead_letter_schema_ddl("audit_outbox").unwrap();
916 assert!(
917 !ddl.contains("dead_letter_event_id"),
918 "{dialect:?} DLQ DDL must not create a redundant event_id index, got:\n{ddl}"
919 );
920 }
921 }
922
923 #[test]
924 fn sql_generation_quotes_identifiers() {
925 for dialect in [Dialect::Postgres, Dialect::MySql, Dialect::Sqlite] {
930 let table = "user";
931 let quoted = dialect.quote_identifier(table);
932 assert!(
933 dialect.poll_sql(table).contains("ed),
934 "{dialect:?} poll_sql must quote the table name"
935 );
936 assert!(
937 dialect.mark_delivered_sql(table).contains("ed),
938 "{dialect:?} mark_delivered_sql must quote the table name"
939 );
940 assert!(
941 dialect.mark_failed_sql(table).contains("ed),
942 "{dialect:?} mark_failed_sql must quote the table name"
943 );
944 assert!(
945 dialect.insert_sql(table).contains("ed),
946 "{dialect:?} insert_sql must quote the table name"
947 );
948 assert!(
949 dialect.claim_sql(table, 1).contains("ed),
950 "{dialect:?} claim_sql must quote the table name"
951 );
952 assert!(
953 dialect.delete_from_main_sql(table).contains("ed),
954 "{dialect:?} delete_from_main_sql must quote the table name"
955 );
956 }
957 }
958}