Skip to main content

ruststream_sqlx_dialect/
outbox.rs

1//! The statements of a transactional outbox table: the take of a record by its id, the mark of a
2//! processed one, and the recovery of the unprocessed records of a name.
3
4use crate::dialect::Dialect;
5use crate::spec::TableSpec;
6use crate::statement::{Statement, StatementError};
7
8/// The statements a dialect builds for an outbox table, which records what a service publishes
9/// until a consumer has processed it.
10///
11/// An outbox table is described by a [`TableSpec`] whose id identifies a record, whose
12/// [`group`](TableSpec::group) column holds the name the record was published under, and whose
13/// optional [`processed_at`](TableSpec::processed_at) column marks a processed record. A table
14/// without it deletes a processed record instead. The table's [`Form`](crate::Form) does not
15/// apply: a record is taken by its id, never claimed. `#[derive(Outbox)]` in `ruststream-sqlx`
16/// builds these statements at compile time with each built-in dialect, and on the database's own
17/// clock ([`TableSpec::database_clock`]). [`Postgres`](crate::Postgres), [`MySql`](crate::MySql)
18/// and [`Sqlite`](crate::Sqlite) implement it.
19///
20/// # Examples
21///
22/// ```
23/// # #[cfg(feature = "postgres")]
24/// # fn main() -> Result<(), ruststream_sqlx_dialect::StatementError> {
25/// use ruststream_sqlx_dialect::{Column, Form, OutboxDialect, Param, Postgres, TableSpec};
26///
27/// // outbox: id BIGSERIAL PRIMARY KEY, name TEXT, payload BYTEA, processed_at TIMESTAMPTZ
28/// const OUTBOX: TableSpec<'static> = TableSpec::new("outbox", Column::new("id"), Form::RowLock)
29///     .group(Column::new("name"))
30///     .processed_at(Column::new("processed_at"))
31///     .payload(Column::new("payload"))
32///     .database_clock();
33///
34/// // What a consumer of a tracked message runs: the take of its record, then the mark once the
35/// // handler acknowledged it.
36/// let fetch = Postgres.outbox_fetch(&OUTBOX)?;
37/// assert_eq!(
38///     fetch.sql(),
39///     r#"SELECT "id", "name", "processed_at", "payload" FROM "outbox" WHERE "id" = $1 AND "processed_at" IS NULL"#
40/// );
41/// let mark = Postgres.outbox_mark(&OUTBOX)?;
42/// assert_eq!(
43///     mark.sql(),
44///     r#"UPDATE "outbox" SET "processed_at" = statement_timestamp() WHERE "id" = $1"#
45/// );
46///
47/// // What the republish at startup runs for each registered name.
48/// let recover = Postgres.outbox_recover(&OUTBOX)?;
49/// assert_eq!(recover.params(), [Param::Group]);
50/// # Ok(())
51/// # }
52/// # #[cfg(not(feature = "postgres"))]
53/// # fn main() {}
54/// ```
55pub trait OutboxDialect: Dialect {
56    /// The statement that reads the record [`Param::Id`](crate::Param::Id) names while it is
57    /// unprocessed, with every column: what the consumer of a tracked message takes into work.
58    ///
59    /// # Errors
60    ///
61    /// [`StatementError::IdentifierTooLong`] for a name longer than the database keeps.
62    ///
63    /// # Examples
64    ///
65    /// ```
66    /// # #[cfg(feature = "mysql")]
67    /// # fn main() -> Result<(), ruststream_sqlx_dialect::StatementError> {
68    /// use ruststream_sqlx_dialect::{Column, Form, MySql, OutboxDialect, Param, TableSpec};
69    ///
70    /// // A table without `processed_at` deletes a processed record, so every record it holds is
71    /// // unprocessed.
72    /// const OUTBOX: TableSpec<'static> = TableSpec::new("outbox", Column::new("id"), Form::RowLock)
73    ///     .group(Column::new("name"))
74    ///     .payload(Column::new("payload"));
75    ///
76    /// let fetch = MySql.outbox_fetch(&OUTBOX)?;
77    /// assert_eq!(fetch.sql(), "SELECT `id`, `name`, `payload` FROM `outbox` WHERE `id` = ?");
78    /// assert_eq!(fetch.params(), [Param::Id]);
79    /// # Ok(())
80    /// # }
81    /// # #[cfg(not(feature = "mysql"))]
82    /// # fn main() {}
83    /// ```
84    fn outbox_fetch(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError>;
85
86    /// The statement that marks the record [`Param::Id`](crate::Param::Id) names processed: it
87    /// sets `processed_at` to now, or deletes the record in a table without that column.
88    ///
89    /// Where the table runs in the row lock form, this is the dialect's
90    /// [`ack`](Dialect::ack) of it.
91    ///
92    /// # Errors
93    ///
94    /// [`StatementError::IdentifierTooLong`] for a name longer than the database keeps.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// # #[cfg(feature = "sqlite")]
100    /// # fn main() -> Result<(), ruststream_sqlx_dialect::StatementError> {
101    /// use ruststream_sqlx_dialect::{Column, Form, OutboxDialect, Sqlite, TableSpec};
102    ///
103    /// const OUTBOX: TableSpec<'static> = TableSpec::new("outbox", Column::new("id"), Form::RowLock)
104    ///     .group(Column::new("name"))
105    ///     .processed_at(Column::new("processed_at"))
106    ///     .payload(Column::new("payload"))
107    ///     .database_clock();
108    ///
109    /// // The mark reads SQLite's own clock, as text that sorts as the times do.
110    /// let mark = Sqlite.outbox_mark(&OUTBOX)?;
111    /// assert_eq!(
112    ///     mark.sql(),
113    ///     "UPDATE `outbox` SET `processed_at` = strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') \
114    ///      WHERE `id` = ?"
115    /// );
116    /// # Ok(())
117    /// # }
118    /// # #[cfg(not(feature = "sqlite"))]
119    /// # fn main() {}
120    /// ```
121    fn outbox_mark(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError>;
122
123    /// The statement that reads the unprocessed records published under the name
124    /// [`Param::Group`](crate::Param::Group) binds, with every column: what the republish at
125    /// startup sends again.
126    ///
127    /// # Errors
128    ///
129    /// [`StatementError::MissingRole`] for a table without a [`group`](TableSpec::group) column,
130    /// and [`StatementError::IdentifierTooLong`] for a name longer than the database keeps.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// # #[cfg(feature = "postgres")]
136    /// # fn main() -> Result<(), ruststream_sqlx_dialect::StatementError> {
137    /// use ruststream_sqlx_dialect::{Column, Form, OutboxDialect, Param, Postgres, TableSpec};
138    ///
139    /// const OUTBOX: TableSpec<'static> = TableSpec::new("outbox", Column::new("id"), Form::RowLock)
140    ///     .within("app")
141    ///     .group(Column::new("name"))
142    ///     .processed_at(Column::new("processed_at"))
143    ///     .payload(Column::new("payload"));
144    ///
145    /// let recover = Postgres.outbox_recover(&OUTBOX)?;
146    /// assert_eq!(
147    ///     recover.sql(),
148    ///     r#"SELECT "id", "name", "processed_at", "payload" FROM "app"."outbox" WHERE "name" = $1 AND "processed_at" IS NULL"#
149    /// );
150    /// assert_eq!(recover.params(), [Param::Group]);
151    /// # Ok(())
152    /// # }
153    /// # #[cfg(not(feature = "postgres"))]
154    /// # fn main() {}
155    /// ```
156    fn outbox_recover(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError>;
157}