MySQL and MariaDB
@typesql/mysql runs queries on mysql2, the mariadb connector, or the serverless drivers of PlanetScale and TiDB Cloud, and the mysql and mariadb dialects from @typesql/core/mysql type them with the rules of MySQL 8.4 and MariaDB 11.8. The two dialects share one parser and set of type rules, and differ where the servers do.
pnpm add @typesql/core @typesql/mysql mysql2Install the driver you use instead of mysql2: mariadb, @planetscale/database or @tidbcloud/serverless. They are optional peer dependencies.
Use mysql for MySQL and the servers that speak its dialect: Amazon Aurora MySQL, Amazon RDS for MySQL, Google Cloud SQL, Azure Database for MySQL, PlanetScale (Vitess) and TiDB. Use mariadb for MariaDB and SkySQL.
Run queries
Pass a Pool, PoolConnection or Connection from mysql2/promise to createSql:
import { createPool } from "mysql2/promise";
import { createSql } from "@typesql/mysql";
const sql = createSql<DB>(createPool(process.env["DATABASE_URL"]!));
const posts = await sql(
`SELECT id, title FROM posts WHERE author_id = ${sql.param(1)}`,
).execute();- The adapter rewrites
$1,$2, … to?, and mysql2 escapes the values into the statement (query()), so every statement works,LIMIT $1and DDL included. sql.json(value)is sent as JSON text and aUint8Arrayas binary. mysql2 writes aDatein the time zone of itstimezoneoption.- On MariaDB, pass
{ dialect: "mariadb" }, which checks statements against MariaDB's SQL (see Differences between the servers):createSql<DB>(pool, { dialect: "mariadb" }). - Attach
CasePluginfor camelCase keys.
The mariadb connector
import { createPool } from "mariadb";
import { createSql } from "@typesql/mysql/mariadb";
const sql = createSql<DB>(createPool(process.env["DATABASE_URL"]!));It takes a Pool, PoolConnection or Connection of the connector's promise API. The dialect defaults to mariadb here; pass { dialect: "mysql" } when the connector talks to MySQL.
PlanetScale and TiDB Cloud
import { Client } from "@planetscale/database";
import { createSql } from "@typesql/mysql/planetscale";
const sql = createSql<DB>(new Client({ url: process.env["DATABASE_URL"] }));import { connect } from "@tidbcloud/serverless";
import { createSql } from "@typesql/mysql/tidb";
const sql = createSql<DB>(connect({ url: process.env["DATABASE_URL"] }));These HTTP drivers bind nothing on the server, so the adapter writes each value into the statement as an escaped literal (Dates in UTC). Both use the mysql dialect. Where TCP connections are available, mysql2 also connects to PlanetScale and TiDB.
Prepared statements
With mysql2 and the mariadb connector, a query from sql.prepare runs on the binary protocol (execute()): the driver prepares the statement once per connection and reuses it. Two differences from query(): FLOAT values arrive widened to double (1.1 reads as 1.100000023841858), and mysql2 before 3.24 sends numbers as DOUBLE, which MySQL rejects in LIMIT ?. The HTTP drivers have no prepared statements; sql.prepare() still assembles the SQL once.
Schema
Columns use MySQL's type names in lower case and without lengths (Col<"int">, Col<"varchar">, Col<"datetime", { nullable: true }>), and integer types take unsigned ("bigint unsigned").
import type { Col, Enum } from "@typesql/core/mysql";
interface DB {
users: {
id: Col<"int unsigned", { autoIncrement: true }>;
email: Col<"varchar">;
display_name: Col<"varchar", { nullable: true }>;
role: Enum<"admin" | "member", { hasDefault: true }>;
};
}The names are tinyint, smallint, mediumint, int, bigint, decimal, float, double, bit, char, varchar, tinytext, text, mediumtext, longtext, binary, varbinary, tinyblob, blob, mediumblob, longblob, enum, set, date, datetime, timestamp, time, year, json, the spatial types (geometry, point, …), MariaDB's uuid, inet4 and inet6, and vector. Enum<Values> declares an ENUM column typed as its values.
Column flags
| Flag | Effect |
|---|---|
nullable, hasDefault | NULL allowed; optional in INSERT |
position | column order, used by SELECT * and INSERT … VALUES without a column list |
autoIncrement | an AUTO_INCREMENT column; implies hasDefault |
generated | a GENERATED ALWAYS AS column: readable, but rejected in INSERT and UPDATE; implies hasDefault |
hidden | an INVISIBLE column: usable by name, but not in SELECT * or INSERT … VALUES without a column list |
Typed values
Json<T> types a JSON column's ->, ->> and JSON_EXTRACT with a constant path (->> unquotes, so its result is text), and Typed<"varchar", T> keeps a column's MySQL type and types its values as T.
import type { Col, Json, Typed } from "@typesql/core/mysql";
interface DB {
posts: {
meta: Json<{ tags: string[]; views: number }, { nullable: true }>;
kind: Col<Typed<"varchar", "note" | "link">>;
};
}
sql("SELECT meta -> '$.tags' AS tags, JSON_EXTRACT(meta, '$.views') AS views FROM posts");
// { tags: string[] | null; views: number | null }[]Generate the schema
typesql introspect reads a live database and writes its schema, with each column's type, nullability, default, position, ENUM values, AUTO_INCREMENT, and the generated and invisible columns:
pnpm add -D @typesql/cli mysql2
pnpm exec typesql introspect --url mysql://root@localhost/app --out src/db/schema.ts// Generated by `typesql introspect` from MySQL 8.4. Do not edit.
import type { Col, Enum, View } from "@typesql/core/mysql";
export interface DB {
/** View. */
active_users: View<{
id: Col<"int unsigned", { position: 0 }>;
email: Col<"varchar", { position: 1 }>;
}>;
users: {
id: Col<"int unsigned", { position: 0; autoIncrement: true }>;
email: Col<"varchar", { position: 1 }>;
role: Enum<"admin" | "member", { hasDefault: true; position: 2 }>;
created_at: Col<"datetime", { hasDefault: true; position: 3 }>;
};
}The dialect is mysql for a mysql:// URL and mariadb for a mariadb:// URL; --dialect mysql or --dialect mariadb sets it for other URLs. The file is the same on both servers, and its first line names the one it was read from.
| Option | What it does |
|---|---|
--url <url> | connection string, mysql://user:password@host:port/database; default $DATABASE_URL |
--database <name> | the database to read; default the one in the URL |
--out <file> | output file; default stdout |
--exclude <pattern> | relation to leave out, where * matches anything; repeat it or comma-separate |
--type-name <name> | name of the interface; default DB |
--import-from <module> | where Col, Enum and View are imported from; default @typesql/core/mysql |
--no-comments | leave out the JSDoc (comments, generated and invisible columns) |
The command connects with mysql2, or with the mariadb connector when mysql2 is not installed, and reads the tables and views of one database. On MariaDB, a LONGTEXT column with CHECK (json_valid(…)) (what JSON creates) is json. From code, @typesql/mysql/introspect exports introspect(client, { database, exclude }), which takes a mysql2 or mariadb connection or pool, and generateSource(result, { typeName, importFrom, comments }).
Types
Expressions get the type the server reports for them:
- Integer literals and integer arithmetic are
biginton MySQL. On MariaDB, short values areint.count(*)isbiginton both, which matters with the mariadb connector, which returnsBIGINTas a JavaScriptbigint. /isdecimal, ordoublewhen an operand is a float or a string.DIVis an integer.- Comparisons are
0or1:biginton MySQL,inton MariaDB. CONCATand the other string functions arevarchar, orvarbinarywhen an argument is a binary string.SUMandAVGof integers and decimals aredecimal(strings with mysql2).CASE,IF,COALESCEandUNIONcombine their types as the server does: integers widen, mixing signed and unsigned gives a wider signed type on MySQL anddecimalon MariaDB, mixed numbers givedecimalordouble, and anything with a string gives a string.@varand@@varareunknown.
Each createSql types rows with the map of its driver, from @typesql/core/mysql:
| Driver | Type map |
|---|---|
| mysql2, on MySQL or MariaDB | DefaultTypeMap (Mysql2MariaDbTypeMap with { dialect: "mariadb" }, the same map) |
| the mariadb connector | MariaDbTypeMap |
| PlanetScale, TiDB Cloud | ServerlessTypeMap |
mysql2 returns DECIMAL as a string and BIGINT as a number, which loses precision past 2^53. Pass the map that matches the pool's options as the second type argument: createSql<DB, BigNumberStringsTypeMap>(pool) for supportBigNumbers with bigNumberStrings, and DateStringsTypeMap for dateStrings. The mariadb connector returns BIGINT as bigint, and createSql<DB>(mariadb) from @typesql/core uses its map. The HTTP drivers return 64-bit integers, decimals and dates as strings. See Type mapping.
Functions
The built-in functions are MySQL's and MariaDB's, each available on the server that has it. Declare stored functions and loadable functions (UDFs) under $functions:
import type { Sig } from "@typesql/core/mysql";
interface DB {
$functions: {
slugify: Sig<["varchar"], "varchar">;
pick: [Sig<["int"], "int">, Sig<["varchar", "varchar"], "varchar">];
join_all: Sig<["varchar", "..."], "varchar", { nullable: false }>;
median: Sig<["double"], "double", { agg: true }>;
};
}Sig<Args, Ret, Flags>lists the argument types and the return type. A trailing"..."repeats the last argument type, and an array ofSigs is an overload set, picked by the number of arguments.- The result is
NULLwhen an argument isNULL. Use{ nullable: true }for a function that can returnNULLfor any input, and{ nullable: false }for one that never does. { agg: true }makes an aggregate (GROUP BYrules apply).- Names are matched case-insensitively, and a declared function replaces a built-in of the same name.
Nullability
LEFTandRIGHT JOINmake the other side nullable.WHERE x IS NOT NULL, or a comparison onx, makesxnon-null again, and aWHEREcondition that rejectsNULLin any column of the outer-joined table turns the join into an inner join for typing./,DIVand%are nullable (division by zero) unless the divisor is a nonzero literal.- Date functions (
DATE(),YEAR(), …) are nullable for string arguments, since an invalid date givesNULL, and follow their argument for date and time arguments.CAST(… AS DATE),DATETIMEandTIMEof anything but a date or time is nullable. - An aggregate query without
GROUP BYreturns one row even for an empty table, so its aggregates are nullable, exceptCOUNTand theBIT_*functions. WITH ROLLUPmakes the grouped columns nullable.
MySQL's default ONLY_FULL_GROUP_BY is checked for queries that aggregate without GROUP BY: a bare column next to an aggregate is an error, as on MySQL. MariaDB allows it, and the column is nullable. With GROUP BY, columns that aren't functionally dependent on the grouped columns are not checked.
Result columns
Row keys are the column names the server reports:
- A column reference is named as written:
SELECT ID FROM usersgivesID. - An expression without
ASis named by its text. On MySQL, a name keeps the whitespace after an expression that ends in an unqualified name, as MySQL does:SELECT a + b FROM tgives the key"a + b ". MariaDB trims it. - A string literal is named by its value.
SELECT 'a' 'b'givesaon MySQL andabon MariaDB. - A parameter is named
?:SELECT n + $1givesn + ?, which is the name the server reports for a prepared statement. - Duplicate names in a derived table, CTE or view are an error (
Duplicate column name), as on the server. - MySQL's
VALUES ROW(…)names its columnscolumn_0,column_1, …; MariaDB'sVALUES (…)names them after the first row's values.
Parameters
Use $1, :name or ${sql.param()}; ? is rejected with an error that says so, because fragments and inline values are numbered with $n. The adapters rewrite $n to ? before the statement reaches the driver.
Parameters take their type from where they are used. Integer parameters accept number | bigint (BIGINT also a string), DECIMAL accepts number | bigint | string, date types accept Date | string, binary types accept Uint8Array | string, and a parameter without context accepts any value the drivers bind. sql.json(value) binds the value as JSON text.
Statements
The SQL is MySQL's: names are quoted with backticks, '…' and "…" are strings with backslash escapes (ANSI_QUOTES is not supported), comments start with #, -- followed by a space, or /*, and X'…', 0x…, b'…' and _charset'…' literals and := work. Reserved words are those both servers reserve; a word only one of them reserves (MySQL's rank, rows, window) is accepted as a name.
SELECTwithDISTINCT/DISTINCTROWand modifiers (SQL_CALC_FOUND_ROWS, …); every join, includingSTRAIGHT_JOIN,NATURAL,USINGand comma joins; derived tables (with their required alias and an optional column list),LATERALandJSON_TABLE; index hints andPARTITION;WHERE,GROUP BY … WITH ROLLUP,HAVING,WINDOW,ORDER BY;LIMIT n,LIMIT off, nandLIMIT n OFFSET m;FOR UPDATE,FOR SHAREandLOCK IN SHARE MODE.WITH [RECURSIVE],UNION,INTERSECTandEXCEPT(withALLorDISTINCT), parenthesized query expressions,VALUES ROW(…)andTABLE t(MySQL), andVALUES (…)(MariaDB).INSERTandREPLACEwithVALUES,VALUE,SETor a query,IGNORE, andON DUPLICATE KEY UPDATEwithVALUES(col)or a row alias (AS new, MySQL).RETURNINGon MariaDB; on MySQL it is an error.UPDATE, including multi-tableUPDATE a JOIN b … SET, andORDER BY/LIMIT(single-table on MySQL).DELETEfrom one table (withORDER BY,LIMIT, andRETURNINGon MariaDB) or several (DELETE t1 FROM …,DELETE FROM t1 USING …).- In migrations:
CREATE TABLE(column attributes, table constraints and options,LIKE,AS SELECT),ALTER TABLE(ADD,DROP,MODIFY,CHANGEandRENAME COLUMN,FIRST/AFTER,ALTER … SET/DROP DEFAULT,SET VISIBLE/INVISIBLE,RENAME TO; index and constraint changes are accepted),RENAME TABLE,DROP TABLE/VIEW,CREATE [OR REPLACE] VIEW,CREATE/DROP INDEXandTRUNCATE.SET,START TRANSACTION,LOCK TABLESand similar statements are accepted without effect on the schema.
Differences between the servers
Statements are checked against the server you pick:
| MySQL | MariaDB | |
|---|---|---|
RETURNING | an error | INSERT, REPLACE, DELETE |
->, ->> | supported | an error |
a bare column in an aggregate query without GROUP BY | an error (ONLY_FULL_GROUP_BY) | nullable |
| integer literals, comparisons | bigint | int for short values |
| signed mixed with unsigned | a wider signed type | decimal |
CAST(… AS JSON), CAST(… AS YEAR), LAG / LEAD with a default | supported | an error |
VALUES ROW(…), TABLE t, LATERAL, AS new in ON DUPLICATE KEY UPDATE | supported | an error |
a query that is VALUES (…) | an error | supported |
| functions | MySQL's (ANY_VALUE, REGEXP_LIKE, UUID_TO_BIN, …) | MariaDB's (NVL, JSON_COMPACT, JSON_QUERY, SFORMAT, UUID_V4, NEXTVAL, …) |
| result names | 'a' 'b' is a; trailing whitespace kept | ab; trimmed |
Errors carry the server's own message (Unknown column 'emial'. Perhaps you meant the column "email"?, Table 'x' doesn't exist, Field 'x' doesn't have a default value, Incorrect parameter count).
Transactions
sql.transaction(fn) works as on every adapter. It takes one connection from a pool for BEGIN … COMMIT and releases it afterwards, or runs on the connection you passed; a connection whose ROLLBACK fails is closed instead of returned to the pool. Nested calls use savepoints. On PlanetScale it uses the client's transaction(), and on TiDB Cloud the connection's begin(); both run on a session of their own.
Migrations
A migration starts from "mysql" or "mariadb", and the migrator connects from url with mysql2 (mysql:// and mariadb:// URLs, with the database in the URL):
import { createMigrator, migration } from "@typesql/core/migration";
export const createUsers = migration("mysql", {
name: "create-users",
up: async (db) =>
db.ddl(`CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
role ENUM('admin', 'member') NOT NULL DEFAULT 'member'
)`),
down: async (db) => {
await db.ddl("DROP TABLE users");
},
});
await createMigrator({
dialect: "mysql",
url: process.env.DATABASE_URL,
migrations: [createUsers],
}).latest();Elsewhere pass executor(connection) from @typesql/mysql as execute, with one dedicated connection. The migrator holds a GET_LOCK named lock while it runs. MySQL and MariaDB commit DDL implicitly, so a migration that fails keeps the DDL that already ran, and the MigrationError reports rolledBack: false for it; a later migration has to repair it.
The schema a migration leaves follows MySQL's rules:
- Type aliases resolve to the type the server stores:
INTEGERisint,BOOLandBOOLEANaretinyint,DEC,NUMERICandFIXEDaredecimal,REAL,DOUBLE PRECISIONandFLOAT(p)withpover 24 aredouble,CHARACTER VARYINGandNVARCHARarevarchar, andLONG VARCHARismediumtext. SERIALisbigint unsigned NOT NULL AUTO_INCREMENT, andPRIMARY KEYcolumns areNOT NULL.AUTO_INCREMENT,DEFAULTand generated columns can be left out of anINSERT, andENUM('a', 'b')columns areEnum<"a" | "b">.CREATE TRIGGER,PROCEDURE,FUNCTIONandEVENTskip the rest of the text, so give each its own.ddl().
Limits
- Without
positionflags,SELECT *order is unspecified andINSERT INTO t VALUES (…)without a column list may type its values against the wrong columns, so name your columns or generate the schema. sql()on mysql2 and the mariadb connector, and every query on the HTTP drivers, write the values into the statement before the server sees it, so an unaliased expression with a parameter is named after its value (n + 7), notn + ?. Give such expressions an alias.- A string literal that contains
\'confuses:namerewriting and fragment interpolation. Write''instead. /*! … */executable comments are treated as comments.- Table names are matched case-insensitively. MySQL on Linux compares them case-sensitively by default.
- Views are writable as far as TypeSQL is concerned, and
ONLY_FULL_GROUP_BYdoesn't check functional dependency onGROUP BYcolumns. - On MariaDB, the integer width of some expressions is approximated (
CAST(… AS SIGNED),MINof literals), so they can bebigintwhere the server saysint. SRIDattributes andNOT NULLon generated columns, which only MySQL accepts, are not checked in DDL.sql.id,sql.setandsql.valuesquote names with backticks, andsql.valueswrites() VALUES ()for a row with no values. A column left out of some rows is sent asDEFAULT.