Standard library · Database

db/mysql

Connecting to MySQL and MariaDB.

import db.mysql

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.

db-mysql-connect.salam
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
end
Outputserver: 11.8.8-MariaDB-ubu2404 client: salam-native alive: true database: shop charset: utf8mb4 tls: TLSv1.3 with a wrong password: false Access denied for user 'salam'@'127.0.0.1' (using password: YES) Can't connect to server on '127.0.0.1' port 3307

The 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.

db-mysql-schema.salam
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
end
Outputbooks: 0 orders: 0 books has 7 columns orders has 5 columns

The 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.

db-mysql-insert.salam
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
end
Outputfirst book id: 1 second book id: 2 rows added: 3 id of the first row of that insert: 3 books now: 5 stock in total: 20

Inside 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.

db-mysql-structs.salam
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
end
Outputcolumns: id, title, author, price, stock 1 Master and Commander by Patrick O'Brian costs 14.50 with 4 in stock 2 The Remains of the Day by Kazuo Ishiguro costs 11.25 with 2 in stock 3 A Wizard of Earthsea by Ursula K. Le Guin costs 9.99 with 6 in stock 4 The Blind Owl by Sadegh Hedayat costs 8.00 with 3 in stock 5 شازده احتجاب by هوشنگ گلشیری costs 7.50 with 5 in stock under 9: 2

The 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.

db-mysql-safe.salam
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
end
Outputby Patrick O'Brian: 1 by هوشنگ گلشیری: 1 quoted: 'nobody\' OR \'1\'=\'1' safe query finds: 0 pasted query finds: 5 titles with 'the': 2 titles with '%': 0

The 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.

db-mysql-null.salam
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
end
OutputMaster and Commander | 9780393307054 | 412 pages The Remains of the Day | 9780571200733 | unknown A Wizard of Earthsea | (no isbn) | 183 pages The Blind Owl | (no isbn) | unknown شازده احتجاب | (no isbn) | 120 pages IS NULL finds: 3 = NULL finds: 0 Int of NULL: 0 COALESCE: none

In 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.

db-mysql-update.salam
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
end
Outputrestocked: 1 repriced: 2 setting the same value again changes: 0 deleted: 1 1 Master and Commander 14.50 4 2 The Remains of the Day 11.25 12 3 A Wizard of Earthsea 9.99 6 5 شازده احتجاب 8.25 5

MySQL 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.

db-mysql-tx.salam
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
end
OutputSara, 2 x book 3: true Reza, 1 x book 1: true Neda, 3 x book 5: true Ali, 9 x book 1: false Omid, 1 x book 99: false orders: 3 stock of book 1: 3 stock of book 3: 4 takings: 59.23

Ali 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.

db-mysql-errors.salam
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
end
Outputerror 1062 -> Duplicate entry 'A Wizard of Earthsea' for key 'title' error 1452 -> Cannot add or update a child row: a foreign key constraint fails (`shop`.`orders`, CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`)) error 1364 -> Field 'price' doesn't have a default value error 1064 -> You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'SELEC title FROM books' at line 1 ok query failed: 1146 Table 'shop.authors' doesn't exist still connected: true stock of book 1: 3

An 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.

db-mysql-pool.salam
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
end
Outputsize: 3 idle: 3 kind: mysql in use: 2 separate sessions: true restock in one transaction: true stock of book 1 seen from the other connection: 8 a fourth lease: false idle again: 3 dead connections dropped: 0

Each 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.

db-mysql-report.salam
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
end
Output3 books sold شازده احتجاب | copies 3 | takings 24.75 | customers 1 A Wizard of Earthsea | copies 2 | takings 19.98 | customers 1 Master and Commander | copies 1 | takings 14.50 | customers 1 never sold: The Remains of the Day about the sea: Master and Commander pages on the shelf: 5183 stock value: 357.41

The 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.

db-mysql-guide-upsert.salam
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
end
Outputaffected rows: 4 books now: 6 Earthsea stock: 13

For 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.

db-mysql-guide-migrate.salam
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
end
Outputversion before: 0 pending: 2 applied: 2 version after: 2 applied on a second run: 0 review columns: 4 rolled back: true version now: 1 migrated to 0: 1 reviews table left: 0

The 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.

db-mysql-guide-tls.salam
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
end
OutputSALAM_MYSQL_SSL_MODE: (not set) encrypted with TLSv1.3 using TLS_CHACHA20_POLY1305_SHA256

The 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

JobUse
Connect and checkOpen, then Ok and Error; Close at the end
Run a statement with no rowsExec, then AffectedRows and LastInsertId
One number or one text valueQueryInt, QueryText
Many rowsQuery, QueryOk, a loop on Next, then Finish
A value from a userd.Quote, or Escape inside quotes you write
Tell NULL from emptyIsNull
Several writes togetherBegin, Commit, Rollback, or db.InTx
Why a statement failedErrno and Error
Code that works on any databaseConn and the functions of db
Is the server still therePing

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
One MySQL connection.
NameTypeDescription
handlevoid*The pointer to the connection state.
db-mysql-Client.salam
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)
end

Kind

func Kind(): str
The name of the database engine.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ok

func Ok(): bool
It is healthy.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ping

func Ping(): bool
The connection is alive.

The example for this item is shown with the ones above: db-mysql-Client.salam

Exec

func Exec(sql: str): int
Runs one statement; zero means success.

The example for this item is shown with the ones above: db-mysql-Client.salam

Query

func Query(sql: str): dyn db.Rows
Runs a query and returns the rows.

The example for this item is shown with the ones above: db-mysql-Client.salam

Begin

func Begin(): dyn db.Tx
Starts a transaction.

The example for this item is shown with the ones above: db-mysql-Client.salam

LastInsertId

func LastInsertId(): i64
The id of the last inserted row.

The example for this item is shown with the ones above: db-mysql-Client.salam

Changes

func Changes(): i64
The number of rows the last statement changed.

The example for this item is shown with the ones above: db-mysql-Client.salam

Error

func Error(): str
The last error message.

The example for this item is shown with the ones above: db-mysql-Client.salam

Quote

func Quote(s: str): str
Returns the string quoted and escaped, safe for SQL.

The example for this item is shown with the ones above: db-mysql-Client.salam

Close

func Close(): int
Closes and frees it.

The example for this item is shown with the ones above: db-mysql-Client.salam

Result

struct Result
The result of one query.
NameTypeDescription
handlevoid*The result pointer.
rowvoid*The current row.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ok

func Ok(): bool
It is healthy.

The example for this item is shown with the ones above: db-mysql-Client.salam

Next

func Next(): bool
Moves to the next row; false when no row is left.

The example for this item is shown with the ones above: db-mysql-Client.salam

ColumnCount

func ColumnCount(): int
The number of columns.

The example for this item is shown with the ones above: db-mysql-Client.salam

ColumnName

func ColumnName(col: int): str
The column name.

The example for this item is shown with the ones above: db-mysql-Client.salam

ColumnType

func ColumnType(col: int): int
TYPE_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): bool
The column value is null.

The example for this item is shown with the ones above: db-mysql-Client.salam

Text

func Text(col: int): str
The column value as text.

The example for this item is shown with the ones above: db-mysql-Client.salam

Int

func Int(col: int): int
The column value as an integer.

The example for this item is shown with the ones above: db-mysql-Client.salam

Int64

func Int64(col: int): i64
The column value as a 64 bit integer.

The example for this item is shown with the ones above: db-mysql-Client.salam

Float

func Float(col: int): f64
The column value as a float.

The example for this item is shown with the ones above: db-mysql-Client.salam

Close

func Close(): int
Closes and frees it.

The example for this item is shown with the ones above: db-mysql-Client.salam

Txn

struct Txn
One transaction.
NameTypeDescription
handlevoid*The connection pointer.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ok

func Ok(): bool
It is healthy.

The example for this item is shown with the ones above: db-mysql-Client.salam

Exec

func Exec(sql: str): int
Runs one statement; zero means success.

The example for this item is shown with the ones above: db-mysql-Client.salam

Query

func Query(sql: str): dyn db.Rows
Runs a query and returns the rows.

The example for this item is shown with the ones above: db-mysql-Client.salam

Commit

func Commit(): int
Commits the transaction.

The example for this item is shown with the ones above: db-mysql-Client.salam

Rollback

func Rollback(): int
Rolls the transaction back.

The example for this item is shown with the ones above: db-mysql-Client.salam

Functions

Available

func Available(): bool
The MySQL client is available; since it is inside Salam itself, this is always true.

The example for this item is shown with the ones above: db-mysql-Client.salam

ClientVersion

func ClientVersion(): str
The name and version of the client.

The example for this item is shown with the ones above: db-mysql-Client.salam

ServerVersion

func ServerVersion(db: Database): str
The server version.

The 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): Database
Connects to the server.

The 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): Database
Connects with extra client flags. Pass an empty socket: Unix sockets are not supported, and a socket path makes the call fail.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ok

func Ok(db: Database): bool
The connection is up.

The example for this item is shown with the ones above: db-mysql-Client.salam

Close

func Close(db: Database)
Closes the connection.

The example for this item is shown with the ones above: db-mysql-Client.salam

Ping

func Ping(db: Database): bool
The connection is alive.

The example for this item is shown with the ones above: db-mysql-Client.salam

SelectDB

func SelectDB(db: Database, name: str): int
Changes the current database.

The example for this item is shown with the ones above: db-mysql-Client.salam

SetCharset

func SetCharset(db: Database, charset: str): int
Runs SET 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): str
The last error message; after a failed Open, why it failed.

The example for this item is shown with the ones above: db-mysql-Client.salam

Errno

func Errno(db: Database): int
The server's code for the last error, such as 1062 for a duplicate key; -1 when there is no connection.

The example for this item is shown with the ones above: db-mysql-Client.salam

Exec

func Exec(db: Database, sql: str): int
Runs one statement; 0 means success and -1 failure.

The example for this item is shown with the ones above: db-mysql-Client.salam

AffectedRows

func AffectedRows(db: Database): i64
The number of rows changed.

The example for this item is shown with the ones above: db-mysql-Client.salam

Changes

func Changes(db: Database): i64
The same as AffectedRows.

The example for this item is shown with the ones above: db-mysql-Client.salam

LastInsertId

func LastInsertId(db: Database): i64
The id of the last inserted row.

The example for this item is shown with the ones above: db-mysql-Client.salam

Begin

func Begin(db: Database): int
Starts a transaction with START TRANSACTION.

The example for this item is shown with the ones above: db-mysql-Client.salam

Commit

func Commit(db: Database): int
Commits it.

The example for this item is shown with the ones above: db-mysql-Client.salam

Rollback

func Rollback(db: Database): int
Rolls it back.

The example for this item is shown with the ones above: db-mysql-Client.salam

Autocommit

func Autocommit(db: Database, enabled: bool): int
Turns committing every statement by itself on or off.

The example for this item is shown with the ones above: db-mysql-Client.salam

Query

func Query(db: Database, sql: str): Result
Runs a query and reads every row of the result; check it with QueryOk.

The example for this item is shown with the ones above: db-mysql-Client.salam

QueryOk

func QueryOk(res: Result): bool
The query gave a result.

The example for this item is shown with the ones above: db-mysql-Client.salam

Next

func Next(res &: Result): bool
Moves to the next row.

The example for this item is shown with the ones above: db-mysql-Client.salam

Finish

func Finish(res: Result)
Frees the result.

The example for this item is shown with the ones above: db-mysql-Client.salam

ColumnCount

func ColumnCount(res: Result): int
The number of columns.

The example for this item is shown with the ones above: db-mysql-Client.salam

RowCount

func RowCount(res: Result): i64
The number of rows in the result.

The example for this item is shown with the ones above: db-mysql-Client.salam

ColumnName

func ColumnName(res: Result, col: int): str
The column name.

The example for this item is shown with the ones above: db-mysql-Client.salam

FieldType

func FieldType(res: Result, col: int): int
The MySQL type code of the column, such as 3 for INT, 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): int
The kind of the column from its declared type: TYPE_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): int
The same as FieldKind, 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): str
The column value as text.

The example for this item is shown with the ones above: db-mysql-Client.salam

Int

func Int(res: Result, col: int): int
As an integer.

The example for this item is shown with the ones above: db-mysql-Client.salam

Int64

func Int64(res: Result, col: int): i64
As a 64 bit integer.

The example for this item is shown with the ones above: db-mysql-Client.salam

Float

func Float(res: Result, col: int): f64
As a float.

The example for this item is shown with the ones above: db-mysql-Client.salam

IsNull

func IsNull(res: Result, col: int): bool
It is null.

The example for this item is shown with the ones above: db-mysql-Client.salam

Escape

func Escape(db: Database, s: str): str
Escapes the special characters of a string for SQL; put the quotes around it yourself. % 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): int
The first column of the first row as an integer; 0 when there is no row or the query fails.

The example for this item is shown with the ones above: db-mysql-Client.salam

QueryText

func QueryText(db: Database, sql: str): str
The first column of the first row as text; empty when there is no row or the query fails.

The example for this item is shown with the ones above: db-mysql-Client.salam

LibraryInit

func LibraryInit(): bool
Readies the client; kept for compatibility and nothing else is needed.

The example for this item is shown with the ones above: db-mysql-Client.salam

LibraryEnd

func LibraryEnd()
Ends the client; kept for compatibility.

The example for this item is shown with the ones above: db-mysql-Client.salam

ThreadInit

func ThreadInit(): bool
Kept for compatibility with the C API; a thread needs no setup. It returns false, which the C API uses for success.

The example for this item is shown with the ones above: db-mysql-Client.salam

ThreadEnd

func ThreadEnd()
Kept for compatibility; there is nothing to free.

The example for this item is shown with the ones above: db-mysql-Client.salam

Conn

func Conn(d: Database): dyn db.Connection
Returns the connection as a shared Connection.

The example for this item is shown with the ones above: db-mysql-Client.salam