db/mysql
Connecting to MySQL and MariaDB.
import db.mysql
- Tutorial: a small bookshop in eleven steps
- 1. Connect
- 2. Create the tables
- 3. Insert rows
- 4. Read rows into your own structs
- 5. Values from users, safely
- 6. NULL values
- 7. Update and delete
- 8. Transactions
- 9. Errors
- 10. The shared interface and a pool
- 11. A sales report
- Bulk import and upsert
- Schema migrations
- Encryption and TLS modes
- Which function for which job
- Types
- Functions
This package speaks the MySQL and MariaDB client protocol itself and needs no outside library: there is no libmysqlclient to install and nothing to link. It logs in with mysql_native_password or caching_sha2_password, encrypts the connection with TLS whenever the server offers it, and asks for utf8mb4 during the handshake, so Persian text and emoji work from the first query. The functions follow the shape of the C API (Open, Exec, Query, Next, Finish), and Conn turns a connection into the shared Connection of db, so code written against that interface also runs on SQLite and PostgreSQL. This page starts with a tutorial; the reference of every function comes after it.
Tutorial: a small bookshop in eleven steps
The steps below build the database of a small bookshop: a table of books, a table of orders, and the code to sell, restock and report on them. Each step is a complete program, and the output under it is what it printed against a real MariaDB 11.8 server. Run them in order, because each step uses the rows the earlier ones left behind.
First start a server. With Docker that is one command, and it also creates the database and the user the programs log in with:
docker run -d --name shop-db -p 3306:3306 -e MARIADB_ROOT_PASSWORD=rootpw -e MARIADB_DATABASE=shop -e MARIADB_USER=salam -e MARIADB_PASSWORD=secret mariadb:11.8
The programs connect to 127.0.0.1 on port 3306 as salam with the password secret and use the database shop. The outputs come from MariaDB. MySQL 8 speaks the same protocol and the package works with it too, but a few statements here use MariaDB names: on MySQL the status table of step 1 is performance_schema.session_status.
1. Connect
Open takes the host, user, password, database name and port, and always returns a Database. Check it with Ok before anything else: when the login fails the handle is empty and Error says why. A database name of "" connects without choosing one, and SelectDB picks it later. Close sends the server a polite goodbye and frees the connection, so defer it right after the check.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
println "server:", mysql.ServerVersion(d)
println "client:", mysql.ClientVersion()
println "alive:", mysql.Ping(d)
println "database:", mysql.QueryText(d, "SELECT DATABASE()")
println "charset:", mysql.QueryText(d, "SELECT @@character_set_connection")
println "tls:", mysql.QueryText(d, "SELECT VARIABLE_VALUE FROM information_schema.SESSION_STATUS WHERE VARIABLE_NAME = 'Ssl_version'")
bad := mysql.Open("127.0.0.1", "salam", "wrong", "shop", 3306)
println "with a wrong password:", mysql.Ok(bad)
println mysql.Error(bad)
nobody := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3307)
println mysql.Error(nobody)
ret 0
endThe tls line shows that the session is encrypted even though the program asked for nothing: MariaDB 11.8 has TLS on by default and the client uses it when it is there. Encryption and TLS modes below shows how to require it or turn it off. Note that Errno only works on a connection that exists; for a failed Open there is just the message.
2. Create the tables
Exec runs one statement and returns 0 when it worked and -1 when it did not. SQL that spans several lines is easiest to write as a backtick string, which keeps newlines and quotes exactly as typed. One Exec runs one statement; to run several, call it once for each.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
mysql.Exec(d, "DROP TABLE IF EXISTS orders")
mysql.Exec(d, "DROP TABLE IF EXISTS books")
books := `CREATE TABLE books (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL UNIQUE,
author VARCHAR(100) NOT NULL,
price DECIMAL(8, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
isbn VARCHAR(20) NULL,
details JSON NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4`
println "books:", mysql.Exec(d, books)
orders := `CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
book_id INT NOT NULL,
customer VARCHAR(100) NOT NULL,
qty INT NOT NULL,
total DECIMAL(10, 2) NOT NULL,
FOREIGN KEY (book_id) REFERENCES books (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4`
println "orders:", mysql.Exec(d, orders)
mut r := mysql.Query(d, "SELECT TABLE_NAME, COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME IN ('books', 'orders') GROUP BY TABLE_NAME ORDER BY TABLE_NAME")
until mysql.Next(r):
println mysql.Text(r, 0), "has", mysql.Int(r, 1), "columns"
end
mysql.Finish(r)
ret 0
endThe books table has a nullable isbn and a JSON column for details that differ from book to book. InnoDB is the engine that gives transactions and foreign keys, and utf8mb4 is the real four byte UTF-8; the older utf8 of MySQL cannot store every character.
3. Insert rows
After an INSERT, LastInsertId gives the AUTO_INCREMENT id the server chose and AffectedRows (or its other name Changes) how many rows went in. One INSERT with several value lists is much faster than many single ones, and for it LastInsertId is the id of the first new row.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
mysql.Exec(d, `INSERT INTO books (title, author, price, stock, details) VALUES ('Master and Commander', 'Patrick O''Brian', 14.50, 4, '{"pages": 412, "tags": ["sea", "navy"]}')`)
println "first book id:", mysql.LastInsertId(d)
mysql.Exec(d, "INSERT INTO books (title, author, price, stock, isbn) VALUES ('The Remains of the Day', 'Kazuo Ishiguro', 11.25, 2, '9780571200733')")
println "second book id:", mysql.LastInsertId(d)
many := `INSERT INTO books (title, author, price, stock, details) VALUES
('A Wizard of Earthsea', 'Ursula K. Le Guin', 9.99, 6, '{"pages": 183, "tags": ["fantasy"]}'),
('The Blind Owl', 'Sadegh Hedayat', 8.00, 3, NULL),
('شازده احتجاب', 'هوشنگ گلشیری', 7.50, 5, '{"pages": 120, "tags": ["novel"]}')`
mysql.Exec(d, many)
println "rows added:", mysql.AffectedRows(d)
println "id of the first row of that insert:", mysql.LastInsertId(d)
println "books now:", mysql.QueryInt(d, "SELECT COUNT(*) FROM books")
println "stock in total:", mysql.QueryInt(d, "SELECT SUM(stock) FROM books")
ret 0
endInside SQL a single quote is written twice (O''Brian). That is fine for text you typed yourself; text that comes from a user goes through Quote or Escape, as step 5 shows.
4. Read rows into your own structs
Query returns a Result with every row already read from the server. Keep it in a mut variable, because Next moves it to the next row and returns false when none is left. Text, Int, Int64 and Float read a column of the current row by its position, starting at 0, and Finish frees the result. Wrapping that loop in a function that returns a Vector of your own struct keeps the SQL in one place.
import str
import db.mysql
struct Book:
pub id: int = 0
pub title: str = ""
pub author: str = ""
pub price: str = ""
pub stock: int = 0
end
func load_books(d: mysql.Database, sql: str): Vector<Book>:
mut out := Vector {} as Vector<Book>
mut r := mysql.Query(d, sql)
if not mysql.QueryOk(r):
println "query failed:", mysql.Error(d)
ret out
end
until mysql.Next(r):
out.push(Book {
id = mysql.Int(r, 0), title = mysql.Text(r, 1), author = mysql.Text(r, 2), price = mysql.Text(r, 3), stock = mysql.Int(r, 4)
})
end
mysql.Finish(r)
ret out
end
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
r := mysql.Query(d, "SELECT id, title, author, price, stock FROM books LIMIT 0")
mut names := Vector {} as Vector<str>
repeat mysql.ColumnCount(r) in i:
names.push(mysql.ColumnName(r, i))
end
mysql.Finish(r)
println "columns:", str.Join(names, ", ")
books := load_books(d, "SELECT id, title, author, price, stock FROM books ORDER BY price DESC")
each b in books:
println b.id, b.title, "by", b.author, "costs", b.price, "with", b.stock, "in stock"
end
cheap := load_books(d, "SELECT id, title, author, price, stock FROM books WHERE price < 9 ORDER BY id")
println "under 9:", cheap.len()
ret 0
endThe server sends every value as text and the column functions convert it, so a DECIMAL read with Text stays exact (14.50), while Float would turn it into a binary fraction. Keep money as text or as whole cents, and let SQL do the sums. ColumnCount and ColumnName describe the columns, which is handy for printing a header or a CSV file; LIMIT 0 gets the columns without any rows.
5. Values from users, safely
This package has no prepared statements, so a value is put into the SQL text. Never paste user text in directly. Quote (a method of Database) escapes the text and adds the quotes around it; Escape only escapes, for when you write the quotes yourself. Both escape quotes, backslashes, newlines and NUL bytes the way the server reads them.
import str
import db.mysql
func count_by_author(d: mysql.Database, name: str): int:
ret mysql.QueryInt(d, "SELECT COUNT(*) FROM books WHERE author = " + d.Quote(name))
end
func like_pattern(d: mysql.Database, word: str): str:
mut w := mysql.Escape(d, word)
w = str.Replace(w, "%", "\\%")
w = str.Replace(w, "_", "\\_")
ret "'%" + w + "%'"
end
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
println "by Patrick O'Brian:", count_by_author(d, "Patrick O'Brian")
println "by هوشنگ گلشیری:", count_by_author(d, "هوشنگ گلشیری")
attack := "nobody' OR '1'='1"
println "quoted:", d.Quote(attack)
println "safe query finds:", count_by_author(d, attack)
unsafe := "SELECT COUNT(*) FROM books WHERE author = '" + attack + "'"
println "pasted query finds:", mysql.QueryInt(d, unsafe)
println "titles with 'the':", mysql.QueryInt(d, "SELECT COUNT(*) FROM books WHERE title LIKE " + like_pattern(d, "the"))
println "titles with '%':", mysql.QueryInt(d, "SELECT COUNT(*) FROM books WHERE title LIKE " + like_pattern(d, "%"))
ret 0
endThe pasted query counts every book, because the attacker's quote ended the string and OR '1'='1' became part of the condition. The quoted one looks for an author whose name is literally that text and finds nothing. LIKE needs one more step: % and _ are wildcards there and Escape leaves them alone, so like_pattern puts a backslash before each. Numbers are safest as real numbers: convert the input to int first and write it back with str.FromInt.
6. NULL values
IsNull tells a NULL apart from an empty string. The other column functions give a neutral value for NULL ("", 0 or 0.0), which is convenient but means you cannot tell no value from zero without asking. JSON fields come back through JSON_VALUE as ordinary text, and NULL when the document or the key is missing.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
mysql.Exec(d, "UPDATE books SET isbn = '9780393307054' WHERE id = 1")
mut r := mysql.Query(d, "SELECT title, isbn, JSON_VALUE(details, '$.pages') FROM books ORDER BY id")
until mysql.Next(r):
mut isbn := mysql.Text(r, 1)
if mysql.IsNull(r, 1):
isbn = "(no isbn)"
end
mut pages := "unknown"
if not mysql.IsNull(r, 2):
pages = mysql.Text(r, 2) + " pages"
end
println mysql.Text(r, 0), "|", isbn, "|", pages
end
mysql.Finish(r)
println "IS NULL finds:", mysql.QueryInt(d, "SELECT COUNT(*) FROM books WHERE isbn IS NULL")
println "= NULL finds:", mysql.QueryInt(d, "SELECT COUNT(*) FROM books WHERE isbn = NULL")
println "Int of NULL:", mysql.QueryInt(d, "SELECT NULL")
println "COALESCE:", mysql.QueryText(d, "SELECT COALESCE(isbn, 'none') FROM books WHERE id = 3")
ret 0
endIn SQL nothing is equal to NULL, not even NULL, so isbn = NULL finds no rows; write IS NULL. COALESCE replaces a NULL with a default inside the query, which saves a check in the program.
7. Update and delete
UPDATE and DELETE are run with Exec as well, and AffectedRows says how many rows they changed. Arithmetic belongs in the SQL (stock = stock + 10), so two programs restocking at the same time cannot overwrite each other.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
mysql.Exec(d, "UPDATE books SET stock = stock + 10 WHERE author = 'Kazuo Ishiguro'")
println "restocked:", mysql.AffectedRows(d)
mysql.Exec(d, "UPDATE books SET price = price * 1.10 WHERE price < 9")
println "repriced:", mysql.AffectedRows(d)
mysql.Exec(d, "UPDATE books SET stock = 12 WHERE id = 2")
println "setting the same value again changes:", mysql.AffectedRows(d)
mysql.Exec(d, "DELETE FROM books WHERE title = 'The Blind Owl'")
println "deleted:", mysql.AffectedRows(d)
mut r := mysql.Query(d, "SELECT id, title, price, stock FROM books ORDER BY id")
until mysql.Next(r):
println mysql.Int(r, 0), mysql.Text(r, 1), mysql.Text(r, 2), mysql.Int(r, 3)
end
mysql.Finish(r)
ret 0
endMySQL and MariaDB count rows that actually changed, not rows that matched the WHERE: setting stock to the value it already has reports 0. Keep that in mind before you treat 0 as not found; check with a SELECT when the difference matters.
8. Transactions
An order takes two writes: lower the stock and add the order row. Either both happen or neither. Begin starts a transaction, Commit keeps its writes and Rollback throws them away. The UPDATE only lowers the stock when there is enough of it, and AffectedRows tells the program whether it did.
import str
import db.mysql
func place_order(d: mysql.Database, book: int, customer: str, qty: int): bool:
if mysql.Begin(d) != 0:
ret false
end
id := str.FromInt(book)
n := str.FromInt(qty)
mysql.Exec(d, "UPDATE books SET stock = stock - " + n + " WHERE id = " + id + " AND stock >= " + n)
if mysql.AffectedRows(d) != 1:
mysql.Rollback(d)
ret false
end
sql := "INSERT INTO orders (book_id, customer, qty, total) SELECT id, " + d.Quote(customer) + ", " + n + ", price * " + n + " FROM books WHERE id = " + id
if mysql.Exec(d, sql) != 0:
mysql.Rollback(d)
ret false
end
ret mysql.Commit(d) == 0
end
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
println "Sara, 2 x book 3:", place_order(d, 3, "Sara", 2)
println "Reza, 1 x book 1:", place_order(d, 1, "Reza", 1)
println "Neda, 3 x book 5:", place_order(d, 5, "Neda", 3)
println "Ali, 9 x book 1:", place_order(d, 1, "Ali", 9)
println "Omid, 1 x book 99:", place_order(d, 99, "Omid", 1)
println "orders:", mysql.QueryInt(d, "SELECT COUNT(*) FROM orders")
println "stock of book 1:", mysql.QueryInt(d, "SELECT stock FROM books WHERE id = 1")
println "stock of book 3:", mysql.QueryInt(d, "SELECT stock FROM books WHERE id = 3")
println "takings:", mysql.QueryText(d, "SELECT SUM(total) FROM orders")
ret 0
endAli wanted more copies than the shop had and the order for book 99 found no book, so both rolled back and left no trace. Inside a transaction InnoDB locks the rows it changes until Commit, so two orders for the last copy cannot both succeed. Autocommit(d, false) is the other way to work: every statement then waits for an explicit Commit. A CREATE, ALTER or DROP commits on its own in MySQL, so do not put schema changes in the middle of a transaction you might roll back.
9. Errors
When Exec returns -1 or QueryOk is false, Errno gives the server's error number and Error its message. The number is what a program should branch on: 1062 is a duplicate key, 1452 a missing parent row, 1364 a required column with no value, 1064 a syntax error and 1146 a missing table.
import db.mysql
func attempt(d: mysql.Database, sql: str):
if mysql.Exec(d, sql) == 0:
println "ok"
ret
end
println "error", mysql.Errno(d), "->", mysql.Error(d)
end
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
attempt(d, "INSERT INTO books (title, author, price) VALUES ('A Wizard of Earthsea', 'Someone', 5)")
attempt(d, "INSERT INTO orders (book_id, customer, qty, total) VALUES (99, 'Omid', 1, 5)")
attempt(d, "INSERT INTO books (title, author) VALUES ('No Price', 'Nobody')")
attempt(d, "SELEC title FROM books")
attempt(d, "UPDATE books SET stock = stock WHERE id = 1")
r := mysql.Query(d, "SELECT name FROM authors")
if not mysql.QueryOk(r):
println "query failed:", mysql.Errno(d), mysql.Error(d)
end
println "still connected:", mysql.Ping(d)
println "stock of book 1:", mysql.QueryInt(d, "SELECT stock FROM books WHERE id = 1")
ret 0
endAn error in one statement does not break the connection; the next statement runs normally, as the last two lines show. A lost connection is different: Ping returns false and the only cure is a new Open. The 1062 error is also the clean way to find out that a row exists: insert it and handle the duplicate, instead of checking first and racing another program.
10. The shared interface and a pool
Conn wraps a connection as a dyn db.Connection. Through it the helpers of db work on MySQL: db.QueryInt, db.Run, db.InTx and the rest. db.Pool keeps several open connections and lends them out by number with Acquire and Release, so a server does not log in again for every request.
import db
import db.mysql
func main:
mut pool := db.NewPool(3)
repeat 3 in i:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "connection", i, "failed:", mysql.Error(d)
ret 1
end
db.Add(pool, mysql.Conn(d))
end
println "size:", db.Size(pool), "idle:", db.Idle(pool)
a := db.Acquire(pool)
b := db.Acquire(pool)
ca := db.Conn(pool, a)
cb := db.Conn(pool, b)
println "kind:", ca.Kind(), "in use:", db.InUse(pool)
println "separate sessions:", db.QueryInt(ca, "SELECT CONNECTION_ID()") != db.QueryInt(cb, "SELECT CONNECTION_ID()")
restock := Vector {} as Vector<str>
restock.push("UPDATE books SET stock = stock + 5 WHERE id = 1")
restock.push("UPDATE books SET stock = stock + 5 WHERE id = 3")
println "restock in one transaction:", db.InTx(cb, restock)
println "stock of book 1 seen from the other connection:", db.QueryInt(ca, "SELECT stock FROM books WHERE id = 1")
c := db.Acquire(pool)
full := db.Acquire(pool)
println "a fourth lease:", db.Leased(full)
db.Release(pool, a)
db.Release(pool, b)
db.Release(pool, c)
println "idle again:", db.Idle(pool)
println "dead connections dropped:", db.DropDead(pool)
db.Free(pool)
ret 0
endEach connection is its own session with its own CONNECTION_ID(), its own transaction and its own last insert id. A connection is not safe to use from two threads at once: give each thread its own, or one leased from a pool. When Acquire returns db.NO_LEASE every connection is busy; wait and try again, or open another one. DropDead pings the idle connections and removes the ones the server has closed.
11. A sales report
The point of keeping orders in SQL is that the questions become one query each. JOIN and GROUP BY sum the sales per book, GROUP_CONCAT folds a list into one text value, and the JSON functions search inside the details column. RowCount says how many rows a result holds before you loop over it.
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
sales := `SELECT b.title, SUM(o.qty), SUM(o.total), COUNT(DISTINCT o.customer)
FROM orders o JOIN books b ON b.id = o.book_id
GROUP BY b.id, b.title
ORDER BY SUM(o.total) DESC`
mut r := mysql.Query(d, sales)
println mysql.RowCount(r), "books sold"
until mysql.Next(r):
println mysql.Text(r, 0), "| copies", mysql.Int(r, 1), "| takings", mysql.Text(r, 2), "| customers", mysql.Int(r, 3)
end
mysql.Finish(r)
println "never sold:", mysql.QueryText(d, "SELECT GROUP_CONCAT(title ORDER BY id SEPARATOR ', ') FROM books WHERE id NOT IN (SELECT book_id FROM orders)")
println "about the sea:", mysql.QueryText(d, "SELECT title FROM books WHERE JSON_CONTAINS(details, '\"sea\"', '$.tags')")
println "pages on the shelf:", mysql.QueryInt(d, "SELECT SUM(stock * JSON_VALUE(details, '$.pages')) FROM books")
println "stock value:", mysql.QueryText(d, "SELECT SUM(stock * price) FROM books")
ret 0
endThe bookshop is done: its schema, writes that cannot half happen, reads into structs and reports. The sections below cover jobs that did not fit the story.
Bulk import and upsert
A delivery of books, some new and some already on the shelf, is one statement: a multi-row INSERT with ON DUPLICATE KEY UPDATE. A row whose UNIQUE title already exists updates that row instead of failing with 1062. Each value still goes through Quote, and str.Join puts the rows together.
import str
import db.mysql
struct Delivery:
pub title: str = ""
pub author: str = ""
pub price: str = ""
pub copies: int = 0
end
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
mut crate := Vector {} as Vector<Delivery>
crate.push(Delivery { title = "A Wizard of Earthsea", author = "Ursula K. Le Guin", price = "9.99", copies = 4 })
crate.push(Delivery { title = "The Tombs of Atuan", author = "Ursula K. Le Guin", price = "9.99", copies = 3 })
crate.push(Delivery { title = "Kafka on the Shore", author = "Haruki Murakami", price = "13.40", copies = 2 })
mut rows := Vector {} as Vector<str>
each b in crate:
rows.push("(" + d.Quote(b.title) + ", " + d.Quote(b.author) + ", " + b.price + ", " + str.FromInt(b.copies) + ")")
end
sql := "INSERT INTO books (title, author, price, stock) VALUES " + str.Join(rows, ", ") + " ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock), price = VALUES(price)"
if mysql.Exec(d, sql) != 0:
println "import failed:", mysql.Error(d)
ret 1
end
println "affected rows:", mysql.AffectedRows(d)
println "books now:", mysql.QueryInt(d, "SELECT COUNT(*) FROM books")
println "Earthsea stock:", mysql.QueryInt(d, "SELECT stock FROM books WHERE title = 'A Wizard of Earthsea'")
ret 0
endFor an upsert, AffectedRows counts 1 for each new row and 2 for each updated one, so 4 here means two new books and one restock. The server limits the size of one statement (max_allowed_packet, 16 MB by default in MariaDB), so split a large import into chunks of a few thousand rows, and wrap the chunks in a transaction when the import must go in whole or not at all.
Schema migrations
A real application changes its schema over time. db.Migrator from db keeps numbered steps, each with an up and a down SQL text, and records the applied ones in a schema_migrations table. Run MigrateUp when the program starts and it applies only the new steps.
import db
import db.mysql
func main:
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
c := mysql.Conn(d)
mut m := db.NewMigrator()
db.AddMigration(m, 1, "reviews", "CREATE TABLE reviews (id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, stars TINYINT NOT NULL)", "DROP TABLE reviews")
db.AddMigration(m, 2, "review text", "ALTER TABLE reviews ADD COLUMN body TEXT NULL", "ALTER TABLE reviews DROP COLUMN body")
println "version before:", db.SchemaVersion(m, c), "pending:", db.PendingCount(m, c)
println "applied:", db.MigrateUp(m, c)
println "version after:", db.SchemaVersion(m, c)
println "applied on a second run:", db.MigrateUp(m, c)
println "review columns:", db.QueryInt(c, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'reviews'")
println "rolled back:", db.Rollback(m, c)
println "version now:", db.SchemaVersion(m, c)
println "migrated to 0:", db.MigrateTo(m, c, 0)
println "reviews table left:", db.QueryInt(c, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'reviews'")
db.FreeMigrator(m)
c.Close()
ret 0
endThe migrator runs each step in a transaction, but MySQL commits every CREATE and ALTER on its own, so a step that fails halfway can leave part of its work behind. Keep each step to one schema change, or write steps that are safe to run again, such as CREATE TABLE IF NOT EXISTS.
Encryption and TLS modes
The client encrypts whenever the server offers TLS, with no setting in the program. Two environment variables change that. SALAM_MYSQL_SSL_MODE is DISABLED (never encrypt), REQUIRED (refuse a server without TLS) or VERIFY_IDENTITY (also check the certificate and the host name); left unset, it encrypts when it can without checking the certificate. SALAM_MYSQL_SSL_CA names the CA file to check against.
import env
import db.mysql
func status(d: mysql.Database, name: str): str:
ret mysql.QueryText(d, "SELECT VARIABLE_VALUE FROM information_schema.SESSION_STATUS WHERE VARIABLE_NAME = '" + name + "'")
end
func main:
mode := env.GetOr("SALAM_MYSQL_SSL_MODE", "(not set)")
println "SALAM_MYSQL_SSL_MODE:", mode
d := mysql.Open("127.0.0.1", "salam", "secret", "shop", 3306)
if not mysql.Ok(d):
println "cannot connect:", mysql.Error(d)
ret 1
end
defer mysql.Close(d)
version := status(d, "Ssl_version")
if version == "":
println "this connection is not encrypted"
else:
println "encrypted with", version, "using", status(d, "Ssl_cipher")
end
ret 0
endThe same program with encryption turned off:
SALAM_MYSQL_SSL_MODE=DISABLED ./guide-tls
SALAM_MYSQL_SSL_MODE: DISABLED
this connection is not encrypted
Use VERIFY_IDENTITY with a CA file whenever the database is on another machine. Without a certificate check, TLS still hides the traffic from someone listening, but not from someone who can pretend to be the server.
Which function for which job
| Job | Use |
|---|---|
| Connect and check | Open, then Ok and Error; Close at the end |
| Run a statement with no rows | Exec, then AffectedRows and LastInsertId |
| One number or one text value | QueryInt, QueryText |
| Many rows | Query, QueryOk, a loop on Next, then Finish |
| A value from a user | d.Quote, or Escape inside quotes you write |
| Tell NULL from empty | IsNull |
| Several writes together | Begin, Commit, Rollback, or db.InTx |
| Why a statement failed | Errno and Error |
| Code that works on any database | Conn and the functions of db |
| Is the server still there | Ping |
LibraryInit, LibraryEnd, ThreadInit and ThreadEnd exist so that code ported from the C API compiles; this client needs none of them. OpenFull takes extra client flags, but its socket argument must be empty, because connections are over TCP only.
Types
Database
struct Database| Name | Type | Description |
|---|---|---|
handle | void* | The pointer to the connection state. |
import db
import db.mysql
func main:
if not mysql.Available():
println "the MySQL client is not available"
ret
end
_ := mysql.LibraryInit()
_ := mysql.ThreadInit()
println mysql.ClientVersion()
d := mysql.Open("127.0.0.1", "app", "s3cret", "shop", 3306)
if not mysql.Ok(d):
println mysql.Errno(d), mysql.Error(d)
ret
end
println mysql.ServerVersion(d), mysql.Ping(d)
_ := mysql.SetCharset(d, "utf8mb4")
_ := mysql.SelectDB(d, "shop")
name := mysql.Escape(d, "O'Brien")
_ := mysql.Exec(d, "INSERT INTO customers (name) VALUES ('" + name + "')")
println mysql.LastInsertId(d), mysql.AffectedRows(d), mysql.Changes(d)
_ := mysql.Autocommit(d, false)
_ := mysql.Begin(d)
_ := mysql.Exec(d, "UPDATE products SET stock = stock - 1 WHERE id = 7")
if mysql.QueryInt(d, "SELECT stock FROM products WHERE id = 7") < 0:
_ := mysql.Rollback(d)
else:
_ := mysql.Commit(d)
end
mut result := mysql.Query(d, "SELECT id, name, price, note FROM products")
if mysql.QueryOk(result):
println mysql.ColumnCount(result), mysql.RowCount(result), mysql.ColumnName(result, 1)
println mysql.FieldType(result, 0), mysql.FieldKind(result, 2) == db.TYPE_FLOAT
until mysql.Next(result):
println mysql.Int(result, 0), mysql.Text(result, 1), mysql.Float(result, 2), mysql.Int64(result, 0), mysql.IsNull(result, 3), mysql.ColumnKind(result, 3) == db.TYPE_NULL
end
mysql.Finish(result)
end
println mysql.QueryText(d, "SELECT VERSION()")
c := mysql.Conn(d)
println c.Kind(), db.QueryInt(c, "SELECT COUNT(*) FROM products")
println d.Kind(), d.Ping(), d.Quote("x'y")
rows := d.Query("SELECT name FROM customers")
until rows.Next():
println rows.Text(0)
end
_ := rows.Close()
mysql.Close(d)
mysql.ThreadEnd()
mysql.LibraryEnd()
_ := mysql.OpenFull("localhost", "app", "s3cret", "shop", 0, "/tmp/mysql.sock", 0 as u64)
endKind
func Kind(): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Ok
func Ok(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Ping
func Ping(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Exec
func Exec(sql: str): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Query
func Query(sql: str): dyn db.RowsThe example for this item is shown with the ones above: db-mysql-Client.salam
Begin
func Begin(): dyn db.TxThe example for this item is shown with the ones above: db-mysql-Client.salam
LastInsertId
func LastInsertId(): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Changes
func Changes(): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Error
func Error(): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Quote
func Quote(s: str): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Close
func Close(): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Result
struct Result| Name | Type | Description |
|---|---|---|
handle | void* | The result pointer. |
row | void* | The current row. |
The example for this item is shown with the ones above: db-mysql-Client.salam
Ok
func Ok(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Next
func Next(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
ColumnCount
func ColumnCount(): intThe example for this item is shown with the ones above: db-mysql-Client.salam
ColumnName
func ColumnName(col: int): strThe example for this item is shown with the ones above: db-mysql-Client.salam
ColumnType
func ColumnType(col: int): intTYPE_NULL when the value in the current row is NULL, otherwise TYPE_TEXT, because the server sends every value as text; see db.The example for this item is shown with the ones above: db-mysql-Client.salam
IsNull
func IsNull(col: int): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Text
func Text(col: int): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Int
func Int(col: int): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Int64
func Int64(col: int): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Float
func Float(col: int): f64The example for this item is shown with the ones above: db-mysql-Client.salam
Close
func Close(): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Txn
struct Txn| Name | Type | Description |
|---|---|---|
handle | void* | The connection pointer. |
The example for this item is shown with the ones above: db-mysql-Client.salam
Ok
func Ok(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Exec
func Exec(sql: str): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Query
func Query(sql: str): dyn db.RowsThe example for this item is shown with the ones above: db-mysql-Client.salam
Commit
func Commit(): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Rollback
func Rollback(): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Functions
Available
func Available(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
ClientVersion
func ClientVersion(): strThe example for this item is shown with the ones above: db-mysql-Client.salam
ServerVersion
func ServerVersion(db: Database): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Open
func Open(host: str, user: str, pass: str, dbname: str, port: int): DatabaseThe example for this item is shown with the ones above: db-mysql-Client.salam
OpenFull
func OpenFull(host: str, user: str, pass: str, dbname: str, port: int, socket: str, flags: u64): DatabaseThe example for this item is shown with the ones above: db-mysql-Client.salam
Ok
func Ok(db: Database): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Close
func Close(db: Database)The example for this item is shown with the ones above: db-mysql-Client.salam
Ping
func Ping(db: Database): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
SelectDB
func SelectDB(db: Database, name: str): intThe example for this item is shown with the ones above: db-mysql-Client.salam
SetCharset
func SetCharset(db: Database, charset: str): intSET NAMES with the given character set; the connection already starts in utf8mb4, so this is only for a different one.The example for this item is shown with the ones above: db-mysql-Client.salam
Error
func Error(db: Database): strOpen, why it failed.The example for this item is shown with the ones above: db-mysql-Client.salam
Errno
func Errno(db: Database): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Exec
func Exec(db: Database, sql: str): intThe example for this item is shown with the ones above: db-mysql-Client.salam
AffectedRows
func AffectedRows(db: Database): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Changes
func Changes(db: Database): i64AffectedRows.The example for this item is shown with the ones above: db-mysql-Client.salam
LastInsertId
func LastInsertId(db: Database): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Begin
func Begin(db: Database): intSTART TRANSACTION.The example for this item is shown with the ones above: db-mysql-Client.salam
Commit
func Commit(db: Database): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Rollback
func Rollback(db: Database): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Autocommit
func Autocommit(db: Database, enabled: bool): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Query
func Query(db: Database, sql: str): ResultQueryOk.The example for this item is shown with the ones above: db-mysql-Client.salam
QueryOk
func QueryOk(res: Result): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Next
func Next(res &: Result): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Finish
func Finish(res: Result)The example for this item is shown with the ones above: db-mysql-Client.salam
ColumnCount
func ColumnCount(res: Result): intThe example for this item is shown with the ones above: db-mysql-Client.salam
RowCount
func RowCount(res: Result): i64The example for this item is shown with the ones above: db-mysql-Client.salam
ColumnName
func ColumnName(res: Result, col: int): strThe example for this item is shown with the ones above: db-mysql-Client.salam
FieldType
func FieldType(res: Result, col: int): intINT, 8 for BIGINT, 5 for DOUBLE, 246 for DECIMAL and 253 for VARCHAR; 0 when the query gave no result.The example for this item is shown with the ones above: db-mysql-Client.salam
FieldKind
func FieldKind(res: Result, col: int): intTYPE_INT for the integer types, TYPE_FLOAT for FLOAT and DOUBLE, and TYPE_TEXT for everything else, DECIMAL included; see db. It does not look at the current row, so a NULL cell still gives the column's kind.The example for this item is shown with the ones above: db-mysql-Client.salam
ColumnKind
func ColumnKind(res: Result, col: int): intFieldKind, except that it gives TYPE_NULL when the value in the current row is NULL.The example for this item is shown with the ones above: db-mysql-Client.salam
Text
func Text(res: Result, col: int): strThe example for this item is shown with the ones above: db-mysql-Client.salam
Int
func Int(res: Result, col: int): intThe example for this item is shown with the ones above: db-mysql-Client.salam
Int64
func Int64(res: Result, col: int): i64The example for this item is shown with the ones above: db-mysql-Client.salam
Float
func Float(res: Result, col: int): f64The example for this item is shown with the ones above: db-mysql-Client.salam
IsNull
func IsNull(res: Result, col: int): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
Escape
func Escape(db: Database, s: str): str% and _ are left as they are.The example for this item is shown with the ones above: db-mysql-Client.salam
QueryInt
func QueryInt(db: Database, sql: str): intThe example for this item is shown with the ones above: db-mysql-Client.salam
QueryText
func QueryText(db: Database, sql: str): strThe example for this item is shown with the ones above: db-mysql-Client.salam
LibraryInit
func LibraryInit(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
LibraryEnd
func LibraryEnd()The example for this item is shown with the ones above: db-mysql-Client.salam
ThreadInit
func ThreadInit(): boolThe example for this item is shown with the ones above: db-mysql-Client.salam
ThreadEnd
func ThreadEnd()The example for this item is shown with the ones above: db-mysql-Client.salam
Conn
func Conn(d: Database): dyn db.ConnectionConnection.The example for this item is shown with the ones above: db-mysql-Client.salam