Skip to main content

ruststream_sqlx_dialect/statement/
error.rs

1//! Why a dialect cannot build a statement, and the longest name a database keeps.
2
3use std::fmt::{self, Display, Formatter};
4
5use thiserror::Error;
6
7use crate::role::Role;
8
9/// The longest name a database keeps, in the unit it measures names by.
10///
11/// Postgres measures a name in bytes, MySQL and MariaDB in characters, so a name of accented
12/// letters can fit one database and not the other.
13///
14/// # Examples
15///
16/// ```
17/// # use std::num::NonZeroUsize;
18/// use ruststream_sqlx_dialect::{
19///     Dialect, NameLimit, Param, Statement, StatementError, TableSpec,
20/// };
21/// # use ruststream_sqlx_dialect::TableName;
22///
23/// /// SQL Server, a database without a built-in dialect.
24/// #[derive(Debug)]
25/// pub struct Mssql;
26///
27/// impl Mssql {
28///     // SQL Server keeps a name of up to 128 characters.
29///     fn named<'n>(&self, name: &'n str) -> Result<&'n str, StatementError> {
30///         if name.chars().count() <= 128 {
31///             return Ok(name);
32///         }
33///         Err(StatementError::IdentifierTooLong {
34///             dialect: self.name(),
35///             identifier: name.to_owned(),
36///             limit: NameLimit::Characters(128),
37///         })
38///     }
39/// }
40///
41/// impl Dialect for Mssql {
42///     fn name(&self) -> &'static str {
43///         "mssql"
44///     }
45///
46///     fn ack(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
47///         let mut sql = String::from("DELETE FROM ");
48///         self.quote_into(self.named(spec.table())?, &mut sql);
49///         sql.push_str(" WHERE ");
50///         self.quote_into(self.named(spec.id().name())?, &mut sql);
51///         sql.push_str(" = @p1");
52///         Ok(Statement::new(sql, [Param::Id]))
53///     }
54/// #     fn quote_into(&self, ident: &str, out: &mut String) { out.push('['); out.push_str(&ident.replace(']', "]]")); out.push(']'); }
55/// #     fn placeholder_into(&self, index: NonZeroUsize, out: &mut String) { out.push_str("@p"); out.push_str(&index.to_string()); }
56/// #     fn fetch(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedFetch { dialect: self.name() }) }
57/// #     fn retry(&self, spec: &TableSpec<'_>) -> Result<Option<Statement>, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
58/// #     fn retry_after(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
59/// #     fn discard(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
60/// #     fn dead_letter_group(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
61/// #     fn dead_letter_table(&self, spec: &TableSpec<'_>, target: TableName<'_>) -> Result<Vec<Statement>, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
62/// #     fn insert(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
63/// }
64/// ```
65// A `u16` holds every database's limit and keeps `StatementError` at 56 bytes: a `usize` would
66// grow it, and every error that carries it, by eight.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum NameLimit {
69    /// At most this many bytes of UTF-8.
70    Bytes(u16),
71    /// At most this many characters.
72    Characters(u16),
73}
74
75impl Display for NameLimit {
76    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
77        match self {
78            Self::Bytes(limit) => write!(f, "{limit} bytes"),
79            Self::Characters(limit) => write!(f, "{limit} characters"),
80        }
81    }
82}
83
84/// Why a dialect cannot build a statement for a table.
85///
86/// A broker builds a subscription's statements when the subscription starts, so a refusal stops
87/// it before it claims a row, and the message names the change to make.
88///
89/// # Examples
90///
91/// A dialect of the service's own refuses a table its claim does not serve:
92///
93/// ```
94/// # use std::num::NonZeroUsize;
95/// use ruststream_sqlx_dialect::{
96///     ClaimShape, Dialect, Isolation, Opening, Param, RowLock, Statement, StatementError,
97///     TableSpec,
98/// };
99/// # use ruststream_sqlx_dialect::TableName;
100///
101/// /// SQL Server, a database without a built-in dialect.
102/// #[derive(Debug)]
103/// pub struct Mssql;
104///
105/// impl RowLock for Mssql {
106///     // The claim of the service's one queue table, which it reads whole.
107///     fn lock_claim(
108///         &self,
109///         spec: &TableSpec<'_>,
110///         _shape: ClaimShape,
111///     ) -> Result<Statement, StatementError> {
112///         // This claim keeps no group in order.
113///         if spec.is_fifo() {
114///             return Err(StatementError::UnsupportedFifo { dialect: self.name() });
115///         }
116///
117///         // READPAST, which skips the rows another claim holds, refuses SERIALIZABLE.
118///         let opening = spec.opening();
119///         if opening == Opening::Isolation(Isolation::Serializable) {
120///             return Err(StatementError::UnsupportedOpening {
121///                 dialect: self.name(),
122///                 opening: opening.name(),
123///             });
124///         }
125///         Ok(Statement::new(
126///             "SELECT TOP (@p1) [job_id], [payload] FROM [email_jobs] \
127///              WITH (UPDLOCK, READPAST) ORDER BY [job_id]",
128///             [Param::Limit],
129///         ))
130///     }
131/// }
132/// # impl Dialect for Mssql {
133/// #     fn name(&self) -> &'static str { "mssql" }
134/// #     fn quote_into(&self, ident: &str, out: &mut String) { out.push('['); out.push_str(&ident.replace(']', "]]")); out.push(']'); }
135/// #     fn placeholder_into(&self, index: NonZeroUsize, out: &mut String) { out.push_str("@p"); out.push_str(&index.to_string()); }
136/// #     fn fetch(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedFetch { dialect: self.name() }) }
137/// #     fn ack(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
138/// #     fn retry(&self, spec: &TableSpec<'_>) -> Result<Option<Statement>, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
139/// #     fn retry_after(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
140/// #     fn discard(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
141/// #     fn dead_letter_group(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
142/// #     fn dead_letter_table(&self, spec: &TableSpec<'_>, target: TableName<'_>) -> Result<Vec<Statement>, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
143/// #     fn insert(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> { Err(StatementError::UnsupportedForm { dialect: self.name(), form: spec.form().name() }) }
144/// # }
145/// ```
146#[derive(Debug, Clone, PartialEq, Eq, Error)]
147#[non_exhaustive]
148pub enum StatementError {
149    /// The statement needs a column that plays `role`, and the table has none.
150    #[error(
151        "the {statement} statement needs a column playing `{role}`: add `#[field({role})]` to \
152         the struct"
153    )]
154    MissingRole {
155        /// The statement being built.
156        statement: &'static str,
157        /// The role it needs.
158        role: Role,
159    },
160    /// The dialect has no statements for the table's form.
161    #[error("the {dialect} dialect has no statements for the {form} form")]
162    UnsupportedForm {
163        /// The dialect's name.
164        dialect: &'static str,
165        /// The name of the table's form.
166        form: &'static str,
167    },
168    /// The statement belongs to one form, and the table takes rows in another: the row lock
169    /// claim asked for a lease table, a lease statement for a table without a lease, or an
170    /// advisory statement for a table without a lock key.
171    #[error("the {statement} statement does not serve a table in the {form} form")]
172    FormMismatch {
173        /// The statement being built.
174        statement: &'static str,
175        /// The name of the table's form.
176        form: &'static str,
177    },
178    /// The dialect has no claim that keeps a group in order. A dialect of the service's own
179    /// returns it from a claim of a table with FIFO groups when it builds no claim for them.
180    #[error("the {dialect} dialect has no claim for FIFO groups")]
181    UnsupportedFifo {
182        /// The dialect's name.
183        dialect: &'static str,
184    },
185    /// The table takes its rows by advisory lock and keeps its groups in order. In that form a
186    /// lock key on the group's column keeps a group in order, so the advisory claim and the take
187    /// refuse FIFO groups.
188    #[error(
189        "FIFO groups do not combine with the advisory lock form in the {dialect} dialect: drop \
190         `fifo` and put the group's column into the lock key"
191    )]
192    AdvisoryFifo {
193        /// The dialect's name.
194        dialect: &'static str,
195    },
196    /// A table, schema or column name is longer than the database allows. Postgres would cut it
197    /// short without a word and address another object; MySQL would refuse the statement.
198    #[error("`{identifier}` is longer than the {limit} the {dialect} dialect allows in a name")]
199    IdentifierTooLong {
200        /// The dialect's name.
201        dialect: &'static str,
202        /// The name that is too long.
203        identifier: String,
204        /// The longest name the database keeps, in the unit it measures names by.
205        limit: NameLimit,
206    },
207    /// The statement needs every column, and the struct flattens another whose columns the
208    /// description cannot see.
209    #[error(
210        "the {statement} statement needs every column, and a `#[sqlx(flatten)]` field hides some: \
211         write it in the service"
212    )]
213    Flattened {
214        /// The statement being built.
215        statement: &'static str,
216    },
217    /// The server is older than the dialect's statements need, as
218    /// [`Dialect::check_server`](crate::Dialect::check_server) found when the subscription
219    /// started.
220    #[error(
221        "the {dialect} dialect needs {required} or later for this form; the server reports \
222         `{server}`"
223    )]
224    ServerTooOld {
225        /// The dialect's name.
226        dialect: &'static str,
227        /// The version the server reports.
228        server: String,
229        /// The oldest server the dialect's statements run on.
230        required: &'static str,
231    },
232    /// The dialect cannot read rows by a list of ids, so a claim of the service's own needs a
233    /// fetch of its own beside it.
234    #[error(
235        "the {dialect} dialect cannot read rows by a list of ids: list `fetch` in `custom(..)` \
236         beside `claim`"
237    )]
238    UnsupportedFetch {
239        /// The dialect's name.
240        dialect: &'static str,
241    },
242    /// The table declares a lease and reads the database's clock. Settlement matches the expiry
243    /// the claim wrote, so the claim computes it from the crate's clock.
244    #[error(
245        "the lease form computes its expiry from the crate's clock: a table on `DatabaseClock` \
246         cannot declare `locked_until`"
247    )]
248    LeaseOnDatabaseClock {
249        /// The dialect's name.
250        dialect: &'static str,
251    },
252    /// The dialect opens no transaction at the table's isolation level or mode, as
253    /// [`Dialect::begin`](crate::Dialect::begin) found.
254    #[error("the {dialect} dialect opens no transaction at {opening}")]
255    UnsupportedOpening {
256        /// The dialect's name.
257        dialect: &'static str,
258        /// The opening, as [`Opening::name`](crate::Opening::name) names it.
259        opening: &'static str,
260    },
261    /// The table keeps its groups in order, takes its rows by row lock and opens its claims at
262    /// SERIALIZABLE, a level at which the dialect's [`fifo_guard`](crate::Dialect::fifo_guard)
263    /// would wait for the group's row in work instead of answering at once.
264    #[error(
265        "the {dialect} dialect claims FIFO groups below SERIALIZABLE: InnoDB turns every read of a \
266         SERIALIZABLE transaction into a locking read, so a claim would wait for the group's row in \
267         work; declare `isolation = repeatable_read` or a lower level"
268    )]
269    FifoAtSerializable {
270        /// The dialect's name.
271        dialect: &'static str,
272    },
273}
274
275#[cfg(test)]
276mod tests {
277    use super::{NameLimit, StatementError};
278    use crate::role::Role;
279
280    #[test]
281    fn errors_name_the_missing_piece() {
282        assert_eq!(
283            StatementError::UnsupportedForm {
284                dialect: "postgres",
285                form: "advisory lock",
286            }
287            .to_string(),
288            "the postgres dialect has no statements for the advisory lock form"
289        );
290        assert_eq!(
291            StatementError::UnsupportedFifo {
292                dialect: "postgres"
293            }
294            .to_string(),
295            "the postgres dialect has no claim for FIFO groups"
296        );
297        assert_eq!(
298            StatementError::MissingRole {
299                statement: "dead_letter_group",
300                role: Role::Group,
301            }
302            .to_string(),
303            "the dead_letter_group statement needs a column playing `group`: add `#[field(group)]` \
304             to the struct"
305        );
306    }
307
308    #[test]
309    fn the_new_errors_name_what_to_fix() {
310        assert_eq!(
311            StatementError::IdentifierTooLong {
312                dialect: "postgres",
313                identifier: "jobs".to_owned(),
314                limit: NameLimit::Bytes(63),
315            }
316            .to_string(),
317            "`jobs` is longer than the 63 bytes the postgres dialect allows in a name"
318        );
319        assert_eq!(
320            StatementError::IdentifierTooLong {
321                dialect: "mysql",
322                identifier: "jobs".to_owned(),
323                limit: NameLimit::Characters(64),
324            }
325            .to_string(),
326            "`jobs` is longer than the 64 characters the mysql dialect allows in a name"
327        );
328        assert_eq!(
329            StatementError::Flattened {
330                statement: "insert"
331            }
332            .to_string(),
333            "the insert statement needs every column, and a `#[sqlx(flatten)]` field hides some: \
334             write it in the service"
335        );
336        assert_eq!(
337            StatementError::FormMismatch {
338                statement: "extend",
339                form: "row lock",
340            }
341            .to_string(),
342            "the extend statement does not serve a table in the row lock form"
343        );
344        assert_eq!(
345            StatementError::UnsupportedOpening {
346                dialect: "postgres",
347                opening: "isolation `read_uncommitted`",
348            }
349            .to_string(),
350            "the postgres dialect opens no transaction at isolation `read_uncommitted`"
351        );
352        assert_eq!(
353            StatementError::FifoAtSerializable { dialect: "mysql" }.to_string(),
354            "the mysql dialect claims FIFO groups below SERIALIZABLE: InnoDB turns every read of a \
355             SERIALIZABLE transaction into a locking read, so a claim would wait for the group's \
356             row in work; declare `isolation = repeatable_read` or a lower level"
357        );
358    }
359}