Wherewith

Muscle memory for AI. Solve it once in Temper, and every language remembers.

SQL written once, for every database and language.

Write a query once, in Wherewith’s common dialect, and run it on SQLite, PostgreSQL, MySQL and MariaDB, and DuckDB, from C++, C#, Java, JavaScript, Lua, Python, and Rust. Wherewith checks that it means the same on each, and writes it the way each one needs.

Written once, in Temper, where word is "Launch"

let q = sql"""
  "SELECT title, "dueDate" FROM tasks
  "WHERE title LIKE ${word} || '%' ESCAPE '!'
  "ORDER BY "dueDate" NULLS LAST
  "LIMIT ${20} OFFSET ${40}
;

Checked once, for all four: bare names are lowercase, since PostgreSQL folds them, so "dueDate" is quoted to keep its case, and a pattern that isn’t a literal needs ESCAPE, since PostgreSQL and MySQL read \ in it as an escape, and SQLite and DuckDB don’t.

What each database’s driver gets, with what differs marked

  • SQLite ? placeholders

    SELECT "title", "dueDate" FROM "tasks"
    WHERE "title" LIKE (? || '%') ESCAPE '!'
    ORDER BY "dueDate" NULLS LAST
    LIMIT ? OFFSET ?

    Sorts nulls first going up, so it keeps NULLS LAST.

  • PostgreSQL $1 placeholders

    SELECT "title", "dueDate" FROM "tasks"
    WHERE "title" LIKE (CAST($1 AS TEXT) || '%') ESCAPE '!'
    ORDER BY "dueDate"
    LIMIT $2 OFFSET $3

    Already sorts nulls last going up, so NULLS LAST goes. The value gets a cast, so || reads it as text, whatever type the driver sends.

  • MySQL and MariaDB %s placeholders

    SELECT `title`, `dueDate` FROM `tasks`
    WHERE `title` LIKE (CONCAT(%s, '%%')) ESCAPE '!'
    ORDER BY `dueDate` IS NULL, `dueDate`
    LIMIT %s OFFSET %s

    || means OR here, so it’s CONCAT. No NULLS LAST, so it sorts by IS NULL first. With %s placeholders, % doubles.

  • DuckDB $1 placeholders

    SELECT "title", "dueDate" FROM "tasks"
    WHERE "title" LIKE ($1 || '%') ESCAPE '!'
    ORDER BY "dueDate" NULLS LAST
    LIMIT $2 OFFSET $3

    Where nulls sort is a setting, so NULLS LAST is always written.

Parameters "Launch", 20, 40 sent apart, for the driver to bind

One query, four databases. Values never become SQL: the driver binds them.

Where dialects differ

The same SQL text can read differently from one database to the next. The common dialect handles these for you, and each database’s own template, for SQL outside it, checks them with that database’s rules. Three places it matters, with examples from Wherewith's tests:

Backslashes in strings

MySQL and MariaDB read \ in a string as an escape, unless NO_BACKSLASH_ESCAPES is on. SQLite, DuckDB, and PostgreSQL (with standard_conforming_strings on, its default) read it as text.

If ignored, a string quoted the standard way can go on past its closing quote in MySQL, since \' is a quote inside the string.

Wherewith escapes backslashes only in the mysql profile, and refuses a MySQL template whose strings the two modes would end in different places. A common query refuses \ in its strings while MySQL is a target: bind that text as a value instead.

quoteString(path), where path is "C:\temp\"

  • sqlite, postgres, duckdb, mysqlNoBackslashEscapes'C:\temp\'
  • mysql'C:\\temp\\'

Template "SELECT ${path} = 'C:\temp\'"

  • sqliteSELECT ? = 'C:\temp\'
  • mysqlRefused: backslash before a quote or backslash in a string

Long or odd names

PostgreSQL cuts names down to 63 bytes, without an error. MySQL and MariaDB reject names over 64 characters, and names ending in whitespace.

If ignored, two long PostgreSQL names that differ only after 63 bytes name the same table.

Wherewith refuses such names up front, for each profile. An Identifier, and every name in a common query, must suit them all.

quoteName(long), where long is "revenue_by_customer_segment_and_acquisition_channel_and_month_v1"

  • postgresRefused: name longer than 63 UTF-8 bytes

quoteName(padded), where padded is "total "

  • mysql, mysqlNoBackslashEscapesRefused: name ends in whitespace
  • sqlite, postgres, duckdb"total "

Nested comments

PostgreSQL and DuckDB nest /* */ comments, and SQLite and MySQL don't, so /* /* */ is a whole comment to SQLite and MySQL, and still open to PostgreSQL and DuckDB.

If ignored, a placeholder that looks like code can be inside a comment, where the database never sees it.

Wherewith reads each template the way its database does, and refuses a hole that would land in a comment. A common query refuses /* inside a comment, and never writes comments out.

Template "SELECT 1 /* /* */, ${name}", where name is "Ann"

  • sqlite, mysqlSELECT 1 /* /* */, ?
  • postgres, duckdbRefused: hole inside a quote or comment

One query, seven languages

In Temper, a common query is an sql template, checked for every database when it runs. Every language gets the same library, built from it, and builds the same query with a QueryBuilder for Dialect.common:

Temper

let word = "Launch";
let q = sql"""
  "SELECT title, "dueDate" FROM tasks
  "WHERE title LIKE ${word} || '%' ESCAPE '!'
  "ORDER BY "dueDate" NULLS LAST
  "LIMIT ${20} OFFSET ${40}
;
let r = q.render(Dialect.postgres, PlaceholderStyle.dollarNumbered) orelse panic();
from wherewith import Dialect, PlaceholderStyle, QueryBuilder

word = "Launch"

# One query, in the common dialect, checked for every database.
b = QueryBuilder(Dialect.common)
b.append_safe('SELECT title, "dueDate" FROM tasks WHERE title LIKE ')
b.append_string(word)
b.append_safe(" || '%' ESCAPE '!' ORDER BY \"dueDate\" NULLS LAST LIMIT ")
b.append_int(20)
b.append_safe(" OFFSET ")
b.append_int(40)
q = b.build()

# Rendered for each database, with the placeholders its driver takes.
print("sqlite: " + q.render(Dialect.sqlite, PlaceholderStyle.question).sql)
print("postgres: " + q.render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql)
print("mysql: " + q.render(Dialect.mysql, PlaceholderStyle.percent_s).sql)
r = q.render(Dialect.duckdb, PlaceholderStyle.dollar_numbered)
print("duckdb: " + r.sql)
# The values travel apart from the SQL, for the driver to bind.
print("params: " + str(len(r.params)))

# What databases would read differently is refused, with its place.
bad = QueryBuilder(Dialect.common)
bad.append_safe("SELECT title, dueDate FROM tasks")
print("refused: " + bad.template_problem)  # and bad.build() raises

Run it in demos/example/py: uv run example.py

Every language prints the same:

sqlite: SELECT "title", "dueDate" FROM "tasks" WHERE "title" LIKE (? || '%') ESCAPE '!' ORDER BY "dueDate" NULLS LAST LIMIT ? OFFSET ?
postgres: SELECT "title", "dueDate" FROM "tasks" WHERE "title" LIKE (CAST($1 AS TEXT) || '%') ESCAPE '!' ORDER BY "dueDate" LIMIT $2 OFFSET $3
mysql: SELECT `title`, `dueDate` FROM `tasks` WHERE `title` LIKE (CONCAT(%s, '%%')) ESCAPE '!' ORDER BY `dueDate` IS NULL, `dueDate` LIMIT %s OFFSET %s
duckdb: SELECT "title", "dueDate" FROM "tasks" WHERE "title" LIKE ($1 || '%') ESCAPE '!' ORDER BY "dueDate" NULLS LAST LIMIT $2 OFFSET $3
params: 3
refused: bare name not lowercase; quote it to keep its case, at line 1, column 15: SELECT title, dueDate FROM tasks

Each program is in demos/example, and CI runs them all and checks their output, so this page shows code that works. The Temper is from the library's own tests.

Why Wherewith

  • Written once, for every database. The common dialect is SQL each database reads the same way. What would differ is written the way each one needs, or refused, with its line and column, when the query is built.
  • Values become placeholders. A template's values go to the driver as parameters, with their types, so a value never becomes SQL, whatever it holds.
  • Names are quoted for the dialect. "students" for PostgreSQL, `students` for MySQL, with each database's own limits. A name that can't be written safely, such as a 64-byte PostgreSQL name, is refused, not cut short.
  • Mistakes are refused, not guessed at. A value inside quotes or a comment, a stray ? or $1 in the text, or text that ends inside a quote: Wherewith says what's wrong instead of building the query.
  • Each database’s own SQL, too. For SQL outside the common dialect, templates for SQLite, PostgreSQL, MySQL and MariaDB, and DuckDB check it with that database's rules. Every query renders with whichever placeholder style the driver takes: ?, $1, %s, @p1, or :p1.
  • Tested against real engines. Its tests run in all seven languages, and round trips run hostile input and random templates through SQLite, DuckDB, PostgreSQL 18, MariaDB 11.8, and MySQL 8.4, checking that quoted strings and names read back exactly, bound values come back equal, and common queries give the same rows on each.

Dialects and placeholders

Placeholder syntax depends on the driver, not only the database, so it's chosen when a query is rendered. Each driver's style, from the design notes:

LanguageSQLitePostgreSQLMySQL and MariaDBDuckDB
In the SQL itself?, ?1, :a, @a, $a$1 (server side)? (server side)?, ?1, $1, $a
C++sqlite3: ?libpq: $1Connector/C: ?DuckDB's C and C++ APIs: ? or $1
C#Microsoft.Data.Sqlite: @p1 or :p1 (no ?)Npgsql: $1 (recommended), or @p1MySqlConnector: ? or @p1DuckDB.NET: ? or $1
JavaJDBC: ?JDBC: ?JDBC: ?JDBC: ?
JavaScriptnode:sqlite, better-sqlite3: ?pg: $1mysql2: ?@duckdb/node-api: ? or $1
Lualsqlite3: ?LuaSQL: none, quoting onlyLuaSQL: none, quoting onlynone common, quoting only
Pythonsqlite3: ?psycopg 3: %s, or $1 with its RawCursor; asyncpg: $1PyMySQL, mysqlclient: %s; MariaDB Connector: ?duckdb: ? or $1
Rustrusqlite: ?tokio-postgres, sqlx: $1mysql, sqlx: ?duckdb: ? or $1

The tour, in seven languages and Temper

One program through the whole API, step by step: writing a query once, binding values, what’s refused and why, what each database needs written differently, nesting, building queries in code, placeholder styles, each database’s own SQL, and quoting by hand. Every version prints exactly the same 86 lines, and CI checks that they do. The Temper writes each query with the sql tag, where the others use a QueryBuilder.

The whole Python tour is in demos/tour/py: uv run tour.py

Setup

from wherewith import Dialect, Identifier, PlaceholderStyle, QueryBuilder, identifier_problem

# Each database, with a placeholder style its drivers take.
databases = [
    ("sqlite", Dialect.sqlite, PlaceholderStyle.question),
    ("postgres", Dialect.postgres, PlaceholderStyle.dollar_numbered),
    ("mysql", Dialect.mysql, PlaceholderStyle.percent_s),
    ("duckdb", Dialect.duckdb, PlaceholderStyle.dollar_numbered),
]


def for_each_database(q):
    for name, dialect, style in databases:
        print(f"{name}: {q.render(dialect, style).sql}")


# A common query is a QueryBuilder for Dialect.common: SQL text with
# append_safe, and values, names, and other queries in holes between. In
# Temper, sql"…" builds the same.
def common(text):
    b = QueryBuilder(Dialect.common)
    b.append_safe(text)
    return b

1. Write it once

b = QueryBuilder(Dialect.common)
b.append_safe("SELECT name, coalesce(nick, name) AS shown FROM users WHERE id IN (")
b.append_int(7)
b.append_safe(", ")
b.append_int(9)
b.append_safe(") ORDER BY name NULLS LAST LIMIT ")
b.append_int(10)
q = b.build()  # raises RuntimeError if the query is refused

# Rendered for Dialect.common, it's the canonical form, for logs.
print("common: " + q.render(Dialect.common, PlaceholderStyle.question).sql)
# PostgreSQL already sorts nulls last going up, so it needs no NULLS LAST,
# and MySQL has no NULLS, so it sorts by name IS NULL first.
for_each_database(q)
# Values print as SQL would write them, for reading only.
print("params: " + ", ".join(str(v) for v in q.params))

It prints:

common: SELECT name, COALESCE(nick, name) AS shown FROM users WHERE id IN (?, ?) ORDER BY name NULLS LAST LIMIT ?sqlite: SELECT "name", COALESCE("nick", "name") AS "shown" FROM "users" WHERE "id" IN (?, ?) ORDER BY "name" NULLS LAST LIMIT ?postgres: SELECT "name", COALESCE("nick", "name") AS "shown" FROM "users" WHERE "id" IN ($1, $2) ORDER BY "name" LIMIT $3mysql: SELECT `name`, COALESCE(`nick`, `name`) AS `shown` FROM `users` WHERE `id` IN (%s, %s) ORDER BY `name` IS NULL, `name` LIMIT %sduckdb: SELECT "name", COALESCE("nick", "name") AS "shown" FROM "users" WHERE "id" IN ($1, $2) ORDER BY "name" NULLS LAST LIMIT $3params: 7, 9, 10

2. Values are bound, never spliced

name = "Robert'); DROP TABLE students; --"
# Building SQL by hand puts the value in the text, where it ends the
# string early and adds a statement of its own.
print("by hand: SELECT id FROM students WHERE name = '" + name + "'")
# A hole sends the value apart from the SQL, for the driver to bind.
b = common("SELECT id FROM students WHERE name = ")
b.append_string(name)
r = b.build().render(Dialect.sqlite, PlaceholderStyle.question)
print("wherewith: " + r.sql)
print("params: " + ", ".join(str(v) for v in r.params))

# A name from outside is an Identifier, quoted for each database, never
# a value or SQL.
b = common("SELECT * FROM ")
b.append_identifier(Identifier.named('students"; DROP TABLE x; --'))
q = b.build()
print("a hostile name: " + q.render(Dialect.sqlite, PlaceholderStyle.question).sql)
print("in MySQL: " + q.render(Dialect.mysql, PlaceholderStyle.percent_s).sql)

# A hole inside quotes is refused: it would be text, not a value.
b = common("SELECT id FROM students WHERE name = '")
b.append_string(name)
b.append_safe("'")
print("a hole in quotes: " + b.template_problem)

It prints:

by hand: SELECT id FROM students WHERE name = 'Robert'); DROP TABLE students; --'wherewith: SELECT "id" FROM "students" WHERE "name" = ?params: 'Robert''); DROP TABLE students; --'a hostile name: SELECT * FROM "students""; DROP TABLE x; --"in MySQL: SELECT * FROM `students"; DROP TABLE x; --`a hole in quotes: hole inside a quote or comment, at line 1, column 39: dents WHERE name = '

3. Refused when it's built

# Each of these runs on every database, and gives different results on
# some. template_problem says why build would refuse it, and where.
def refused(sql):
    print(f"{sql}: {common(sql).template_problem or 'built'}")


# 7 / 2 is 3 in SQLite and PostgreSQL, and 3.5 in MySQL and DuckDB.
refused("SELECT a / 2 FROM t")
# b = NULL matches no row: NULL isn't equal to anything, even NULL.
refused("SELECT a FROM t WHERE b = NULL")
# PostgreSQL reads Name as name; the others keep its case.
refused("SELECT Name FROM t")
# SQLite gives any row's a; the others refuse the query.
refused("SELECT a, count(*) AS n FROM t GROUP BY b")
# SQLite reads ('a' || 1) + 2, PostgreSQL 'a' || (1 + 2).
refused("SELECT 'a' || 1 + 2 AS x")
# Some databases turn 0 into text, and others refuse it.
refused("SELECT CASE WHEN a > 1 THEN 'x' ELSE 0 END AS c FROM t")
# Databases name a + 1 differently (a+1, ?column?, or (a + 1)), so
# an expression in a FROM subquery needs an alias.
refused("SELECT * FROM (SELECT a + 1 FROM t) AS s")
# A placeholder in the text would shift every value's place.
refused("SELECT a FROM t WHERE id = ?")
# What isn't in the common dialect yet is refused too.
refused("SELECT 1 AS a UNION SELECT 2")
# The fix for the first: a float operand divides the same everywhere.
refused("SELECT 7 / 2.0 AS x")
# A null value is refused where NULL would be.
b = common("SELECT a FROM t WHERE b = ")
b.append_string(None)
print("SELECT a FROM t WHERE b = ${null}: " + b.template_problem)

It prints:

SELECT a / 2 FROM t: / without a float operand; databases divide integers differently, at line 1, column 10: SELECT a / 2 FROM tSELECT a FROM t WHERE b = NULL: = NULL is never true; use IS NULL, at line 1, column 25: CT a FROM t WHERE b = NULLSELECT Name FROM t: bare name not lowercase; quote it to keep its case, at line 1, column 8: SELECT Name FROM tSELECT a, count(*) AS n FROM t GROUP BY b: a is neither grouped nor aggregated; databases differ, at line 1, column 8: SELECT a, count(*) AS n FROSELECT 'a' || 1 + 2 AS x: arithmetic beside || needs parentheses; databases group them differently, at line 1, column 17: SELECT 'a' || 1 + 2 AS xSELECT CASE WHEN a > 1 THEN 'x' ELSE 0 END AS c FROM t: CASE mixes text and a number, at line 1, column 38: a > 1 THEN 'x' ELSE 0 END AS c FROM tSELECT * FROM (SELECT a + 1 FROM t) AS s: an expression in a FROM subquery needs an alias; databases name it differently, at line 1, column 23: LECT * FROM (SELECT a + 1 FROM t) AS sSELECT a FROM t WHERE id = ?: ? outside quotes; put values in holes, at line 1, column 28: a FROM t WHERE id = ?SELECT 1 AS a UNION SELECT 2: UNION isn't in the common dialect yet, at line 1, column 15: SELECT 1 AS a UNION SELECT 2SELECT 7 / 2.0 AS x: builtSELECT a FROM t WHERE b = ${null}: = with a null value is never true; use IS NULL, at line 1, column 25: CT a FROM t WHERE b = ${…}

4. What differs, handled

# MySQL's || means OR, so it's CONCAT there.
text = "SELECT id || ':' || name AS tag FROM people"
print(text)
for_each_database(common(text).build())
# Each database names its types differently, and MySQL's LENGTH counts
# bytes, not characters.
text = "SELECT CAST(score AS INTEGER) AS s, CAST(id AS TEXT) AS t, LENGTH(name) AS n FROM people"
print(text)
for_each_database(common(text).build())
# Where nulls sort differs, and so does OFFSET without LIMIT.
b = common("SELECT name FROM people ORDER BY nick DESC OFFSET ")
b.append_int(20)
print("SELECT name FROM people ORDER BY nick DESC OFFSET ${20}")
for_each_database(b.build())
# PostgreSQL can't guess a lone parameter's type, so it gets a cast.
b = common("SELECT ")
b.append_string("hi")
b.append_safe(" AS greeting")
print('SELECT ${"hi"} AS greeting')
for_each_database(b.build())

It prints:

SELECT id || ':' || name AS tag FROM peoplesqlite: SELECT "id" || ':' || "name" AS "tag" FROM "people"postgres: SELECT "id" || ':' || "name" AS "tag" FROM "people"mysql: SELECT CONCAT(`id`, ':', `name`) AS `tag` FROM `people`duckdb: SELECT "id" || ':' || "name" AS "tag" FROM "people"SELECT CAST(score AS INTEGER) AS s, CAST(id AS TEXT) AS t, LENGTH(name) AS n FROM peoplesqlite: SELECT CAST("score" AS INTEGER) AS "s", CAST("id" AS TEXT) AS "t", LENGTH("name") AS "n" FROM "people"postgres: SELECT CAST("score" AS BIGINT) AS "s", CAST("id" AS TEXT) AS "t", LENGTH("name") AS "n" FROM "people"mysql: SELECT CAST(`score` AS SIGNED) AS `s`, CAST(`id` AS CHAR) AS `t`, CHAR_LENGTH(`name`) AS `n` FROM `people`duckdb: SELECT CAST("score" AS BIGINT) AS "s", CAST("id" AS VARCHAR) AS "t", LENGTH("name") AS "n" FROM "people"SELECT name FROM people ORDER BY nick DESC OFFSET ${20}sqlite: SELECT "name" FROM "people" ORDER BY "nick" DESC LIMIT -1 OFFSET ?postgres: SELECT "name" FROM "people" ORDER BY "nick" DESC NULLS LAST OFFSET $1mysql: SELECT `name` FROM `people` ORDER BY `nick` DESC LIMIT 18446744073709551615 OFFSET %sduckdb: SELECT "name" FROM "people" ORDER BY "nick" DESC NULLS LAST OFFSET $1SELECT ${"hi"} AS greetingsqlite: SELECT ? AS "greeting"postgres: SELECT CAST($1 AS TEXT) AS "greeting"mysql: SELECT %s AS `greeting`duckdb: SELECT $1 AS "greeting"

5. Queries in queries

big = common("SELECT user_id FROM orders WHERE total > ")
big.append_int(100)
b = common("SELECT name FROM users WHERE id IN (")
b.append_query(big.build())
b.append_safe(") AND active = ")
b.append_string("yes")
q = b.build()
# The inner query's parameters are numbered with the outer query's.
print("postgres: " + q.render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql)
print("mysql: " + q.render(Dialect.mysql, PlaceholderStyle.percent_s).sql)
# And it's checked again as part of the whole, with problems at its hole.
inner = common("SELECT user_id FROM orders LIMIT ")
inner.append_int(5)
b = common("SELECT name FROM people WHERE id IN (")
b.append_query(inner.build())
b.append_safe(")")
print("with LIMIT inside: " + b.template_problem)

It prints:

postgres: SELECT "name" FROM "users" WHERE "id" IN (SELECT "user_id" FROM "orders" WHERE "total" > $1) AND "active" = $2mysql: SELECT `name` FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > %s) AND `active` = %swith LIMIT inside: LIMIT in an IN subquery; MySQL refuses it, at line 1, column 38: people WHERE id IN (${…})

6. Building queries in code

# Filters only when given, a sort column from the caller, and a page.
# Every variant is still checked when it's built.
def search(word=None, min_total=None, sort=Identifier.named("created"), descending=False, page=0):
    b = common("SELECT id, title FROM orders")
    joiner = " WHERE "
    if word is not None:
        b.append_safe(joiner + "title LIKE ")
        b.append_string(word)
        b.append_safe(" || '%' ESCAPE '!'")
        joiner = " AND "
    if min_total is not None:
        b.append_safe(joiner + "total >= ")
        b.append_int(min_total)
    b.append_safe(" ORDER BY ")
    b.append_identifier(sort)
    b.append_safe(" DESC LIMIT " if descending else " LIMIT ")
    b.append_int(20)
    b.append_safe(" OFFSET ")
    b.append_int(page * 20)
    return b.build().render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql


print(search())
print(search(word="Launch"))
print(search(min_total=100, sort=Identifier.named("total"), descending=True, page=2))

# A query for fewer databases relaxes the checks for the others: MySQL
# reads \ in a string as an escape, so it's refused only while MySQL is
# a target. The query renders only for its targets.
b = QueryBuilder(Dialect.common)
b.targets = [Dialect.postgres, Dialect.sqlite]
b.append_safe("SELECT 'C:\\temp' AS p")
q = b.build()
print("for postgres and sqlite: " + q.render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql)
print("for mysql: " + q.render_problem(Dialect.mysql, PlaceholderStyle.percent_s))

It prints:

SELECT "id", "title" FROM "orders" ORDER BY "created" NULLS FIRST LIMIT $1 OFFSET $2SELECT "id", "title" FROM "orders" WHERE "title" LIKE (CAST($1 AS TEXT) || '%') ESCAPE '!' ORDER BY "created" NULLS FIRST LIMIT $2 OFFSET $3SELECT "id", "title" FROM "orders" WHERE "total" >= $1 ORDER BY "total" DESC NULLS LAST LIMIT $2 OFFSET $3for postgres and sqlite: SELECT 'C:\temp' AS "p"for mysql: common query checked for postgres, sqlite, not mysql

7. Placeholders for each driver

# The placeholder style depends on the driver, not the database, so it's
# chosen when rendering. percent_s doubles %, for drivers that fill in
# values with Python's % formatting.
b = common("SELECT name FROM users WHERE name LIKE 'a%' ESCAPE '!' AND id = ")
b.append_int(1)
q = b.build()
styles = [
    PlaceholderStyle.question,
    PlaceholderStyle.dollar_numbered,
    PlaceholderStyle.percent_s,
    PlaceholderStyle.at_named,
    PlaceholderStyle.colon_named,
]
for style in styles:
    print(f"{style}: {q.render(Dialect.sqlite, style).sql}")
# A named style refuses text holding its marker, which a driver could
# misread as a name.
b = common("SELECT 'a:b' AS s FROM users WHERE id = ")
b.append_int(1)
print("colonNamed with 'a:b': " + b.build().render_problem(Dialect.sqlite, PlaceholderStyle.colon_named))

It prints:

question: SELECT "name" FROM "users" WHERE "name" LIKE 'a%' ESCAPE '!' AND "id" = ?dollarNumbered: SELECT "name" FROM "users" WHERE "name" LIKE 'a%' ESCAPE '!' AND "id" = $1percentS: SELECT "name" FROM "users" WHERE "name" LIKE 'a%%' ESCAPE '!' AND "id" = %satNamed: SELECT "name" FROM "users" WHERE "name" LIKE 'a%' ESCAPE '!' AND "id" = @p1colonNamed: SELECT "name" FROM "users" WHERE "name" LIKE 'a%' ESCAPE '!' AND "id" = :p1colonNamed with 'a:b': text has :, which colonNamed uses

8. Each database's own SQL

# For SQL the common dialect doesn't cover yet, a database's own
# template, checked with its rules. In Temper, postgres"…" builds it.
b = QueryBuilder(Dialect.postgres)
b.append_safe("INSERT INTO tags (name) VALUES (")
b.append_string("sql")
b.append_safe(") ON CONFLICT (name) DO NOTHING RETURNING id")
q = b.build()
print("upsert: " + q.render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql)
# It renders only for its own database.
print("for mysql: " + q.render_problem(Dialect.mysql, PlaceholderStyle.percent_s))
# A common query nests in it, rendered for that database.
b = QueryBuilder(Dialect.postgres)
b.append_safe("SELECT jsonb_agg(x) FROM (")
b.append_query(big.build())
b.append_safe(") AS s(x)")
print("nested: " + b.build().render(Dialect.postgres, PlaceholderStyle.dollar_numbered).sql)
# # starts a comment in MySQL only, so a value after it would vanish.
b = QueryBuilder(Dialect.mysql)
b.append_safe("SELECT * FROM t WHERE a = 1 # ")
b.append_string("x")
print("mysql, after #: " + b.template_problem)

It prints:

upsert: INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING idfor mysql: query checked for postgres, not mysqlnested: SELECT jsonb_agg(x) FROM (SELECT "user_id" FROM "orders" WHERE "total" > $1) AS s(x)mysql, after #: hole inside a quote or comment

9. Quoting by hand

# For tools that write SQL text themselves, such as DDL defaults. Each
# raises RuntimeError rather than change what it can't write, and a
# _problem function says why.
print("mysql name: " + Dialect.mysql.quote_name("my `table`"))
print("mysql string: " + Dialect.mysql.quote_string("it's \\ fine"))
print("postgres string: " + Dialect.postgres.quote_string("it's \\ fine"))
long_name = "n" * 64
print("postgres, 64-byte name: " + Dialect.postgres.name_problem(long_name))
try:
    Dialect.postgres.quote_string("a\0b")
    print("postgres, NUL in a string: quoted")
except RuntimeError:
    print("postgres, NUL in a string: refused, " + Dialect.postgres.string_problem("a\0b"))
try:
    Identifier.named(long_name)
    print("Identifier, 64 bytes: named")
except RuntimeError:
    print("Identifier, 64 bytes: refused, " + identifier_problem(long_name))

It prints:

mysql name: `my ``table```mysql string: 'it''s \\ fine'postgres string: 'it''s \ fine'postgres, 64-byte name: name longer than 63 UTF-8 bytespostgres, NUL in a string: refused, NUL in stringIdentifier, 64 bytes: refused, name longer than 63 UTF-8 bytes

Runs the JavaScript tour in this page, and compares what it prints with every other language.

The tour stops at what the driver gets. Wherewith’s tests go further, running such queries through SQLite, DuckDB, PostgreSQL, and MariaDB.