The sql provider lets a scenario reach into the database to perform the few operations the public HTTP / mobile API does not allow: flip an internal feature flag, bump a counter, mark an account as verified, or read back a server-side value that is never returned over the wire. Once that state is in place, the behaviour itself is asserted through the regular HTTP / UI surface, the same way a real user would experience it.
Other drivers are rejected at runtime with unsupported sql driver "<name>". Each step author writes the placeholder syntax their driver expects. Tales does not rewrite SQL between dialects.
Connections live under config.sql.connections.<name>. They are opened lazily on first use and closed when the suite ends.
config {
sql ={
connections= {
app= {
driver="postgres"
dsn=env("DATABASE_URL")
}
}
}
}
env("DATABASE_URL", "") is the recommended pattern so the same suite can run locally (with a Docker container) and in CI (with a managed instance). Pair it with skip_unless { env_set = ["DATABASE_URL"] } if the SQL preconditions are optional. See Conditional execution.
Tales injects a default dial timeout into the DSN when none is set, so a missing or unreachable database fails fast instead of stalling on the OS TCP retry stack (~127s on Linux when SYN packets are dropped):
MySQL: timeout=10s is appended unless the DSN already carries timeout=....
Postgres: connect_timeout=10 is appended unless the DSN already carries connect_timeout=... (both URL and key=value flavors).
The user-supplied value always wins. The first time a connection is used, Tales also runs a bounded PingContext (10s, or sooner if --timeout / per-step request.timeout is stricter) and surfaces a clear error if connectivity is broken. The connection is not cached when ping fails.
sql ="SELECT id, vip FROM organizations WHERE id = $1"
args =[result.create_org.id]
}
expect {
json ={
row_count=1
rows= [
{
id= result.create_org.id
vip=true
},
]
}
}
capture {
vip =response.json.rows[0].vip
}
}
Query response shape:
{
"row_count": 1,
"columns": ["id", "vip"],
"rows": [
{ "id": "org_123", "vip": true }
]
}
rows is a list of objects keyed by column name. Always alias your columns when joining two tables that share a name: duplicate column names fail the step with duplicate SQL column "id"; use aliases in query.
[]byte columns are decoded as UTF-8 strings; non-UTF-8 bytes cause an explicit error rather than silent corruption.
time.Time columns are returned as RFC3339Nano strings.
PostgreSQL jsonb columns come back as strings. Use HCL’s jsondecode(response.json.rows[0].metadata).field to descend into them.
The SQL provider is teardown-friendly. Combine it with the standard when = can(...) pattern so the cleanup only runs when the prerequisite captured value exists:
teardown {
step "sql""reset_org_vip" {
when = can(result.create_org.id)
connection ="app"
exec {
sql ="UPDATE organizations SET vip = $1 WHERE id = $2"
Args are bound through database/sql as positional parameters; Tales never interpolates them into the SQL text.
HCL value
Bound as
null
SQL NULL
true / false
bool
integer
int64 (preserves bigint precision)
non-integer
float64
string
string
Lists, tuples and objects are rejected explicitly: unsupported SQL arg type at args[<i>]: <type>. Wrap them in a string representation (JSON, comma-separated) before binding if you need to pass a composite value.
DSNs are masked through a centralised MaskDSN helper. They never appear in reports: only the connection name, driver alias, SQL text and the count of args do.
Error wrappers replace any embedded DSN substring with ***.
Args are reported in Output.Request.args exactly as written. If a step binds a secret, mark the value as such on the call site (capture it from a previous step’s response so it is never inlined in the .tales file).
SQL steps appear with provider: "sql" in the JSONL, JUnit and HTML reports. The request structure carries connection, mode (exec / query), sql and args; the response carries the json payload described above.