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}