feat: PostgreSQL driver support

This commit is contained in:
fsylvain committed 2026-10-06 13:35:10 +02:00
1 parent eb9bfabed5
commit dc5d12348c
24 files changed
+1578 -68

No files matched your search

+2 -1
View File
@@ -19,11 +19,12 @@ RENEWCMS_CONTAINER=false
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=3306
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
+40
View File
@@ -87,6 +87,46 @@ tasks:
exit $STATUS
silent: true
test:postgres:
desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-postgres
STATUS=0
run_engine() {
NAME="renewcms-test-postgres-$1"
IMAGE="postgres:$1"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine 16 || STATUS=1
run_engine 17 || STATUS=1
exit $STATUS
silent: true
test:s3:
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
cmds:
+21
View File
@@ -141,6 +141,8 @@ func databaseConfig(settings config.Settings) database.Config {
switch settings.Database.Driver {
case config.DatabaseDriverMySQL:
return mysqlDatabaseConfig(settings.Database)
case config.DatabaseDriverPostgres:
return postgresDatabaseConfig(settings.Database)
case "", config.DatabaseDriverSQLite:
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
default:
@@ -167,6 +169,25 @@ func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
}
}
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverPostgres,
Source: database.PostgresDSN(database.PostgresConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
SSLMode: settings.SSLMode,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(databaseConfig(settings))
if err != nil {
+1 -1
View File
@@ -46,8 +46,8 @@ var envDefaults = map[string]string{
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_PORT": config.DefaultDatabasePort,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: renewcms
POSTGRES_USER: renewcms
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U renewcms -d renewcms"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- docker.env
environment:
DB_DRIVER: postgres
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: renewcms
DB_USER: renewcms
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
DB_SSLMODE: disable
DATA_DIR: /data
UPLOAD_DIR: /data/uploads
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
postgres-data:
+2 -1
View File
@@ -14,11 +14,12 @@ TRUSTED_PROXIES=
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=3306
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
+5 -3
View File
@@ -82,7 +82,7 @@ Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `CreateImageUse
SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is `DATA_DIR` when set and the directory of `DB_FILE` otherwise, so it does not depend on where the database is.
Two drivers are registered in `database.drivers`: `sqlite` (default) and `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`, pure Go so `CGO_ENABLED=0` and the six release targets still build). `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver "<name>"`. `database.MySQLDSN` builds the DSN from separate variables, and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers:
Three drivers are registered in `database.drivers`: `sqlite` (default), `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`) and `postgres` (PostgreSQL 16 and 17, alias `postgresql`, through `gorm.io/driver/postgres` on pgx v5); all are pure Go so `CGO_ENABLED=0` and the six release targets still build. `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver "<name>"`. `database.MySQLDSN` and `database.PostgresDSN` build the DSN from separate variables (the PostgreSQL one as an escaped URL with `sslmode`, `timezone=UTC` and a connect timeout), and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers:
- Indexed and unique strings carry `size:` tags in the models (a MySQL index needs a bounded `varchar`). SQLite maps them to the same `text` column, so an existing SQLite file migrates with no schema or row change (`test/sqlite_schema_unchanged_test.go` builds a file from the old model definitions and compares `sqlite_master` and every row).
- `Migrate` creates MySQL tables with `utf8mb4_bin` (`gorm:table_options`), so `Bob` and `bob` are distinct like in SQLite. The DSN also sets `collation=utf8mb4_bin`.
@@ -90,9 +90,11 @@ Two drivers are registered in `database.drivers`: `sqlite` (default) and `mysql`
- The Go driver sends a zero `time.Time` as `0000-00-00`, which MySQL's default `NO_ZERO_DATE` rejects, so each session sets `sql_mode` to the server default minus `NO_ZERO_DATE`. Reading `0000-00-00` gives the zero time back.
- `UserRepository.Create` returns the filled model instead of re-reading it, because MySQL has no `RETURNING`.
Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. Both keep the newest 3 files (`pruneBackups`).
On PostgreSQL the MySQL-only table options are not applied, pgx returns `timestamptz` values in the local zone so a query callback in the driver converts them to UTC, and only the `public` schema is used.
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each.
Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. PostgreSQL reuses `DumpBackup` with its own `dumpDialect` (double-quoted identifiers, doubled single quotes, `\x` bytea literals): `CREATE TABLE` is rebuilt from `pg_catalog` (serial columns, defaults, primary key, unique and check constraints), followed by the indexes, the rows and a `setval` per `id` sequence, all inside one repeatable-read read-only transaction. All drivers keep the newest 3 files (`pruneBackups`).
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each. The same contract tests run on PostgreSQL when `RENEWCMS_TEST_POSTGRES_DSN` points at a server (admin DSN, for example `postgres://postgres:pw@127.0.0.1:5432/postgres?sslmode=disable`), and `task test:postgres` starts `postgres:16` then `postgres:17` in Docker and runs the `Postgres` tests against each.
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
+12 -5
View File
@@ -24,9 +24,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
| `DB_DRIVER` | `sqlite` | `sqlite` or `mysql` (MySQL and MariaDB). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER=mysql`. |
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `DB_DRIVER` | `sqlite` | `sqlite`, `mysql` (MySQL and MariaDB) or `postgres` (alias `postgresql`). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER` is `mysql` or `postgres`. |
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
@@ -72,22 +72,29 @@ The pre-update backup covers the database only (and `DATA_DIR` state). Neither t
## Database
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER=mysql`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER` is `mysql` or `postgres`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
| Name | Default | Description |
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
| `DB_HOST` | required | Server host name or IP address. |
| `DB_PORT` | `3306` | Server port. |
| `DB_PORT` | `3306` (`5432` for PostgreSQL) | Server port. |
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
| `DB_SSLMODE` | `disable` | PostgreSQL only: `disable`, `require`, `verify-ca` or `verify-full`. Any other value stops the server with an error naming `DB_SSLMODE` and these values. Ignored for SQLite and MySQL (use `DB_TLS` there). |
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
### PostgreSQL
Set `DB_DRIVER=postgres` (or `postgresql`) and the same connection variables, with `DB_PORT` defaulting to `5432` and `DB_SSLMODE` instead of `DB_TLS`. PostgreSQL 16 and 17 are tested. The database must already exist and `DB_USER` needs full rights on the `public` schema, which is the only schema used. The connection string is built with escaping from the variables (a password may contain spaces, quotes, `@` or `/`), sets the session time zone to UTC and a 5 second connect timeout, and the pool variables above apply. A failure to connect stops startup with `Unable to open the database`, naming the host and port but never the password. Usernames and emails compare case-sensitively, as on SQLite. Before an update download RenewCMS writes a logical dump to `DATA_DIR/backups/renewcms-<time>.sql` (the newest 3 are kept); replay it into an empty database with `psql -f`. For routine backups prefer `pg_dump`.
### MySQL settings
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
+18 -2
View File
@@ -8,7 +8,7 @@ RenewCMS ships as **one self-contained executable**. It serves:
| `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB or PostgreSQL server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb) and [Using PostgreSQL](#using-postgresql)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
@@ -292,6 +292,22 @@ DATA_DIR=/var/lib/renewcms
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
## Using PostgreSQL
To use an existing PostgreSQL 16 or 17 server, create an empty database and a user with full rights on its `public` schema, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=postgres
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_SSLMODE=require
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored, and `DB_PORT` defaults to `5432`. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir`. Use `DB_SSLMODE=require` (or `verify-full`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup. Moving an existing SQLite site is a manual procedure like the MySQL one below, and RenewCMS writes a logical `.sql` dump to `DATA_DIR/backups` before each update download.
### Moving an existing SQLite site to MySQL
There is no migration tool. The manual procedure, with the site stopped:
@@ -305,7 +321,7 @@ Keep the SQLite file until you have checked the site.
## Backups
With MySQL or MariaDB, back the database up with your server's own tools (for example `mysqldump --single-transaction`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
With MySQL, MariaDB or PostgreSQL, back the database up with your server's own tools (for example `mysqldump --single-transaction` or `pg_dump`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
+1
View File
@@ -56,6 +56,7 @@ If your editor reports `pattern all:dist: no matching files found`, either run `
task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task test:mysql # MySQL tests against real servers in Docker (mysql:8.4, then mariadb:11)
task test:postgres # PostgreSQL tests against real servers in Docker (postgres:16, then postgres:17)
task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
+11
View File
@@ -102,6 +102,17 @@ To use MariaDB, change the `mysql` service image to `mariadb:11`, its `MYSQL_*`
Back the database up with `docker compose -f docker-compose.mysql.yml exec mysql mysqldump ...` (a `tar` copy of the volume is not consistent while it runs), and the `renewcms-data` volume as described above. Moving an existing SQLite volume is a manual procedure, see [deployment.md](deployment.md#moving-an-existing-sqlite-site-to-mysql).
## PostgreSQL
[`docker-compose.postgres.yml`](../docker-compose.postgres.yml) runs RenewCMS next to a PostgreSQL 17 container:
```sh
export DB_PASSWORD=<generated>
docker compose -f docker-compose.postgres.yml up -d
```
It starts the `postgres` service with a named volume `postgres-data` and a `pg_isready` healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=postgres`, `DB_HOST=postgres`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DB_SSLMODE=disable` (the traffic stays on the Compose network) from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume. For a server on another machine drop the `postgres` service, point `DB_HOST` at it and set `DB_SSLMODE=require`. Back the database up with `docker compose -f docker-compose.postgres.yml exec postgres pg_dump -U renewcms renewcms`. Replace the placeholders; never commit real passwords.
## S3-compatible storage (SeaweedFS)
[`docker-compose.s3.yml`](../docker-compose.s3.yml) runs RenewCMS with `STORAGE_DRIVER=s3` next to a [SeaweedFS](https://github.com/seaweedfs/seaweedfs) server (Apache 2.0, image `chrislusf/seaweedfs`, override with `SEAWEEDFS_IMAGE`) with its S3 gateway enabled, and a one-shot `seaweedfs-init` step that creates the bucket with `weed shell`:
+153
View File
@@ -0,0 +1,153 @@
Feature: Optional PostgreSQL database driver
An operator who prefers PostgreSQL runs RenewCMS against it by setting
environment variables. SQLite stays the default, and MySQL/MariaDB behaves
exactly as before.
Background:
Given the database drivers are registered by name in database.drivers
And the database is chosen by the DB_DRIVER environment variable
And repositories, use cases and handlers are unchanged
Scenario Outline: Driver selection
Given DB_DRIVER is "<value>"
When the server starts
Then the outcome is "<outcome>"
Examples:
| value | outcome |
| unset | the sqlite driver opens DB_FILE |
| sqlite | the sqlite driver opens DB_FILE |
| mysql | the mysql driver opens the DB_HOST connection |
| postgres | the postgres driver opens the DB_HOST connection |
| postgresql | the postgres driver opens the DB_HOST connection |
| oracle | startup fails with: unsupported database driver "oracle" |
Scenario Outline: Missing PostgreSQL variable
Given DB_DRIVER is "postgres"
And "<missing>" is not set
When the environment is validated
Then startup fails with "Environment variable <missing> is not set"
Examples:
| missing |
| DB_HOST |
| DB_USER |
| DB_PASSWORD |
| DB_NAME |
Scenario: SQLite and MySQL do not require PostgreSQL-only variables
Given DB_DRIVER is "sqlite" or "mysql"
And DB_SSLMODE is "maybe"
When the settings are read
Then DB_SSLMODE is ignored and validation succeeds
Scenario Outline: Connection string built from variables
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_SSLMODE "<sslmode>"
When the PostgreSQL DSN is built
Then the DSN targets "<host>:<expectedPort>" and database "cms"
And it sets sslmode "<expectedSslmode>"
And it sets the session time zone to UTC
And a password containing spaces, quotes, "@" or "/" is escaped so that parsing the DSN returns it unchanged
Examples:
| host | port | expectedPort | sslmode | expectedSslmode |
| db.local | | 5432 | | disable |
| db.local | 5433 | 5433 | require | require |
| pg | 5432 | 5432 | verify-full | verify-full |
Scenario: Invalid SSL mode is rejected
Given DB_DRIVER is "postgres" and DB_SSLMODE is "maybe"
When the settings are read
Then startup fails naming DB_SSLMODE and the accepted values
Scenario Outline: Fail fast when the server cannot be used
Given DB_DRIVER is "postgres" and "<fault>"
When the server starts
Then startup stops with "Unable to open the database"
And the message names the host and port
And the message does not contain the DB_PASSWORD value
Examples:
| fault |
| nothing listens on DB_PORT |
| the password is wrong |
| the database does not exist |
| the server requires TLS and DB_SSLMODE is disable |
Scenario: Data directory with PostgreSQL
Given DB_DRIVER is "postgres" and DATA_DIR is "/data"
Then update state, staging and backups live in "/data"
And DB_FILE is ignored
Scenario: Connection pool
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
When the PostgreSQL connection is opened
Then the pool limits are 4 open, 2 idle and 60 seconds
Scenario: Migration on PostgreSQL
Given an empty PostgreSQL database
When database.Migrate runs
Then the tables articles, images, users, invites, api_keys and purge_settings exist in schema "public"
And the unique indexes on users and api_keys exist
And the first user lacking a role becomes "owner" and the others "admin"
And running database.Migrate a second time changes nothing
Scenario: Existing drivers are untouched by the PostgreSQL addition
Given a SQLite file holding rows, and a MySQL database holding rows
When database.Migrate runs on each
Then the schema and every row are unchanged on both
Scenario Outline: Repository behaviour is the same on every driver
Given the "<driver>" driver
Then the repository contract "<case>" holds
Examples:
| driver | case |
| postgres | create user and read it back with its id and timestamps |
| postgres | username "Bob" and "bob" are distinct users |
| postgres | email "A@x.io" and "a@x.io" follow the same rule as on sqlite |
| postgres | duplicate email returns an error and stores nothing |
| postgres | the failed duplicate insert leaves the connection usable |
| postgres | unknown id returns domain ErrUserNotFound |
| postgres | accept invite twice: the second returns ErrInviteNotFound |
| postgres | accept invite with a taken username leaves the invite unaccepted |
| postgres | renew invite with unchanged values still succeeds |
| postgres | duplicate image hashes are grouped and counted |
| postgres | media search treats % and _ literally |
| postgres | media search is case-sensitive exactly as on sqlite |
| postgres | article body of 1 MB round-trips |
| postgres | article title with emoji and accents round-trips |
| postgres | zero reset expiration round-trips as a zero time |
| postgres | stored times read back in UTC |
| postgres | purge settings save, then SaveLastRun with identical values succeeds |
| postgres | api key unique hashed_key, revoke and last_used |
| postgres | offline article reads as not found publicly, online article is listed |
| postgres | listing order and pagination match sqlite for the same data |
Scenario Outline: Backup before an update
Given the "<driver>" driver with a populated database
When the pre-update backup runs
Then a file "<pattern>" exists in DATA_DIR/backups
And only the 3 newest backups are kept
And a failure returns an error, so the update download fails instead of claiming a backup
Examples:
| driver | pattern |
| sqlite | renewcms-<time>.db |
| mysql | renewcms-<time>.sql |
| postgres | renewcms-<time>.sql |
Scenario: PostgreSQL backup content
Given a PostgreSQL database with 2 users and 1 article
When the pre-update backup runs
Then the dump contains the schema, indexes and the rows of every table
And it does not contain the DB_PASSWORD value
And replaying it into an empty database restores the same rows
And sequences are reset so the next insert gets an unused id
Scenario: Secrets are never exposed
Given DB_DRIVER is "postgres"
Then no log line, error message or file in the repository contains the DB_PASSWORD value
Scenario: Static builds
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
+43
View File
@@ -0,0 +1,43 @@
# QA: PostgreSQL database driver
Backend only. Go tests in test/, black-box, no live server by default.
Decisions: DB_DRIVER=postgres (alias postgresql), gorm.io/driver/postgres (pgx, pure Go), shared DB_* vars plus DB_SSLMODE, pure-Go logical dump backup, `task test:postgres` against postgres:16 and postgres:17, SQLite or MySQL to PostgreSQL migration is a documented manual export/import only.
## Automated, no server (task test)
1. Registry: contains sqlite, mysql and postgres. Alias "postgresql" resolves to postgres. "oracle" returns `unsupported database driver "oracle"`.
2. Env validation: table-driven over Driver selection and Missing PostgreSQL variable. Invalid DB_SSLMODE or a non-numeric pool var returns an error naming the variable. DB_SSLMODE is ignored under sqlite and mysql. MySQL validation unchanged.
3. DSN builder: table-driven over the DSN scenario. Parse the output with the pgx config parser and assert host, port, database, sslmode and the UTC time zone. Round-trip a password such as `s3cr3t/Value@42:x 'q'`. The form used in errors never contains the password.
4. Settings.DataDir under postgres: DATA_DIR wins, DB_FILE ignored.
5. Fail fast without a server: closed 127.0.0.1 port, 1s connect timeout. Error has the "Unable to open the database" prefix, mentions host and port, not the password.
6. Pool settings via a stub opener or the real test below.
7. Regression: the existing SQLite and MySQL tests pass unchanged. A pre-change SQLite fixture is unchanged after Migrate.
8. Dump writer: reuse the existing formatter tests. Add PostgreSQL value formatting with a fake row source: bool as true/false, bytea hex escapes, timestamptz in UTC, NULL, identifier and string quoting.
9. Migrate dialect handling: no MySQL table options are applied under postgres (tableOptionsFor).
## Automated, real server (task test:postgres)
Setup: the task runs `docker run` for postgres:16 then postgres:17, waits with pg_isready, exports RENEWCMS_TEST_POSTGRES_DSN, runs `go test -tags nofrontend ./test/... -run Postgres -count=1`, then removes the container. Tests t.Skip when the variable is unset. Repository contract tests are parameterised over sqlite, mysql and postgres with a fresh database each.
10. Migrate on an empty database: tables and indexes via information_schema and pg_indexes, role backfill, idempotent second run.
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound. After the unique violation the next query on the same pool succeeds.
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
13. Image duplicates grouping and counts (GROUP BY strictness), search with % and _ and its case rule, filename backfill listing.
14. Article body 1 MB, unicode title, title lookup, online filter, listing order and pagination parity with sqlite.
15. API key unique hashed_key, revoke, last_used.
16. Zero-time round trip, UTC times.
17. Purge settings upsert.
18. Backup on a populated database: .sql file, 3-file retention, replay into a second empty database gives identical rows, no password in the file, next insert after replay gets a fresh id.
19. Wrong password, missing database and TLS-required-but-disabled fail with the "Unable to open the database" prefix and no password.
## Build and lint
20. `task lint:go` and `task test` pass. `task test:postgres` passes on 16 and 17 (record both; NOT RUN if Docker is unavailable).
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets.
22. `task mutate` impact: none (useCases only).
23. No comment lines in the added Go files.
24. Layering: `go list -deps` on useCases and domain shows no pgx or gorm. Only api/dependecyInjection.go builds the postgres config.
## Manual (needs Compose and real SMTP)
25. docker-compose.postgres.yml: renewcms plus a postgres service with a named volume and a pg_isready healthcheck, depends_on service_healthy, DB_DRIVER=postgres, DB_HOST=postgres, DATA_DIR=/data. `docker compose up`: /healthz 200, register, log in, create an article, restart the stack, data persists.
26. Stop the postgres container and start renewcms: clear error, no password in the logs.
27. In the Docker image (uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
28. Docs: .env.example, docker.env.example, docs/configuration.md, docs/docker.md, docs/deployment.md, docs/development.md and the docs/architecture.md "Storage and database seams" section list DB_DRIVER=postgres, DB_SSLMODE and the manual migration note. DB_SSLMODE goes into cmd/server/main.go envDefaults.
29. SQLite default end to end with no new vars set behaves as before.
+7 -1
View File
@@ -15,6 +15,7 @@ require (
github.com/go-playground/validator/v10 v10.28.0
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v2 v2.1.6
golang.org/x/crypto v0.57.0
@@ -22,7 +23,8 @@ require (
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
@@ -50,6 +52,9 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -64,6 +69,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+20 -4
View File
@@ -81,6 +81,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -105,6 +113,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -114,10 +124,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
@@ -147,8 +159,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
@@ -13,9 +13,14 @@ const (
DatabaseDriverSQLite = "sqlite"
DatabaseDriverMySQL = "mysql"
DatabaseDriverPostgres = "postgres"
databaseDriverPostgresAlias = "postgresql"
DefaultDatabaseDriver = DatabaseDriverSQLite
DefaultDatabasePort = "3306"
DefaultPostgresPort = "5432"
DefaultDatabaseTLS = "false"
DefaultDatabaseSSLMode = "disable"
DefaultMaxOpenConns = "10"
DefaultMaxIdleConns = "5"
DefaultConnMaxLifetimeSeconds = "300"
@@ -26,6 +31,7 @@ const (
envDatabasePassword = "DB_PASSWORD"
envDatabaseName = "DB_NAME"
envDatabaseTLS = "DB_TLS"
envDatabaseSSLMode = "DB_SSLMODE"
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
@@ -35,6 +41,8 @@ var mysqlRequiredEnvVars = []string{envDatabaseHost, envDatabaseUser, envDatabas
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
var DatabaseSSLModes = []string{"disable", "require", "verify-ca", "verify-full"}
type DatabaseSettings struct {
Driver string
Host string
@@ -43,13 +51,22 @@ type DatabaseSettings struct {
Password string
Name string
TLS string
SSLMode string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
func NormalizeDatabaseDriver(driver string) string {
if driver == databaseDriverPostgresAlias {
return DatabaseDriverPostgres
}
return driver
}
func DatabaseRequiredEnvVars(driver string) []string {
if driver == DatabaseDriverMySQL {
switch NormalizeDatabaseDriver(driver) {
case DatabaseDriverMySQL, DatabaseDriverPostgres:
return slices.Clone(mysqlRequiredEnvVars)
}
return nil
@@ -65,19 +82,28 @@ func MissingEnvVarError(names []string, lookup func(string) (string, bool)) erro
}
func databaseDriverFromEnv() string {
return envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver)
return NormalizeDatabaseDriver(envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver))
}
func databaseSettingsFromEnv() (DatabaseSettings, error) {
driver := databaseDriverFromEnv()
if driver != DatabaseDriverMySQL {
if driver != DatabaseDriverMySQL && driver != DatabaseDriverPostgres {
return DatabaseSettings{Driver: driver}, nil
}
tls := envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
defaultPort, tls, sslMode := DefaultDatabasePort, "", ""
if driver == DatabaseDriverPostgres {
defaultPort = DefaultPostgresPort
sslMode = envOrDefault(envDatabaseSSLMode, DefaultDatabaseSSLMode)
if !slices.Contains(DatabaseSSLModes, sslMode) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseSSLMode, strings.Join(DatabaseSSLModes, ", "), sslMode)
}
} else {
tls = envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
if !slices.Contains(DatabaseTLSModes, tls) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
}
}
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
if err != nil {
return DatabaseSettings{}, err
@@ -94,11 +120,12 @@ func databaseSettingsFromEnv() (DatabaseSettings, error) {
return DatabaseSettings{
Driver: driver,
Host: os.Getenv(envDatabaseHost),
Port: envOrDefault(envDatabasePort, DefaultDatabasePort),
Port: envOrDefault(envDatabasePort, defaultPort),
User: os.Getenv(envDatabaseUser),
Password: os.Getenv(envDatabasePassword),
Name: os.Getenv(envDatabaseName),
TLS: tls,
SSLMode: sslMode,
MaxOpenConns: maxOpen,
MaxIdleConns: maxIdle,
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
@@ -28,6 +28,7 @@ type Driver interface {
var drivers = map[string]Driver{
DriverSQLite: sqliteDriver{},
DriverMySQL: mysqlDriver{},
DriverPostgres: postgresDriver{},
}
type Connection struct {
@@ -16,12 +16,13 @@ type DumpBackup struct {
open func() (DumpSource, error)
directory string
now func() time.Time
dialect dumpDialect
}
var _ domain.DatabaseBackup = &DumpBackup{}
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
return &DumpBackup{open: open, directory: directory, now: now}
return &DumpBackup{open: open, directory: directory, now: now, dialect: mysqlDumpDialect}
}
func (b *DumpBackup) Backup() (string, error) {
@@ -63,7 +64,7 @@ func (b *DumpBackup) writePartial(partial string) error {
return err
}
buffered := bufio.NewWriter(file)
if err := WriteDump(buffered, source); err != nil {
if err := writeDump(buffered, source, b.dialect); err != nil {
_ = file.Close()
return err
}
@@ -17,18 +17,44 @@ func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBa
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
}
type mysqlDumpSource struct {
type txDumpSource struct {
tx *gorm.DB
quote func(name string) string
convert func(value any) []byte
}
func openTxDumpSource(db *gorm.DB, quote func(name string) string, convert func(value any) []byte) (*txDumpSource, error) {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if tx.Error != nil {
return nil, tx.Error
}
return &txDumpSource{tx: tx, quote: quote, convert: convert}, nil
}
func (s *txDumpSource) Rows(table string) (DumpRows, error) {
rows, err := s.tx.Raw("SELECT * FROM " + s.quote(table)).Rows()
if err != nil {
return nil, err
}
return &sqlDumpRows{rows: rows, convert: s.convert}, nil
}
func (s *txDumpSource) Close() error {
return s.tx.Rollback().Error
}
type mysqlDumpSource struct {
*txDumpSource
}
var _ DumpSource = &mysqlDumpSource{}
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if tx.Error != nil {
return nil, tx.Error
source, err := openTxDumpSource(db, quoteIdentifier, dumpBytes)
if err != nil {
return nil, err
}
return &mysqlDumpSource{tx: tx}, nil
return &mysqlDumpSource{source}, nil
}
func (s *mysqlDumpSource) Tables() ([]string, error) {
@@ -46,25 +72,14 @@ func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
return statement, nil
}
func (s *mysqlDumpSource) Rows(table string) (DumpRows, error) {
rows, err := s.tx.Raw("SELECT * FROM " + quoteIdentifier(table)).Rows()
if err != nil {
return nil, err
}
return &mysqlDumpRows{rows: rows}, nil
}
func (s *mysqlDumpSource) Close() error {
return s.tx.Rollback().Error
}
type mysqlDumpRows struct {
type sqlDumpRows struct {
rows *sql.Rows
convert func(value any) []byte
}
var _ DumpRows = &mysqlDumpRows{}
var _ DumpRows = &sqlDumpRows{}
func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
func (r *sqlDumpRows) Columns() ([]DumpColumn, error) {
types, err := r.rows.ColumnTypes()
if err != nil {
return nil, err
@@ -76,11 +91,11 @@ func (r *mysqlDumpRows) Columns() ([]DumpColumn, error) {
return columns, nil
}
func (r *mysqlDumpRows) Next() bool {
func (r *sqlDumpRows) Next() bool {
return r.rows.Next()
}
func (r *mysqlDumpRows) Values() ([][]byte, error) {
func (r *sqlDumpRows) Values() ([][]byte, error) {
columnCount, err := r.rows.Columns()
if err != nil {
return nil, err
@@ -95,16 +110,16 @@ func (r *mysqlDumpRows) Values() ([][]byte, error) {
}
values := make([][]byte, len(scanned))
for index, value := range scanned {
values[index] = dumpBytes(value)
values[index] = r.convert(value)
}
return values, nil
}
func (r *mysqlDumpRows) Err() error {
func (r *sqlDumpRows) Err() error {
return r.rows.Err()
}
func (r *mysqlDumpRows) Close() error {
func (r *sqlDumpRows) Close() error {
return r.rows.Close()
}
@@ -0,0 +1,108 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"net"
"net/url"
"reflect"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const (
DriverPostgres = "postgres"
postgresTimeZone = "UTC"
postgresConnectTimeoutSeconds = 5
postgresScheme = "postgres"
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
)
var timeType = reflect.TypeOf(time.Time{})
type postgresDriver struct{}
type PostgresConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
func PostgresDSN(config PostgresConfig) string {
dsn := url.URL{
Scheme: postgresScheme,
User: url.UserPassword(config.User, config.Password),
Host: net.JoinHostPort(config.Host, config.Port),
Path: "/" + config.Name,
}
query := url.Values{}
query.Set("sslmode", config.SSLMode)
query.Set("timezone", postgresTimeZone)
query.Set("connect_timeout", fmt.Sprint(postgresConnectTimeoutSeconds))
dsn.RawQuery = query.Encode()
return dsn.String()
}
func (postgresDriver) Open(source string) (*gorm.DB, error) {
parsed, err := pgconn.ParseConfig(source)
if err != nil {
return nil, fmt.Errorf("the postgres connection settings are invalid")
}
db, err := gorm.Open(postgres.Open(source), &gorm.Config{})
if err != nil {
address := net.JoinHostPort(parsed.Host, fmt.Sprint(parsed.Port))
return nil, fmt.Errorf("cannot use the postgres server at %s: %s", address, redact(err.Error(), parsed.Password))
}
if err := db.Callback().Query().After(gormQueryCallback).Register(postgresUTCCallback, readTimesInUTC); err != nil {
return nil, err
}
return db, nil
}
func readTimesInUTC(db *gorm.DB) {
if db.Error != nil || db.Statement.Dest == nil {
return
}
convertTimesToUTC(reflect.ValueOf(db.Statement.Dest))
}
func convertTimesToUTC(value reflect.Value) {
switch value.Kind() {
case reflect.Pointer, reflect.Interface:
if !value.IsNil() {
convertTimesToUTC(value.Elem())
}
case reflect.Slice, reflect.Array:
for index := 0; index < value.Len(); index++ {
convertTimesToUTC(value.Index(index))
}
case reflect.Struct:
convertStructTimesToUTC(value)
}
}
func convertStructTimesToUTC(value reflect.Value) {
if value.Type() == timeType {
if value.CanSet() {
value.Set(reflect.ValueOf(value.Interface().(time.Time).UTC()))
}
return
}
for index := 0; index < value.NumField(); index++ {
if value.Type().Field(index).IsExported() {
convertTimesToUTC(value.Field(index))
}
}
}
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewPostgresBackup(db, directory, now)
}
@@ -0,0 +1,189 @@
package database
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
const (
postgresSchema = "public"
postgresTablesQuery = "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name"
postgresColumnsQuery = `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(pg_get_expr(d.adbin, d.adrelid), '') AS default_value, a.attidentity::text AS identity
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum`
postgresConstraintsQuery = `SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS definition
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ? AND con.contype IN ('p', 'u', 'c')
ORDER BY CASE con.contype WHEN 'p' THEN 0 ELSE 1 END, con.conname`
postgresIndexesQuery = `SELECT i.indexdef
FROM pg_indexes i
WHERE i.schemaname = ? AND i.tablename = ?
AND i.indexname NOT IN (
SELECT con.conname FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ?
)
ORDER BY i.indexname`
postgresIdentityColumn = "id"
postgresTimeLayout = "2006-01-02 15:04:05.999999999Z07:00"
postgresByteaType = "BYTEA"
)
var postgresNumericTypes = map[string]bool{
"INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true,
}
var postgresSerialTypes = map[string]string{
"smallint": "smallserial",
"integer": "serial",
"bigint": "bigserial",
}
var postgresDumpDialect = dumpDialect{
preamble: []string{
"SET client_encoding = 'UTF8';",
"SET standard_conforming_strings = on;",
"SET TimeZone = 'UTC';",
},
quote: quotePostgresIdentifier,
literal: postgresLiteral,
tableTrailer: postgresSequenceReset,
}
func NewPostgresBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
backup := NewDumpBackup(func() (DumpSource, error) { return openPostgresDumpSource(db) }, directory, now)
backup.dialect = postgresDumpDialect
return backup
}
type postgresDumpSource struct {
*txDumpSource
}
var _ DumpSource = &postgresDumpSource{}
func openPostgresDumpSource(db *gorm.DB) (*postgresDumpSource, error) {
source, err := openTxDumpSource(db, quotePostgresIdentifier, postgresDumpBytes)
if err != nil {
return nil, err
}
return &postgresDumpSource{source}, nil
}
func (s *postgresDumpSource) Tables() ([]string, error) {
var tables []string
err := s.tx.Raw(postgresTablesQuery, postgresSchema).Scan(&tables).Error
return tables, err
}
type postgresColumn struct {
Name string
DataType string
NotNull bool
DefaultValue string
Identity string
}
type postgresConstraint struct {
Name string
Definition string
}
func (s *postgresDumpSource) CreateStatement(table string) (string, error) {
var columns []postgresColumn
if err := s.tx.Raw(postgresColumnsQuery, postgresSchema, table).Scan(&columns).Error; err != nil {
return "", err
}
var constraints []postgresConstraint
if err := s.tx.Raw(postgresConstraintsQuery, postgresSchema, table).Scan(&constraints).Error; err != nil {
return "", err
}
var indexes []string
if err := s.tx.Raw(postgresIndexesQuery, postgresSchema, table, postgresSchema, table).Scan(&indexes).Error; err != nil {
return "", err
}
var definitions []string
for _, column := range columns {
definitions = append(definitions, " "+postgresColumnDefinition(column))
}
for _, constraint := range constraints {
definitions = append(definitions, fmt.Sprintf(" CONSTRAINT %s %s", quotePostgresIdentifier(constraint.Name), constraint.Definition))
}
statements := []string{fmt.Sprintf("CREATE TABLE %s (\n%s\n)", quotePostgresIdentifier(table), strings.Join(definitions, ",\n"))}
statements = append(statements, indexes...)
return strings.Join(statements, ";\n"), nil
}
func postgresColumnDefinition(column postgresColumn) string {
quoted := quotePostgresIdentifier(column.Name)
serial, isSerial := postgresSerialTypes[column.DataType]
if isSerial && strings.HasPrefix(column.DefaultValue, "nextval(") {
return quoted + " " + serial + " NOT NULL"
}
if column.Identity != "" {
return quoted + " " + column.DataType + " GENERATED BY DEFAULT AS IDENTITY NOT NULL"
}
definition := quoted + " " + column.DataType
if column.DefaultValue != "" {
definition += " DEFAULT " + column.DefaultValue
}
if column.NotNull {
definition += " NOT NULL"
}
return definition
}
func postgresDumpBytes(value any) []byte {
if moment, isTime := value.(time.Time); isTime {
return []byte(moment.UTC().Format(postgresTimeLayout))
}
return dumpBytes(value)
}
func quotePostgresIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func postgresLiteral(databaseType string, value []byte) string {
if value == nil {
return "NULL"
}
upper := strings.ToUpper(databaseType)
if postgresNumericTypes[upper] && plainNumber.Match(value) {
return string(value)
}
if upper == postgresByteaType {
return fmt.Sprintf(`'\x%x'`, value)
}
return "'" + strings.ReplaceAll(string(value), "'", "''") + "'"
}
func postgresSequenceReset(table string, columns []DumpColumn) []string {
for _, column := range columns {
if column.Name != postgresIdentityColumn {
continue
}
quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name)
sequenceTable := strings.ReplaceAll(quotedTable, "'", "''")
return []string{fmt.Sprintf(
"SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE((SELECT MAX(%s) FROM %s), 1), (SELECT COUNT(*) > 0 FROM %s));",
sequenceTable, column.Name, quotedColumn, quotedTable, quotedTable,
)}
}
return nil
}
@@ -28,14 +28,26 @@ type DumpSource interface {
Close() error
}
var dumpPreamble = []string{
type dumpDialect struct {
preamble []string
postamble []string
quote func(name string) string
literal func(databaseType string, value []byte) string
tableTrailer func(table string, columns []DumpColumn) []string
}
var mysqlDumpDialect = dumpDialect{
preamble: []string{
"SET NAMES utf8mb4;",
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
"SET foreign_key_checks = 0;",
}
var dumpPostamble = []string{
},
postamble: []string{
"SET foreign_key_checks = 1;",
},
quote: quoteIdentifier,
literal: sqlLiteral,
tableTrailer: func(string, []DumpColumn) []string { return nil },
}
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
@@ -50,7 +62,11 @@ var textEscapes = strings.NewReplacer(
)
func WriteDump(output io.Writer, source DumpSource) error {
if err := writeStatements(output, dumpPreamble); err != nil {
return writeDump(output, source, mysqlDumpDialect)
}
func writeDump(output io.Writer, source DumpSource, dialect dumpDialect) error {
if err := writeStatements(output, dialect.preamble); err != nil {
return err
}
tables, err := source.Tables()
@@ -58,11 +74,11 @@ func WriteDump(output io.Writer, source DumpSource) error {
return err
}
for _, table := range tables {
if err := writeTable(output, source, table); err != nil {
if err := writeTable(output, source, table, dialect); err != nil {
return err
}
}
return writeStatements(output, dumpPostamble)
return writeStatements(output, dialect.postamble)
}
func writeStatements(output io.Writer, statements []string) error {
@@ -74,7 +90,7 @@ func writeStatements(output io.Writer, statements []string) error {
return nil
}
func writeTable(output io.Writer, source DumpSource, table string) error {
func writeTable(output io.Writer, source DumpSource, table string, dialect dumpDialect) error {
create, err := source.CreateStatement(table)
if err != nil {
return err
@@ -87,39 +103,42 @@ func writeTable(output io.Writer, source DumpSource, table string) error {
return err
}
defer rows.Close()
return writeRows(output, table, rows)
return writeRows(output, table, rows, dialect)
}
func writeRows(output io.Writer, table string, rows DumpRows) error {
func writeRows(output io.Writer, table string, rows DumpRows, dialect dumpDialect) error {
columns, err := rows.Columns()
if err != nil {
return err
}
insertPrefix := insertStatementPrefix(table, columns)
insertPrefix := insertStatementPrefix(table, columns, dialect)
for rows.Next() {
values, err := rows.Values()
if err != nil {
return err
}
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values)); err != nil {
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values, dialect)); err != nil {
return err
}
}
return rows.Err()
if err := rows.Err(); err != nil {
return err
}
return writeStatements(output, dialect.tableTrailer(table, columns))
}
func insertStatementPrefix(table string, columns []DumpColumn) string {
func insertStatementPrefix(table string, columns []DumpColumn, dialect dumpDialect) string {
names := make([]string, len(columns))
for index, column := range columns {
names[index] = quoteIdentifier(column.Name)
names[index] = dialect.quote(column.Name)
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", quoteIdentifier(table), strings.Join(names, ","))
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", dialect.quote(table), strings.Join(names, ","))
}
func joinLiterals(columns []DumpColumn, values [][]byte) string {
func joinLiterals(columns []DumpColumn, values [][]byte, dialect dumpDialect) string {
literals := make([]string, len(columns))
for index, column := range columns {
literals[index] = sqlLiteral(column.Type, values[index])
literals[index] = dialect.literal(column.Type, values[index])
}
return strings.Join(literals, ",")
}
+337
View File
@@ -0,0 +1,337 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/persistence/database"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
const postgresTestPassword = "p@ss w'rd/\"x:42%"
func setPostgresEnv(t *testing.T) {
t.Helper()
setBaseSettingsEnv(t)
t.Setenv("DB_DRIVER", "postgres")
t.Setenv("DB_HOST", "db.local")
t.Setenv("DB_USER", "app")
t.Setenv("DB_PASSWORD", postgresTestPassword)
t.Setenv("DB_NAME", "cms")
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_SSLMODE", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
t.Setenv(optional, "")
}
}
func TestPostgresDriverSelection(t *testing.T) {
t.Run("Driver selection", func(t *testing.T) {
cases := []struct {
value string
driver string
}{
{"postgres", "postgres"},
{"postgresql", "postgres"},
{"mysql", "mysql"},
{"oracle", "oracle"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_DRIVER", tc.value)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Driver != tc.driver {
t.Errorf("expected driver %q, got %+v err %v", tc.driver, settings.Database, err)
}
})
}
})
}
func TestPostgresEnvironmentValidation(t *testing.T) {
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
t.Run("Missing PostgreSQL variable", func(t *testing.T) {
for _, driver := range []string{"postgres", "postgresql"} {
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
t.Run(driver+" "+missing, func(t *testing.T) {
values := map[string]string{}
for name, value := range complete {
if name != missing {
values[name] = value
}
}
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(values))
expected := "Environment variable " + missing + " is not set"
if err == nil || err.Error() != expected {
t.Errorf("expected %q, got %v", expected, err)
}
})
}
}
})
t.Run("all PostgreSQL variables set passes validation", func(t *testing.T) {
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("postgres"), lookupFrom(complete)); err != nil {
t.Errorf("expected success, got %v", err)
}
})
t.Run("SQLite and MySQL do not require PostgreSQL-only variables", func(t *testing.T) {
for _, driver := range []string{"sqlite", "mysql"} {
setMySQLEnv(t)
t.Setenv("DB_DRIVER", driver)
t.Setenv("DB_SSLMODE", "maybe")
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != "" {
t.Errorf("driver %q: DB_SSLMODE must be ignored, got %+v err %v", driver, settings.Database, err)
}
}
})
}
func TestPostgresSettingsFromEnv(t *testing.T) {
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
setPostgresEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("settings: %v", err)
}
database := settings.Database
if database.Port != "5432" || database.SSLMode != "disable" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
t.Errorf("unexpected defaults %+v", database)
}
if database.Host != "db.local" || database.User != "app" || database.Password != postgresTestPassword || database.Name != "cms" {
t.Errorf("unexpected connection fields %+v", database)
}
})
t.Run("MySQL keeps its default port", func(t *testing.T) {
setMySQLEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Port != "3306" {
t.Errorf("unexpected settings %+v err %v", settings.Database, err)
}
})
t.Run("Invalid SSL mode is rejected", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", "maybe")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error")
}
for _, expected := range []string{"DB_SSLMODE", "disable", "require", "verify-ca", "verify-full"} {
if !strings.Contains(err.Error(), expected) {
t.Errorf("error %q must mention %q", err, expected)
}
}
})
t.Run("every accepted SSL mode is read", func(t *testing.T) {
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", mode)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != mode {
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
}
}
})
t.Run("Connection pool variables are read", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "4")
t.Setenv("DB_MAX_IDLE_CONNS", "2")
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
settings, err := config.NewSettingsFromEnv()
database := settings.Database
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second {
t.Errorf("unexpected settings %+v, err %v", database, err)
}
})
t.Run("Data directory with PostgreSQL", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DATA_DIR", "/data")
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
settings, err := config.NewSettingsFromEnv()
if err != nil || strings.ReplaceAll(settings.DataDir(), "\\", "/") != "/data" {
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
}
})
}
func TestPostgresDSN(t *testing.T) {
t.Run("Connection string built from variables", func(t *testing.T) {
cases := []struct {
host, port, expectedPort, sslmode, expectedSslmode string
}{
{"db.local", "", "5432", "", "disable"},
{"db.local", "5433", "5433", "require", "require"},
{"pg", "5432", "5432", "verify-full", "verify-full"},
}
for _, tc := range cases {
t.Run(tc.host+":"+tc.port+" sslmode "+tc.sslmode, func(t *testing.T) {
port, sslmode := tc.port, tc.sslmode
if port == "" {
port = config.DefaultPostgresPort
}
if sslmode == "" {
sslmode = config.DefaultDatabaseSSLMode
}
dsn := database.PostgresDSN(database.PostgresConfig{Host: tc.host, Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: sslmode})
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse: %v", err)
}
if fmt.Sprintf("%s:%d", parsed.Host, parsed.Port) != tc.host+":"+tc.expectedPort || parsed.Database != "cms" || parsed.User != "app" {
t.Errorf("unexpected target %s:%d db %q user %q", parsed.Host, parsed.Port, parsed.Database, parsed.User)
}
if parsed.Password != postgresTestPassword {
t.Errorf("the password must survive the DSN, got %q", parsed.Password)
}
if parsed.RuntimeParams["timezone"] != "UTC" {
t.Errorf("the session time zone must be UTC, got %v", parsed.RuntimeParams)
}
if tc.expectedSslmode == "disable" && parsed.TLSConfig != nil {
t.Errorf("sslmode disable must not configure TLS")
}
if tc.expectedSslmode != "disable" && parsed.TLSConfig == nil {
t.Errorf("sslmode %s must configure TLS", tc.expectedSslmode)
}
if query := mustParseQuery(t, dsn); query.Get("sslmode") != tc.expectedSslmode {
t.Errorf("expected sslmode %q, got %q", tc.expectedSslmode, query.Get("sslmode"))
}
if parsed.ConnectTimeout <= 0 || parsed.ConnectTimeout > 10*time.Second {
t.Errorf("connect timeout must be short, got %v", parsed.ConnectTimeout)
}
})
}
})
}
func mustParseQuery(t *testing.T, dsn string) url.Values {
t.Helper()
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
return parsed.Query()
}
func TestPostgresFailFast(t *testing.T) {
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
port := closedLocalPort(t)
dsn := database.PostgresDSN(database.PostgresConfig{Host: "127.0.0.1", Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: "disable"})
started := time.Now()
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: dsn})
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
t.Errorf("the error must name the host and port: %v", err)
}
if strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("the error must not contain the password: %v", err)
}
if elapsed := time.Since(started); elapsed > 8*time.Second {
t.Errorf("startup must fail fast, took %v", elapsed)
}
})
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: "postgres://user:" + postgresTestPassword + "@[broken"})
if err == nil || strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("unexpected error %v", err)
}
})
}
func TestPostgresStartupFailures(t *testing.T) {
binary := buildServerBinary(t, "")
t.Run("Missing PostgreSQL variable stops the server", func(t *testing.T) {
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": postgresTestPassword, "DB_NAME": "cms"}
for missing := range complete {
t.Run(missing, func(t *testing.T) {
extra := []string{"DB_DRIVER=postgresql"}
for name, value := range complete {
if name != missing {
extra = append(extra, name+"="+value)
}
}
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
}
})
t.Run("an invalid DB_SSLMODE stops the server naming the variable", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms", "DB_SSLMODE=maybe"))
if code == 0 || !strings.Contains(output, "DB_SSLMODE") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
port := closedLocalPort(t)
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || !strings.Contains(output, "Unable to open the database") {
t.Errorf("code %d output %q", code, output)
}
if !strings.Contains(output, "127.0.0.1:"+port) {
t.Errorf("the message must name the host and port: %q", output)
}
if strings.Contains(output, postgresTestPassword) {
t.Errorf("the message must not contain the password: %q", output)
}
})
t.Run("an omitted DB_PORT targets 5432", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
if strings.Contains(output, "Unable to open the database") && !strings.Contains(output, "127.0.0.1:5432") {
t.Errorf("the default port must be 5432: %q", output)
}
})
}
+444
View File
@@ -0,0 +1,444 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"database/sql"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const postgresTestDSNVariable = "RENEWCMS_TEST_POSTGRES_DSN"
var postgresTestDatabaseCounter atomic.Int64
type postgresTestServer struct {
admin *sql.DB
config database.PostgresConfig
}
func connectPostgresTestServer(t *testing.T) *postgresTestServer {
t.Helper()
dsn := os.Getenv(postgresTestDSNVariable)
if dsn == "" {
t.Skip(postgresTestDSNVariable + " is not set; run task test:postgres to test against a real server")
}
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse %s: %v", postgresTestDSNVariable, err)
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open admin: %v", err)
}
t.Cleanup(func() { _ = admin.Close() })
return &postgresTestServer{admin: admin, config: database.PostgresConfig{
Host: parsed.Host, Port: fmt.Sprint(parsed.Port), User: parsed.User, Password: parsed.Password, SSLMode: "disable",
}}
}
func (s *postgresTestServer) createDatabase(t *testing.T) database.PostgresConfig {
t.Helper()
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, postgresTestDatabaseCounter.Add(1))
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") })
config := s.config
config.Name = name
return config
}
func (s *postgresTestServer) connect(t *testing.T, config database.PostgresConfig) *database.Connection {
t.Helper()
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
if err != nil {
t.Fatalf("connect postgres: %v", err)
}
connection.DB.Logger = logger.Discard
t.Cleanup(func() { _ = connection.Close() })
return connection
}
func openContractPostgres(t *testing.T) *gorm.DB {
t.Helper()
server := connectPostgresTestServer(t)
connection := server.connect(t, server.createDatabase(t))
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate postgres: %v", err)
}
return connection.DB
}
func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfig, *gorm.DB) {
t.Helper()
server := connectPostgresTestServer(t)
config := server.createDatabase(t)
connection := server.connect(t, config)
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate: %v", err)
}
return server, config, connection.DB
}
func TestPostgresRepositoryContract(t *testing.T) {
runRepositoryContract(t, contractBackend{name: "postgres", open: openContractPostgres})
}
func TestPostgresConnectionPool(t *testing.T) {
t.Run("Connection pool", func(t *testing.T) {
server := connectPostgresTestServer(t)
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(server.createDatabase(t)), Pool: &pool})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer connection.Close()
sqlDb, _ := connection.DB.DB()
stats := sqlDb.Stats()
if stats.MaxOpenConnections != 4 {
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
}
})
}
func TestPostgresServerFaults(t *testing.T) {
t.Run("the password is wrong", func(t *testing.T) {
server := connectPostgresTestServer(t)
config := server.createDatabase(t)
config.Password = "definitely/wrong p@ss"
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
expectedAddress := net.JoinHostPort(config.Host, config.Port)
if err == nil || !strings.Contains(err.Error(), expectedAddress) || strings.Contains(err.Error(), config.Password) {
t.Errorf("unexpected error %v", err)
}
})
t.Run("the database does not exist", func(t *testing.T) {
server := connectPostgresTestServer(t)
config := server.config
config.Name = "renewcms_missing_database"
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
if err == nil || !strings.Contains(err.Error(), net.JoinHostPort(config.Host, config.Port)) || strings.Contains(err.Error(), config.Password) {
t.Errorf("unexpected error %v", err)
}
})
}
func TestPostgresMigration(t *testing.T) {
t.Run("Migration on PostgreSQL", func(t *testing.T) {
_, _, db := migratedPostgres(t)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", table)
if count != 1 {
t.Errorf("table %s must exist in schema public", table)
}
}
uniqueColumns := map[string][]string{
"users": {"username", "email", "password_reset_code", "verification_code"},
"api_keys": {"hashed_key"},
}
for table, columns := range uniqueColumns {
for _, column := range columns {
count := columnCount(t, db, `SELECT COUNT(*) FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
WHERE c.relname = ? AND a.attname = ? AND i.indisunique AND array_length(i.indkey, 1) = 1`, table, column)
if count == 0 {
t.Errorf("a unique index on %s.%s must exist", table, column)
}
}
}
})
t.Run("migrating twice changes nothing", func(t *testing.T) {
_, _, db := migratedPostgres(t)
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
if err := database.Migrate(db); err != nil {
t.Errorf("the second migration must succeed: %v", err)
}
if after := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"); after != before {
t.Errorf("indexes changed from %d to %d", before, after)
}
})
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
_, _, db := migratedPostgres(t)
for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
if insert.Error != nil {
t.Fatalf("insert %s: %v", name, insert.Error)
}
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
roles := map[string]string{}
var users []models.User
db.Find(&users)
for _, user := range users {
roles[user.Username] = user.Role
}
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
t.Errorf("unexpected roles %v", roles)
}
})
}
func TestPostgresTimes(t *testing.T) {
t.Run("times are stored and read back in UTC", func(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
zone := time.FixedZone("plus2", 2*3600)
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
user := contractUser("zoned", "zoned@x.example")
user.VerificationExpiration = expiration
created := mustCreateUser(t, repos, user)
read, err := repos.users.Get(created.ID)
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
}
})
}
func TestPostgresTimesInLists(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
zone := time.FixedZone("plus2", 2*3600)
for _, name := range []string{"first", "second"} {
user := contractUser(name, name+"@x.example")
user.VerificationExpiration = time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
mustCreateUser(t, repos, user)
}
users := repos.users.GetAll()
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
for _, user := range users {
if user.VerificationExpiration.Location() != time.UTC {
t.Errorf("%s: expected UTC, got %v", user.Username, user.VerificationExpiration.Location())
}
}
}
func TestPostgresBackupOfIdentityAndBinaryColumns(t *testing.T) {
server, _, db := migratedPostgres(t)
if err := db.Exec(`CREATE TABLE extras (id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, payload bytea, label text DEFAULT 'x' NOT NULL, amount numeric(10,2))`).Error; err != nil {
t.Fatalf("create extras: %v", err)
}
if err := db.Exec(`INSERT INTO extras (payload, amount) VALUES (decode('00ff27', 'hex'), 12.50), (NULL, NULL)`).Error; err != nil {
t.Fatalf("insert extras: %v", err)
}
path, err := database.NewPostgresBackup(db, filepath.Join(t.TempDir(), "backups"), time.Now).Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
content, _ := os.ReadFile(path)
dump := string(content)
for _, fragment := range []string{
`"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`,
`"label" text DEFAULT 'x'::text NOT NULL`,
`'\x00ff27'`,
`12.50`,
`NULL`,
} {
if !strings.Contains(dump, fragment) {
t.Errorf("the dump must contain %s", fragment)
}
}
restoredConfig := server.createDatabase(t)
replayPostgresDump(t, restoredConfig, dump)
restored := server.connect(t, restoredConfig)
var payload []byte
if err := restored.DB.Raw(`SELECT payload FROM extras WHERE id = 1`).Row().Scan(&payload); err != nil || len(payload) != 3 || payload[1] != 0xff {
t.Errorf("unexpected restored payload %v err %v", payload, err)
}
var next int
if err := restored.DB.Raw(`INSERT INTO extras (label) VALUES ('y') RETURNING id`).Row().Scan(&next); err != nil || next != 3 {
t.Errorf("the identity sequence must continue after the restored rows, got %d err %v", next, err)
}
}
func populateForPostgresBackup(t *testing.T, repos contractRepositories) {
t.Helper()
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
second := contractUser("plain", "plain@x.example")
second.PasswordResetCode = "reset-code"
mustCreateUser(t, repos, second)
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ end", IsOnline: true}); err != nil {
t.Fatalf("create article: %v", err)
}
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
t.Fatalf("create invite: %v", err)
}
}
func TestPostgresBackup(t *testing.T) {
t.Run("Backup before an update", func(t *testing.T) {
_, _, db := migratedPostgres(t)
populateForPostgresBackup(t, newContractRepositories(db))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewPostgresBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
var paths []string
for range 5 {
path, err := backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
paths = append(paths, path)
}
entries, _ := os.ReadDir(directory)
if len(entries) != 3 {
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
}
for _, path := range paths[2:] {
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
t.Errorf("unexpected backup name %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("the newest backup %q must remain: %v", path, err)
}
}
})
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
server := connectPostgresTestServer(t)
connection := server.connect(t, server.createDatabase(t))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewPostgresBackup(connection.DB, directory, time.Now)
if err := connection.Close(); err != nil {
t.Fatalf("close: %v", err)
}
path, err := backup.Backup()
if err == nil || path != "" {
t.Errorf("expected a failure, got %q %v", path, err)
}
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
t.Errorf("no file may remain, got %d entries", len(entries))
}
})
t.Run("PostgreSQL backup content", func(t *testing.T) {
server, _, db := migratedPostgres(t)
populateForPostgresBackup(t, newContractRepositories(db))
directory := filepath.Join(t.TempDir(), "backups")
path, err := database.NewPostgresBackup(db, directory, time.Now).Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
dump := string(content)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
if !strings.Contains(dump, `CREATE TABLE "`+table+`"`) {
t.Errorf("the dump must contain the schema of %s", table)
}
}
if !strings.Contains(dump, "CREATE UNIQUE INDEX") {
t.Errorf("the dump must contain the indexes")
}
for _, table := range []string{"articles", "images", "users", "invites"} {
if !strings.Contains(dump, `INSERT INTO "`+table+`"`) {
t.Errorf("the dump must contain rows of %s", table)
}
}
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
t.Errorf("the dump must not contain the DB_PASSWORD value")
}
restoredConfig := server.createDatabase(t)
replayPostgresDump(t, restoredConfig, dump)
restored := server.connect(t, restoredConfig)
restoredPath, err := database.NewPostgresBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
if err != nil {
t.Fatalf("backup of the restored database: %v", err)
}
restoredContent, _ := os.ReadFile(restoredPath)
if line := firstDifferingLine(dump, string(restoredContent)); line != "" {
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
}
restoredRepos := newContractRepositories(restored.DB)
restoredUsers := restoredRepos.users.GetAll()
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
t.Errorf("unexpected restored users %+v", restoredUsers)
}
restoredArticle, _ := restoredRepos.articles.Get(1)
if restoredArticle.Body != "line1\nline2 'q' \"d\" \\ end" {
t.Errorf("unexpected restored article body %q", restoredArticle.Body)
}
next := mustCreateUser(t, restoredRepos, contractUser("third", "third@x.example"))
if next.ID != 3 {
t.Errorf("sequences must be reset so the next insert gets an unused id, got %d", next.ID)
}
})
}
func replayPostgresDump(t *testing.T, config database.PostgresConfig, dump string) {
t.Helper()
replay, err := sql.Open("pgx", database.PostgresDSN(config))
if err != nil {
t.Fatal(err)
}
defer replay.Close()
if _, err := replay.Exec(dump); err != nil {
t.Fatalf("replay: %v", err)
}
}
func TestPostgresUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
t.Run("Bob and bob can both register", func(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
_, missing := repos.users.GetByUsername("BOB")
if err != nil || other.Username != "bob" || missing == nil {
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
}
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
t.Error("emails are compared exactly like SQLite does")
}
})
}