Skip to main content

ruststream_sqlx_dialect/
mysql.rs

1//! The built-in MySQL and MariaDB dialect.
2
3use std::num::NonZeroUsize;
4
5use crate::advisory::Advisory;
6use crate::dialect::Dialect;
7use crate::form::{Form, KeyPart};
8use crate::insert::{Placeholder, Spelling, quote_into};
9use crate::lease::Lease;
10use crate::opening::{Isolation, Opening, Opens, level};
11use crate::outbox::OutboxDialect;
12use crate::row_lock::RowLock;
13use crate::spec::TableSpec;
14use crate::statement::{ClaimShape, NameLimit, Param, Statement, StatementError};
15use crate::table_name::TableName;
16use crate::writer::{BuiltIn, OutboxWriter, Probe, SqlWriter};
17
18/// How MySQL reads the current time: UTC to the microsecond, fixed when the statement starts, so
19/// it does not follow the session's time zone.
20const DATABASE_NOW: &str = "UTC_TIMESTAMP(6)";
21
22/// How a claim locks the rows it takes: until its transaction ends, skipping the rows another
23/// claim holds.
24const LOCK: &str = " FOR UPDATE SKIP LOCKED";
25
26/// How a claim's transaction opens: at READ COMMITTED, which takes no gap locks. Both statements
27/// travel as one text query, in the round trip `BEGIN` would take, and the level holds for this
28/// transaction alone.
29const BEGIN_CLAIM: &str = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION";
30
31// The levels a table names open the same way: one round trip, the level for this transaction.
32const BEGIN_READ_UNCOMMITTED: &str =
33    "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION";
34const BEGIN_REPEATABLE_READ: &str =
35    "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION";
36const BEGIN_SERIALIZABLE: &str = "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION";
37
38/// Tries the session's lock on a key, without waiting. `GET_LOCK` answers `NULL` on an error,
39/// which reads as a lock not taken.
40const TRY_LOCK: &str = "SELECT CAST(COALESCE(GET_LOCK(?, 0), 0) AS SIGNED)";
41
42/// Releases the session's lock on a key. `RELEASE_LOCK` answers `NULL` for a lock no session
43/// holds, which reads as a lock not held.
44const UNLOCK: &str = "SELECT CAST(COALESCE(RELEASE_LOCK(?), 0) AS SIGNED)";
45
46/// The oldest release of a server whose claims skip locked rows, and how a refusal names it.
47#[derive(Debug, Clone, Copy)]
48struct Floor {
49    release: (u32, u32, u32),
50    name: &'static str,
51}
52
53/// MySQL 8.0.1 added `SKIP LOCKED`.
54const MYSQL_FLOOR: Floor = Floor {
55    release: (8, 0, 1),
56    name: "MySQL 8.0.1",
57};
58
59/// MariaDB 10.6 added `SKIP LOCKED`.
60const MARIADB_FLOOR: Floor = Floor {
61    release: (10, 6, 0),
62    name: "MariaDB 10.6",
63};
64
65/// How MySQL and MariaDB spell an insert, for the `const fn` that writes it and for [`MySql`]'s own
66/// statements.
67pub(crate) const SPELLING: Spelling = Spelling {
68    dialect: "mysql",
69    quote: b'`',
70    placeholder: Placeholder::Question,
71    default_row: " () VALUES ()",
72    // MySQL and MariaDB count a name's characters, and refuse a longer one.
73    name_limit: Some(NameLimit::Characters(64)),
74};
75
76/// MySQL and MariaDB: backtick-quoted names, `?` placeholders, rows claimed with
77/// `FOR UPDATE SKIP LOCKED`.
78///
79/// It builds the statements of the row lock form ([`RowLock`]), of the lease form ([`Lease`]), of
80/// the advisory lock form ([`Advisory`]), and the insert, for MySQL 8.0.1 and MariaDB 10.6 or
81/// later, the first versions that skip locked rows; [`check_server`](Dialect::check_server)
82/// refuses an older server for a table in any form. A lease claim selects
83/// the claimable rows, then stamps each one with its lease ([`stamp`](Lease::stamp)) before its
84/// transaction commits. A dead letter into a table copies the row, then deletes it, in one
85/// transaction. Every name is quoted, so a name keeps its case and may hold any character; a name
86/// over 64 characters, which MySQL refuses, is refused before it reaches the server. A table on
87/// the database's clock reads `UTC_TIMESTAMP(6)`. [`fetch`](Dialect::fetch) is refused, so a
88/// claim of the service's own brings a fetch of its own.
89///
90/// A claim's transaction opens at READ COMMITTED in both forms ([`begin`](Dialect::begin) for a
91/// table that names no level, [`begin_lease_claim`](Lease::begin_lease_claim)): under the
92/// server's default REPEATABLE READ a locking read also locks the gaps between the rows it scans,
93/// so a claim held for a handler would block every insert into the table until it ends. A server
94/// whose binary log records statements (`binlog_format = STATEMENT`) refuses writes in such a
95/// transaction; the row and mixed formats accept them. A row lock table that names a level opens
96/// its claims at it, any of the four, READ UNCOMMITTED included; REPEATABLE READ and SERIALIZABLE
97/// bring the gap locks back.
98///
99/// In the advisory lock form a row's key is the text `CONCAT_WS` renders from the key's parts, a
100/// column without a value skipped. A session lock (`GET_LOCK`) holds the row, named by the table's
101/// database, a dot and the key (`app.jobs-7`): the server names its locks server-wide, and the
102/// database keeps the locks of two databases on one server apart. The server takes a lock name of
103/// 1 to 64 characters, so a longer name is locked by its SHA-256, 64 characters of hex: the claim
104/// selects the name it locks. The claim leaves out the names `IS_USED_LOCK` reports in use. An
105/// update returns no rows here, so the take counts the attempt, then reads the row.
106///
107/// In a table with FIFO groups a claim's transaction takes its group
108/// ([`fifo_guard`](Dialect::fifo_guard)) with a locking read of the group's unfinished rows that
109/// skips the rows other transactions hold: the group is the claim's when the read took all of
110/// them. The rows stay locked until the transaction ends, in the row lock form until the delivery
111/// settles, and a write to one of them from elsewhere waits that long. Each claim reads every
112/// unfinished row of its group, and the whole table where the group's column has no index. A row
113/// lock table with FIFO groups claims below SERIALIZABLE: every read of a SERIALIZABLE
114/// transaction locks the rows it reads, so the guard would wait for the group's row in work, and
115/// it refuses such a table with [`StatementError::FifoAtSerializable`].
116///
117/// # Examples
118///
119/// A subscription asks MySQL for the statements of its table, as `ruststream-sqlx` does when the
120/// subscription starts:
121///
122/// ```
123/// use ruststream_sqlx_dialect::{
124///     ClaimShape, Column, Form, Lease, MySql, Statement, StatementError, TableSpec,
125/// };
126///
127/// // The jobs table as `#[derive(Inbox)]` describes it, with a `#[field(locked_until)]` field.
128/// const JOBS: TableSpec<'static> = TableSpec::new(
129///     "jobs",
130///     Column::new("job_id"),
131///     Form::Lease(Column::new("locked_until")),
132/// );
133///
134/// // What a subscription to a lease table prepares: the claim, and the stamp where the claim
135/// // only selects its rows.
136/// fn lease_statements(
137///     dialect: &impl Lease,
138///     spec: &TableSpec<'_>,
139/// ) -> Result<Vec<Statement>, StatementError> {
140///     let mut statements = vec![dialect.lease_claim(spec, ClaimShape::Rows)?];
141///     if !dialect.claim_writes_lease() {
142///         statements.push(dialect.stamp(spec)?);
143///     }
144///     Ok(statements)
145/// }
146///
147/// fn main() -> Result<(), StatementError> {
148///     // MySQL's claim selects the rows, and each row it took is stamped with its lease before
149///     // the claim's transaction commits.
150///     let statements = lease_statements(&MySql, &JOBS)?;
151///     assert_eq!(statements.len(), 2);
152///     Ok(())
153/// }
154/// ```
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
156pub struct MySql;
157
158impl BuiltIn for MySql {
159    const SPELLING: Spelling = SPELLING;
160
161    const ROW_LOCKS: bool = true;
162
163    /// A backslash escapes the next character of a string literal, unless the server's SQL mode
164    /// has `NO_BACKSLASH_ESCAPES`; under that mode a doubled backslash reads as two, the same in
165    /// every statement, so a key stays one key.
166    const BACKSLASH_ESCAPES: bool = true;
167
168    const UPDATE_RETURNS: bool = false;
169
170    const PROBE: Probe = Probe::Check("IS_USED_LOCK(", ") IS NULL");
171
172    fn database_now(&self) -> &'static str {
173        DATABASE_NOW
174    }
175
176    fn database_later(&self, sql: &mut SqlWriter<'_, Self>) {
177        sql.push(DATABASE_NOW)
178            .push(" + INTERVAL ")
179            .param(Param::Delay)
180            .push(" MICROSECOND");
181    }
182
183    fn render_lock_key(
184        &self,
185        sql: &mut SqlWriter<'_, Self>,
186        schema: Option<&str>,
187        key: &[KeyPart<'_>],
188    ) {
189        // The server names its locks server-wide, so the name starts with the table's database:
190        // two databases on one server keep their locks apart, as Postgres keeps them per database.
191        // The database's name is lowercased, so a table named with its database and the same table
192        // reached through the connection's default name one lock alike, whatever case each writes.
193        let text = |sql: &mut SqlWriter<'_, Self>| {
194            sql.push("CONCAT_WS('', LOWER(");
195            match schema {
196                Some(schema) => sql.literal(schema),
197                None => sql.push("DATABASE()"),
198            };
199            sql.push("), '.', ")
200                .key_parts(key, ", ", |sql, column| {
201                    sql.ident(column);
202                })
203                .push(")");
204        };
205        // MySQL refuses a lock name that is empty or longer than 64 characters, and MariaDB takes
206        // no lock on an empty one: such a name is locked by its hash.
207        sql.push("IF(CHAR_LENGTH(");
208        text(sql);
209        sql.push(") BETWEEN 1 AND 64, ");
210        text(sql);
211        sql.push(", SHA2(");
212        text(sql);
213        sql.push(", 256))");
214    }
215}
216
217impl Dialect for MySql {
218    fn name(&self) -> &'static str {
219        "mysql"
220    }
221
222    fn quote_into(&self, ident: &str, out: &mut String) {
223        quote_into(SPELLING.quote, ident, out);
224    }
225
226    fn placeholder_into(&self, _: NonZeroUsize, out: &mut String) {
227        out.push('?');
228    }
229
230    fn fetch(&self, _: &TableSpec<'_>) -> Result<Statement, StatementError> {
231        // A statement is prepared once with fixed text, and MySQL binds no list as one parameter.
232        Err(StatementError::UnsupportedFetch {
233            dialect: self.name(),
234        })
235    }
236
237    fn ack(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
238        self.finish_statement(spec)
239    }
240
241    fn retry(&self, spec: &TableSpec<'_>) -> Result<Option<Statement>, StatementError> {
242        self.retry_statement(spec)
243    }
244
245    fn retry_after(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
246        self.retry_after_statement(spec)
247    }
248
249    fn discard(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
250        self.finish_statement(spec)
251    }
252
253    fn dead_letter_group(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
254        self.dead_letter_group_statement(spec)
255    }
256
257    fn dead_letter_table(
258        &self,
259        spec: &TableSpec<'_>,
260        target: TableName<'_>,
261    ) -> Result<Vec<Statement>, StatementError> {
262        // Neither server feeds a delete's rows into an insert.
263        self.copy_then_delete(spec, target)
264    }
265
266    fn insert(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
267        self.insert_statement(spec)
268    }
269
270    fn server_version(&self) -> Option<&'static str> {
271        Some("SELECT VERSION()")
272    }
273
274    fn check_server(&self, _: &TableSpec<'_>, version: &str) -> Result<(), StatementError> {
275        // The row lock and lease forms claim with `SKIP LOCKED`, and an advisory table shares
276        // their floor: one floor for every table of a server.
277        let floor = if is_mariadb(version) {
278            MARIADB_FLOOR
279        } else {
280            MYSQL_FLOOR
281        };
282        if release(version).is_some_and(|release| release >= floor.release) {
283            return Ok(());
284        }
285        Err(StatementError::ServerTooOld {
286            dialect: self.name(),
287            server: version.to_owned(),
288            required: floor.name,
289        })
290    }
291
292    fn begin(&self, opening: Opening) -> Result<Option<&'static str>, StatementError> {
293        match opening {
294            Opening::Default | Opening::Isolation(Isolation::ReadCommitted) => {
295                Ok(Some(BEGIN_CLAIM))
296            }
297            Opening::Isolation(Isolation::ReadUncommitted) => Ok(Some(BEGIN_READ_UNCOMMITTED)),
298            Opening::Isolation(Isolation::RepeatableRead) => Ok(Some(BEGIN_REPEATABLE_READ)),
299            Opening::Isolation(Isolation::Serializable) => Ok(Some(BEGIN_SERIALIZABLE)),
300            Opening::Mode(_) => Err(opening.refused(self.name())),
301        }
302    }
303
304    fn fifo_guard(&self, spec: &TableSpec<'_>) -> Result<Option<Statement>, StatementError> {
305        if !self.guards_group(spec)? {
306            return Ok(None);
307        }
308        // Why a refusal: InnoDB turns every read of a SERIALIZABLE transaction into a locking
309        // read, so the plain count below would wait for the row another claim holds instead of
310        // answering at once. A lease claim opens at READ COMMITTED whatever the table names, so
311        // only the row lock form claims at the table's level.
312        if matches!(spec.form(), Form::RowLock)
313            && spec.opening() == Opening::Isolation(Isolation::Serializable)
314        {
315            return Err(StatementError::FifoAtSerializable {
316                dialect: self.name(),
317            });
318        }
319        // A named lock belongs to the session here, not to the transaction. A locking read that
320        // skips the rows other transactions hold takes every unfinished row of the group only
321        // when no other transaction holds one, and its locks last until the transaction ends.
322        let mut sql = SqlWriter::new(self);
323        sql.push("SELECT CAST((")
324            .group_count(spec, "")
325            .push(") = (")
326            .group_count(spec, LOCK)
327            .push(") AS SIGNED)");
328        Ok(Some(sql.finish()))
329    }
330}
331
332impl OutboxDialect for MySql {
333    fn outbox_fetch(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
334        self.outbox_fetch_statement(spec)
335    }
336
337    fn outbox_mark(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
338        self.outbox_mark_statement(spec)
339    }
340
341    fn outbox_recover(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
342        self.outbox_recover_statement(spec)
343    }
344}
345
346impl Opens<level::ReadUncommitted> for MySql {}
347
348impl Opens<level::ReadCommitted> for MySql {}
349
350impl Opens<level::RepeatableRead> for MySql {}
351
352impl Opens<level::Serializable> for MySql {}
353
354impl RowLock for MySql {
355    fn lock_claim(
356        &self,
357        spec: &TableSpec<'_>,
358        shape: ClaimShape,
359    ) -> Result<Statement, StatementError> {
360        self.locked(spec, "lock_claim")?;
361        let mut sql = SqlWriter::new(self);
362        sql.claim(spec, shape, LOCK);
363        Ok(sql.finish())
364    }
365}
366
367impl Advisory for MySql {
368    fn advisory_claim(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
369        self.advisory_claim_statement(spec)
370    }
371
372    fn lock(&self) -> Option<Statement> {
373        Some(Statement::new(TRY_LOCK, [Param::Key]))
374    }
375
376    fn unlock(&self) -> Option<Statement> {
377        Some(Statement::new(UNLOCK, [Param::Key]))
378    }
379
380    fn take(
381        &self,
382        spec: &TableSpec<'_>,
383        shape: ClaimShape,
384    ) -> Result<Vec<Statement>, StatementError> {
385        self.take_statements(spec, shape)
386    }
387}
388
389impl Lease for MySql {
390    fn lease_claim(
391        &self,
392        spec: &TableSpec<'_>,
393        shape: ClaimShape,
394    ) -> Result<Statement, StatementError> {
395        // An update cannot return the rows it changed here, so the claim selects the claimable
396        // rows under a lock, and its transaction then stamps each of them.
397        self.leased(spec, "lease_claim")?;
398        let mut sql = SqlWriter::new(self);
399        sql.claim(spec, shape, LOCK);
400        Ok(sql.finish())
401    }
402
403    fn extend(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
404        self.extend_statement(spec)
405    }
406
407    fn stamp(&self, spec: &TableSpec<'_>) -> Result<Statement, StatementError> {
408        self.stamp_statement(spec)
409    }
410
411    fn claim_writes_lease(&self) -> bool {
412        false
413    }
414
415    fn begin_lease_claim(&self) -> Option<&'static str> {
416        Some(BEGIN_CLAIM)
417    }
418}
419
420/// Whether the server is MariaDB: its version names it, in any case.
421fn is_mariadb(version: &str) -> bool {
422    const MARIADB: &[u8] = b"mariadb";
423    version
424        .as_bytes()
425        .windows(MARIADB.len())
426        .any(|window| window.eq_ignore_ascii_case(MARIADB))
427}
428
429/// The `major.minor.patch` release a version opens with, or `None` when it opens with none.
430fn release(version: &str) -> Option<(u32, u32, u32)> {
431    let digits = version
432        .split(|character: char| !character.is_ascii_digit() && character != '.')
433        .next()
434        .unwrap_or_default();
435    let mut numbers = digits.split('.').map(|number| number.parse().ok());
436    let mut next = || numbers.next().flatten();
437    Some((next()?, next()?, next()?))
438}