From 5868b524f337a36f098b22b026efb0f95a73ed13 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 18 Mar 2026 12:34:17 -0500 Subject: [PATCH] feat: switch to sqlite as the default --- .env.example | 12 +- Cargo.toml | 4 + docker-compose.yml | 51 +-- docs/getting-started/configuration.md | 9 +- docs/getting-started/deployment/docker.md | 19 +- docs/getting-started/deployment/other.md | 21 +- docs/getting-started/quickstart.md | 10 +- docs/guides/database-setup.md | 71 ++++ docs/guides/postgres-to-sqlite-migration.md | 86 +++++ docs/reference/architecture.md | 11 +- docs/reference/production-deployment.md | 28 +- src/admin/api_keys.rs | 6 +- src/admin/auth.rs | 17 +- src/admin/backfill.rs | 41 ++- src/admin/events.rs | 19 +- src/admin/labelers.rs | 11 +- src/admin/lexicons.rs | 9 +- src/admin/network_lexicons.rs | 11 +- src/admin/rate_limits.rs | 13 +- src/admin/records.rs | 12 +- src/admin/script_variables.rs | 8 +- src/admin/stats.rs | 46 +-- src/admin/users.rs | 30 +- src/bin/migrate_lua_sql.rs | 364 +++++++++++++++++++ src/db.rs | 379 ++++++++++---------- src/event_log.rs | 11 +- src/labeler.rs | 20 +- src/lua/atproto_api.rs | 46 +-- src/lua/db_api.rs | 103 +++--- src/lua/execute.rs | 2 +- src/lua/record.rs | 22 +- src/main.rs | 4 +- src/record_refs.rs | 4 +- src/tap.rs | 21 +- src/xrpc/procedure.rs | 9 +- src/xrpc/query.rs | 6 +- tests/common/app.rs | 3 +- tests/e2e_admin.rs | 6 +- tests/e2e_labelers.rs | 4 +- tests/e2e_network_lexicons.rs | 8 +- tests/e2e_xrpc.rs | 4 +- tests/lua_atproto_api.rs | 28 +- tests/lua_db_api.rs | 2 +- 43 files changed, 1083 insertions(+), 508 deletions(-) create mode 100644 docs/guides/database-setup.md create mode 100644 docs/guides/postgres-to-sqlite-migration.md create mode 100644 src/bin/migrate_lua_sql.rs diff --git a/.env.example b/.env.example index 41f7ee2..fb05834 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ -# Postgres -POSTGRES_USER=happyview -POSTGRES_PASSWORD=happyview -POSTGRES_DB=happyview -DATABASE_URL=postgres://happyview:happyview@postgres +# Database — SQLite is the default (zero setup required). +# Uncomment the Postgres line instead if you need Postgres for scale. +DATABASE_URL=sqlite://data/happyview.db?mode=rwc +# DATABASE_URL=postgres://happyview:happyview@postgres/happyview +# POSTGRES_USER=happyview +# POSTGRES_PASSWORD=happyview +# POSTGRES_DB=happyview # Tap TAP_RELAY_URL=https://relay1.us-east.bsky.network diff --git a/Cargo.toml b/Cargo.toml index 9aa60f3..9b513c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ urlencoding = "2.1.3" webpki-roots = "0.26" regex = "1.12.3" +[[bin]] +name = "migrate-lua-sql" +path = "src/bin/migrate_lua_sql.rs" + [dev-dependencies] wiremock = "0.6" tower = { version = "0.5", features = ["util"] } diff --git a/docker-compose.yml b/docker-compose.yml index c4fadc8..ea11c99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,24 @@ services: - postgres: - image: postgres:17 - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] - interval: 5s - timeout: 3s - retries: 5 + # Postgres is optional — only needed if you set DATABASE_URL to a postgres:// URL. + # SQLite is the default and requires no container. To use Postgres, uncomment this + # service and update DATABASE_URL in .env. + # + # postgres: + # image: postgres:17 + # environment: + # POSTGRES_USER: ${POSTGRES_USER} + # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # POSTGRES_DB: ${POSTGRES_DB} + # ports: + # - "5432:5432" + # volumes: + # - pgdata:/var/lib/postgresql/data + # - ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + # interval: 5s + # timeout: 3s + # retries: 5 tap: image: ghcr.io/bluesky-social/indigo/tap:latest @@ -27,9 +31,10 @@ services: TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} TAP_COLLECTION_FILTERS: ${TAP_COLLECTION_FILTERS} TAP_SIGNAL_COLLECTIONS: ${TAP_SIGNAL_COLLECTIONS} - depends_on: - postgres: - condition: service_healthy + # Uncomment if using Postgres: + # depends_on: + # postgres: + # condition: service_healthy happyview: image: rust:1.93 @@ -50,8 +55,9 @@ services: RELAY_URL: ${RELAY_URL} PORT: ${PORT} depends_on: - postgres: - condition: service_healthy + # Uncomment if using Postgres: + # postgres: + # condition: service_healthy tap: condition: service_started @@ -70,7 +76,8 @@ services: AIP_PROXY_URL: ${AIP_PROXY_URL} volumes: - pgdata: + # Uncomment if using Postgres: + # pgdata: cargo-registry: cargo-git: cargo-target: diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 78d4189..f9f9a53 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -6,7 +6,8 @@ HappyView is configured via environment variables. A `.env` file in the project | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `DATABASE_URL` | yes | --- | Postgres connection string | +| `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | +| `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | | `AIP_URL` | yes | --- | [AIP](https://github.com/graze-social/aip) instance URL for OAuth token validation | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | @@ -20,9 +21,13 @@ HappyView is configured via environment variables. A `.env` file in the project ## Example `.env` ```sh -DATABASE_URL=postgres://happyview:happyview@localhost/happyview +# SQLite (default — zero setup required) +DATABASE_URL=sqlite://data/happyview.db?mode=rwc AIP_URL=http://localhost:8080 +# Or use Postgres instead: +# DATABASE_URL=postgres://happyview:happyview@localhost/happyview + # Optional overrides # HOST=0.0.0.0 # PORT=3000 diff --git a/docs/getting-started/deployment/docker.md b/docs/getting-started/deployment/docker.md index bcaaccc..5420617 100644 --- a/docs/getting-started/deployment/docker.md +++ b/docs/getting-started/deployment/docker.md @@ -1,6 +1,6 @@ # Local Development with Docker -This guide runs the full HappyView stack locally using Docker Compose: Postgres, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), HappyView, and the web dashboard. +This guide runs the full HappyView stack locally using Docker Compose: [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), HappyView, and the web dashboard. ## Prerequisites @@ -25,7 +25,7 @@ Set `TAP_ADMIN_PASSWORD` in your `.env`. This shared secret is used by both Tap TAP_ADMIN_PASSWORD=your-secret-here ``` -The `docker-compose.yml` configures everything else (database URLs, service connections) automatically. +The `docker-compose.yml` configures everything else (service connections) automatically. See the [database setup guide](../../guides/database-setup.md) if you want to use Postgres instead. ## 2. Start the stack @@ -35,15 +35,18 @@ docker compose up This starts: -| Service | Port | Description | -| ------------- | ---- | ---------------------------------------------------- | -| **postgres** | 5432 | PostgreSQL 17 (databases for both HappyView and Tap) | -| **tap** | 2480 | Firehose consumer, backfill worker | -| **happyview** | 3000 | HappyView API server | -| **web** | 3001 | Next.js dashboard | +| Service | Port | Description | +| ------------- | ---- | ---------------------------------- | +| **tap** | 2480 | Firehose consumer, backfill worker | +| **happyview** | 3000 | HappyView API server | +| **web** | 3001 | Next.js dashboard | HappyView runs migrations automatically on startup. The first build will take a few minutes while Rust compiles. +:::tip +To use Postgres instead of SQLite, uncomment the `postgres` service in `docker-compose.yml` and update `DATABASE_URL` in `.env`. See the [database setup guide](../../guides/database-setup.md). +::: + ## Next steps Your HappyView stack is running. Follow the [Statusphere tutorial](../../tutorials/statusphere.md) to upload lexicons, add custom query logic, and start indexing records from the network. diff --git a/docs/getting-started/deployment/other.md b/docs/getting-started/deployment/other.md index 1efa0fe..bb49830 100644 --- a/docs/getting-started/deployment/other.md +++ b/docs/getting-started/deployment/other.md @@ -1,13 +1,13 @@ # Local Development from Source -This guide runs HappyView directly with `cargo run`, with you managing Postgres, AIP, and Tap separately. If you'd rather use Docker Compose to run everything together, see [Local Development with Docker](docker.md). +This guide runs HappyView directly with `cargo run`, with you managing AIP and Tap separately. If you'd rather use Docker Compose to run everything together, see [Local Development with Docker](docker.md). ## Prerequisites - Rust (stable) -- PostgreSQL 17+ - A running [AIP](https://github.com/graze-social/aip) instance (handles OAuth). See the [AIP documentation](https://github.com/graze-social/aip) for setup. - A running [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) instance (delivers real-time records and handles backfill). See the [Tap documentation](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) for setup. +- (Optional) PostgreSQL 17+ if you prefer Postgres over the default SQLite ## 1. Clone and configure @@ -20,15 +20,26 @@ cp .env.example .env Edit `.env` to point at your running services: ```sh -DATABASE_URL=postgres://happyview:happyview@localhost/happyview +# SQLite (default — no setup needed, file created automatically) +DATABASE_URL=sqlite://data/happyview.db?mode=rwc AIP_URL=http://localhost:8080 TAP_URL=http://localhost:2480 TAP_ADMIN_PASSWORD=your-secret-here ``` -See [Configuration](../configuration.md) for all available variables. +Or if you prefer Postgres: + +```sh +DATABASE_URL=postgres://happyview:happyview@localhost/happyview +``` + +See [Configuration](../configuration.md) for all available variables and the [database setup guide](../../guides/database-setup.md) for details on both backends. + +## 2. Create the database (Postgres only) + +If using SQLite, skip this step — HappyView creates the database file automatically. -## 2. Create the database +If using Postgres: ```sh createdb happyview diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 97613b3..0b25671 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -6,11 +6,11 @@ This page walks you through the fastest path to a working HappyView instance. By Pick whichever option fits your situation: -| Option | Best for | -|--------|----------| -| [**Railway**](deployment/railway.md) | Fastest path — one-click deploy of the full stack (HappyView + AIP + Tap + Postgres) | -| [**Docker Compose**](deployment/docker.md) | Local development with the full stack running in containers | -| [**From source**](deployment/other.md) | Running HappyView with `cargo run` and managing dependencies yourself | +| Option | Best for | +| ------------------------------------------ | ------------------------------------------------------------------------------------ | +| [**Railway**](deployment/railway.md) | Fastest path — one-click deploy of the full stack (HappyView + AIP + Tap + Postgres) | +| [**Docker Compose**](deployment/docker.md) | Local development with the full stack in containers | +| [**From source**](deployment/other.md) | Running HappyView with `cargo run` and managing dependencies yourself | If you're just trying HappyView for the first time, start with Railway. diff --git a/docs/guides/database-setup.md b/docs/guides/database-setup.md new file mode 100644 index 0000000..aa84e24 --- /dev/null +++ b/docs/guides/database-setup.md @@ -0,0 +1,71 @@ +# Database Setup + +HappyView supports two database backends: **SQLite** (default) and **PostgreSQL**. The backend is auto-detected from your `DATABASE_URL` scheme, or you can set `DATABASE_BACKEND` explicitly. + +## SQLite (default) + +SQLite requires zero setup. HappyView creates the database file automatically on first startup. + +```sh +DATABASE_URL=sqlite://data/happyview.db?mode=rwc +``` + +The `?mode=rwc` parameter tells SQLite to create the file if it does not exist. The path is relative to the working directory (or use an absolute path). + +**When to use SQLite:** + +- Getting started or local development +- Small to medium deployments +- Single-server setups where simplicity is preferred + +## PostgreSQL (optional) + +For larger deployments or when you need concurrent write scalability, use Postgres. + +```sh +DATABASE_URL=postgres://happyview:happyview@localhost/happyview +``` + +You need to create the database before starting HappyView: + +```sh +createdb happyview +``` + +HappyView runs migrations automatically on startup for both backends. + +**When to use Postgres:** + +- High write concurrency from many simultaneous users +- You need Postgres-specific features (e.g., advanced JSON queries in Lua scripts) +- You already have a Postgres infrastructure + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | Connection string. `sqlite://...` for SQLite, `postgres://...` for Postgres | +| `DATABASE_BACKEND` | Optional. Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` if not set | + +## Docker Compose + +The default `docker-compose.yml` ships with the Postgres service commented out. To use Postgres: + +1. Uncomment the `postgres` service and `pgdata` volume in `docker-compose.yml` +2. Uncomment the `depends_on: postgres` blocks in the `tap` and `happyview` services +3. Update `DATABASE_URL` in `.env`: + ```sh + DATABASE_URL=postgres://happyview:happyview@postgres/happyview + ``` +4. Set the Postgres credentials: + ```sh + POSTGRES_USER=happyview + POSTGRES_PASSWORD=happyview + POSTGRES_DB=happyview + ``` + +## Lua scripts + +Both backends support the same Lua database API (`db.query`, `db.get`, `db.count`). Write SQL in **SQLite syntax** by default. If you are using Postgres, HappyView automatically translates common SQLite patterns to Postgres equivalents at runtime. + +If you are migrating existing Lua scripts from Postgres SQL syntax to SQLite syntax, see the [Postgres to SQLite migration guide](postgres-to-sqlite-migration.md). diff --git a/docs/guides/postgres-to-sqlite-migration.md b/docs/guides/postgres-to-sqlite-migration.md new file mode 100644 index 0000000..2971280 --- /dev/null +++ b/docs/guides/postgres-to-sqlite-migration.md @@ -0,0 +1,86 @@ +# Migrating from Postgres to SQLite + +This guide covers migrating an existing HappyView deployment from Postgres to SQLite. If you are staying on Postgres, no action is required. + +## Overview + +HappyView now defaults to SQLite and writes all internal SQL in SQLite syntax. When running against Postgres, HappyView translates queries automatically. However, if you have **Lua scripts** that contain raw Postgres SQL, those scripts need to be updated to use SQLite syntax instead. + +## Step 1: Export your data + +Back up your Postgres database before making any changes: + +```sh +pg_dump -U happyview happyview > happyview_backup.sql +``` + +## Step 2: Update environment variables + +Change your `.env` to use SQLite: + +```sh +# Before +DATABASE_URL=postgres://happyview:happyview@localhost/happyview + +# After +DATABASE_URL=sqlite://data/happyview.db?mode=rwc +``` + +If you had `DATABASE_BACKEND` set, update it as well: + +```sh +DATABASE_BACKEND=sqlite +``` + +## Step 3: Migrate Lua scripts + +If you have Lua scripts with raw SQL queries, they need to be converted from Postgres syntax to SQLite syntax. A codemod tool is provided to automate this. + +### Run the codemod tool + +```sh +cargo run --bin migrate-lua-sql -- /path/to/lua/scripts +``` + +The tool scans all `.lua` files in the given directory and rewrites Postgres SQL patterns to SQLite equivalents. + +### What the codemod converts automatically + +- `$1`, `$2`, etc. parameter placeholders to `?` positional parameters +- `jsonb` operators (`->`, `->>`, `@>`, `?`) to SQLite `json_extract()` calls +- `ILIKE` to `LIKE` (SQLite `LIKE` is case-insensitive for ASCII by default) +- `NOW()` to `datetime('now')` +- `::text`, `::integer`, etc. type casts to SQLite equivalents (`CAST(... AS ...)`) +- `COALESCE` and other standard SQL functions (no change needed) +- `TRUE`/`FALSE` literals to `1`/`0` +- `RETURNING *` clauses (removed, as SQLite has limited RETURNING support) + +### What it flags for manual review + +The tool prints warnings for patterns it cannot convert automatically: + +- Complex Postgres-specific functions (`array_agg`, `string_agg`, `generate_series`, etc.) +- Window functions with Postgres-specific syntax +- `ON CONFLICT` clauses with complex conditions +- CTEs (`WITH` queries) that use Postgres-specific features +- Any SQL that the parser cannot confidently transform + +Review the flagged lines and update them manually. + +## Step 4: Import data into SQLite + +Start HappyView with the new `DATABASE_URL`. It will create the SQLite database and run migrations automatically. If you need to import existing records, use the backfill feature to re-index from the network: + +1. Start HappyView with the new SQLite `DATABASE_URL` +2. Upload your lexicons via the dashboard or admin API +3. Run a backfill for each collection (dashboard or `POST /admin/backfill`) + +For small datasets, this is the simplest approach since backfill fetches all records fresh from the network. + +## Step 5: Update Docker Compose (if applicable) + +If you were running Postgres via Docker Compose, you can now comment out the `postgres` service since it is no longer needed. See the [database setup guide](database-setup.md#docker-compose) for details. + +## Rollback + +To switch back to Postgres, revert your `DATABASE_URL` to the Postgres connection string. Your Postgres database remains unchanged — HappyView does not modify it during the migration to SQLite. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index c252907..ed109f4 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -21,13 +21,13 @@ graph LR Procedure -->|proxy write| PDS["User PDS"] - DB[("PostgreSQL
records · lexicons")] + DB[("SQLite / PostgreSQL
records · lexicons")] Tap["Tap
WebSocket"] -->|record events| DB Relay["Relay
Firehose"] --> Tap ``` -Reads flow top-down through the query handler to Postgres. Writes flow through the procedure handler to the user's PDS, then HappyView indexes the record locally. All record data enters the system through Tap, which handles both real-time firehose events and historical backfill. HappyView syncs collection filters to Tap and discovers repos via the relay for backfill, but Tap performs all record fetching. +Reads flow top-down through the query handler to the database (SQLite by default, or Postgres). Writes flow through the procedure handler to the user's PDS, then HappyView indexes the record locally. All record data enters the system through Tap, which handles both real-time firehose events and historical backfill. HappyView syncs collection filters to Tap and discovers repos via the relay for backfill, but Tap performs all record fetching. ## Module overview @@ -250,10 +250,13 @@ POST /admin/backfill # Unit tests (no database needed) cargo test --lib -# All tests including end-to-end (requires Postgres) +# All tests including end-to-end (SQLite by default) +cargo test + +# Or run against Postgres docker compose -f docker-compose.test.yml up -d TEST_DATABASE_URL=postgres://happyview:happyview@localhost:5433/happyview_test cargo test docker compose -f docker-compose.test.yml down ``` -End-to-end tests use `wiremock` to mock external services (AIP, PLC directory, PDSes) and a real Postgres database for full integration coverage. +End-to-end tests use `wiremock` to mock external services (AIP, PLC directory, PDSes) and a real database for full integration coverage. By default tests use SQLite; set `TEST_DATABASE_URL` to a Postgres connection string to test against Postgres. diff --git a/docs/reference/production-deployment.md b/docs/reference/production-deployment.md index 6cd6d26..49e4c6a 100644 --- a/docs/reference/production-deployment.md +++ b/docs/reference/production-deployment.md @@ -1,6 +1,6 @@ # Deployment -HappyView requires a Postgres database and an [AIP](https://github.com/graze-social/aip) instance for OAuth. The [Quickstart](../getting-started/deployment/railway.md) covers the fastest path with Railway. This page covers other deployment options. +HappyView requires a database and an [AIP](https://github.com/graze-social/aip) instance for OAuth. SQLite is the default; Postgres is also supported, but requires additional setup. The [Quickstart](../getting-started/deployment/railway.md) covers the fastest path with Railway. This page covers other deployment options. ## Docker @@ -18,6 +18,26 @@ For local development, see [Docker deployment](../getting-started/deployment/doc This example omits [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), which is required for real-time record streaming and backfill. See the full `docker-compose.yml` in the repository for a complete configuration including Tap. ::: +Using SQLite (default): + +```yaml +services: + happyview: + image: happyview:latest + ports: + - "3000:3000" + environment: + DATABASE_URL: "sqlite://data/happyview.db?mode=rwc" + AIP_URL: "https://aip.example.com" + volumes: + - happyview-data:/app/data + +volumes: + happyview-data: +``` + +Using Postgres: + ```yaml services: postgres: @@ -48,18 +68,20 @@ volumes: The general process for any hosting platform: -1. Provision a Postgres 17+ database +1. Choose a database: SQLite (default, zero setup) or Postgres 17+ (provision separately) 2. Deploy an [AIP](https://github.com/graze-social/aip) instance (handles OAuth for your AppView) 3. Set `DATABASE_URL` and `AIP_URL` environment variables (see [Configuration](../getting-started/configuration.md) for all options) 4. Deploy the Docker image or build from source 5. HappyView listens on `PORT` (default `3000`) 6. Health check: `GET /health` returns `ok` +See the [database setup guide](../guides/database-setup.md) for details on both backends. + For Railway specifically, the [Quickstart](../getting-started/deployment/railway.md) template handles all of this with a single click. ## Database -Migrations run automatically on startup via `sqlx::migrate!()`. No manual migration step is needed. +HappyView supports SQLite (default) and Postgres. The backend is auto-detected from the `DATABASE_URL` scheme (`sqlite://` or `postgres://`). Migrations run automatically on startup. No manual migration step is needed. See the [database setup guide](../guides/database-setup.md) for details. ## TLS diff --git a/src/admin/api_keys.rs b/src/admin/api_keys.rs index 7f80ce1..1b6aacc 100644 --- a/src/admin/api_keys.rs +++ b/src/admin/api_keys.rs @@ -56,7 +56,7 @@ pub(super) async fn create_api_key( serde_json::to_string(&body.permissions).unwrap_or_else(|_| "[]".to_string()); let insert_sql = adapt_sql( - "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", state.db_backend, ); @@ -105,7 +105,7 @@ pub(super) async fn list_api_keys( auth.require(Permission::ApiKeysRead).await?; let select_sql = adapt_sql( - "SELECT k.id, k.name, k.key_prefix, k.permissions, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN users u ON u.id = k.user_id WHERE u.did = $1 ORDER BY k.created_at DESC", + "SELECT k.id, k.name, k.key_prefix, k.permissions, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN users u ON u.id = k.user_id WHERE u.did = ? ORDER BY k.created_at DESC", state.db_backend, ); @@ -156,7 +156,7 @@ pub(super) async fn revoke_api_key( let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE api_keys SET revoked_at = $1 WHERE id = $2 AND user_id = (SELECT id FROM users WHERE did = $3) AND revoked_at IS NULL", + "UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = (SELECT id FROM users WHERE did = ?) AND revoked_at IS NULL", state.db_backend, ); diff --git a/src/admin/auth.rs b/src/admin/auth.rs index e5bf531..4a176ec 100644 --- a/src/admin/auth.rs +++ b/src/admin/auth.rs @@ -54,7 +54,7 @@ impl UserAuth { backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = $1", + "SELECT permission FROM user_permissions WHERE user_id = ?", backend, ); let rows: Vec<(String,)> = sqlx::query_as(&sql) @@ -121,7 +121,7 @@ impl FromRequestParts for UserAuth { let now = now_rfc3339(); let insert_sql = adapt_sql( - "INSERT INTO users (id, did, is_super, created_at) VALUES ($1, $2, $3, $4)", + "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", backend, ); @@ -135,7 +135,7 @@ impl FromRequestParts for UserAuth { if result.is_ok() { let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_at) VALUES ($1, $2, $3)", + "INSERT INTO user_permissions (user_id, permission, granted_at) VALUES (?, ?, ?)", backend, ); for perm in Permission::all() { @@ -164,7 +164,7 @@ impl FromRequestParts for UserAuth { } } - let select_sql = adapt_sql("SELECT id, is_super FROM users WHERE did = $1", backend); + let select_sql = adapt_sql("SELECT id, is_super FROM users WHERE did = ?", backend); let found: Option<(String, i32)> = sqlx::query_as(&select_sql) .bind(&did) .fetch_optional(&state.db) @@ -185,7 +185,7 @@ impl FromRequestParts for UserAuth { let db = state.db.clone(); let uid = user_id.clone(); let now = now_rfc3339(); - let update_sql = adapt_sql("UPDATE users SET last_used_at = $1 WHERE id = $2", backend); + let update_sql = adapt_sql("UPDATE users SET last_used_at = ? WHERE id = ?", backend); tokio::spawn(async move { let _ = sqlx::query(&update_sql) .bind(&now) @@ -229,7 +229,7 @@ impl UserAuth { let backend = state.db_backend; let select_sql = adapt_sql( - "SELECT k.id, u.id, u.did, u.is_super, k.permissions FROM api_keys k JOIN users u ON u.id = k.user_id WHERE k.key_hash = $1 AND k.revoked_at IS NULL", + "SELECT k.id, u.id, u.did, u.is_super, k.permissions FROM api_keys k JOIN users u ON u.id = k.user_id WHERE k.key_hash = ? AND k.revoked_at IS NULL", backend, ); @@ -256,10 +256,7 @@ impl UserAuth { let db = state.db.clone(); let now = now_rfc3339(); - let update_sql = adapt_sql( - "UPDATE api_keys SET last_used_at = $1 WHERE id = $2", - backend, - ); + let update_sql = adapt_sql("UPDATE api_keys SET last_used_at = ? WHERE id = ?", backend); tokio::spawn(async move { let _ = sqlx::query(&update_sql) .bind(&now) diff --git a/src/admin/backfill.rs b/src/admin/backfill.rs index 19e1400..95b5a7e 100644 --- a/src/admin/backfill.rs +++ b/src/admin/backfill.rs @@ -3,6 +3,7 @@ use axum::extract::State; use axum::http::StatusCode; use serde::Deserialize; use serde_json::Value; +use uuid::Uuid; use crate::AppState; use crate::db::{adapt_sql, now_rfc3339}; @@ -91,11 +92,13 @@ pub(super) async fn create_backfill( let backend = state.db_backend; let now = now_rfc3339(); + let job_id = Uuid::new_v4().to_string(); let sql = adapt_sql( - "INSERT INTO backfill_jobs (collection, did, created_at) VALUES ($1, $2, $3) RETURNING id", + "INSERT INTO backfill_jobs (id, collection, did, created_at) VALUES (?, ?, ?, ?) RETURNING id", backend, ); let row: (String,) = sqlx::query_as(&sql) + .bind(&job_id) .bind(&body.collection) .bind(&body.did) .bind(&now) @@ -107,12 +110,12 @@ pub(super) async fn create_backfill( let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'running', started_at = $2 WHERE id = $1", + "UPDATE backfill_jobs SET status = 'running', started_at = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(&now) + .bind(&job_id) .execute(&state.db) .await; @@ -141,13 +144,13 @@ pub(super) async fn create_backfill( let error = format!("no record-type lexicon registered for collection '{col}'"); let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'failed', completed_at = $2, error = $3 WHERE id = $1", + "UPDATE backfill_jobs SET status = 'failed', completed_at = ?, error = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(&now) .bind(&error) + .bind(&job_id) .execute(&state.db) .await; @@ -162,14 +165,10 @@ pub(super) async fn create_backfill( } vec![col.clone()] } else { - let sql = match backend { - crate::db::DatabaseBackend::Postgres => { - "SELECT id FROM lexicons WHERE backfill = 1 AND lexicon_json::jsonb->'defs'->'main'->>'type' = 'record'".to_string() - } - crate::db::DatabaseBackend::Sqlite => { - "SELECT id FROM lexicons WHERE backfill = 1 AND json_extract(lexicon_json, '$.defs.main.type') = 'record'".to_string() - } - }; + let sql = adapt_sql( + "SELECT id FROM lexicons WHERE backfill = 1 AND json_extract(lexicon_json, '$.defs.main.type') = 'record'", + backend, + ); let rows: Vec<(String,)> = sqlx::query_as(&sql) .fetch_all(&state.db) @@ -183,12 +182,12 @@ pub(super) async fn create_backfill( if collections.is_empty() { let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'completed', completed_at = $2, error = 'no backfill-eligible collections' WHERE id = $1", + "UPDATE backfill_jobs SET status = 'completed', completed_at = ?, error = 'no backfill-eligible collections' WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(&now) + .bind(&job_id) .execute(&state.db) .await; @@ -226,12 +225,12 @@ pub(super) async fn create_backfill( let total_repos = all_dids.len() as i32; let sql = adapt_sql( - "UPDATE backfill_jobs SET total_repos = $2 WHERE id = $1", + "UPDATE backfill_jobs SET total_repos = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(total_repos) + .bind(&job_id) .execute(&state.db) .await; @@ -261,13 +260,13 @@ pub(super) async fn create_backfill( tracing::warn!(error = %e, "failed to add repos to tap"); let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'failed', completed_at = $2, error = $3 WHERE id = $1", + "UPDATE backfill_jobs SET status = 'failed', completed_at = ?, error = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(&now) .bind(&e) + .bind(&job_id) .execute(&state.db) .await; @@ -301,13 +300,13 @@ pub(super) async fn create_backfill( let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE backfill_jobs SET status = 'completed', completed_at = $2, processed_repos = $3 WHERE id = $1", + "UPDATE backfill_jobs SET status = 'completed', completed_at = ?, processed_repos = ? WHERE id = ?", backend, ); let _ = sqlx::query(&sql) - .bind(&job_id) .bind(&now) .bind(total_repos) + .bind(&job_id) .execute(&state.db) .await; diff --git a/src/admin/events.rs b/src/admin/events.rs index 4bfd1d1..d2eb8f8 100644 --- a/src/admin/events.rs +++ b/src/admin/events.rs @@ -53,31 +53,24 @@ pub(super) async fn list_events( "SELECT id, event_type, severity, actor_did, subject, detail, created_at FROM event_logs WHERE 1=1", ); - let mut param_count = 0u32; if query.event_type.is_some() { - param_count += 1; - sql.push_str(&format!(" AND event_type = ${param_count}")); + sql.push_str(" AND event_type = ?"); } if query.category.is_some() { - param_count += 1; - sql.push_str(&format!(" AND event_type LIKE ${param_count}")); + sql.push_str(" AND event_type LIKE ?"); } if query.severity.is_some() { - param_count += 1; - sql.push_str(&format!(" AND severity = ${param_count}")); + sql.push_str(" AND severity = ?"); } if query.subject.is_some() { - param_count += 1; - sql.push_str(&format!(" AND subject = ${param_count}")); + sql.push_str(" AND subject = ?"); } if query.cursor.is_some() { - param_count += 1; - sql.push_str(&format!(" AND created_at < ${param_count}")); + sql.push_str(" AND created_at < ?"); } - param_count += 1; - sql.push_str(&format!(" ORDER BY created_at DESC LIMIT ${param_count}")); + sql.push_str(" ORDER BY created_at DESC LIMIT ?"); let sql = adapt_sql(&sql, backend); diff --git a/src/admin/labelers.rs b/src/admin/labelers.rs index 341cecb..6e1944b 100644 --- a/src/admin/labelers.rs +++ b/src/admin/labelers.rs @@ -57,14 +57,15 @@ pub(super) async fn add( let sql = adapt_sql( r#" INSERT INTO labeler_subscriptions (did, created_at) - VALUES ($1, $2) - ON CONFLICT (did) DO UPDATE SET status = 'active', updated_at = $2 + VALUES (?, ?) + ON CONFLICT (did) DO UPDATE SET status = 'active', updated_at = ? "#, backend, ); sqlx::query(&sql) .bind(&body.did) .bind(&now) + .bind(&now) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to add labeler subscription: {e}")))?; @@ -100,7 +101,7 @@ pub(super) async fn update( let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "UPDATE labeler_subscriptions SET status = $1, updated_at = $2 WHERE did = $3", + "UPDATE labeler_subscriptions SET status = ?, updated_at = ? WHERE did = ?", backend, ); let result = sqlx::query(&sql) @@ -144,7 +145,7 @@ pub(super) async fn delete( auth.require(Permission::LabelersDelete).await?; let backend = state.db_backend; - let delete_sql = adapt_sql("DELETE FROM labeler_subscriptions WHERE did = $1", backend); + let delete_sql = adapt_sql("DELETE FROM labeler_subscriptions WHERE did = ?", backend); let result = sqlx::query(&delete_sql) .bind(&did) .execute(&state.db) @@ -158,7 +159,7 @@ pub(super) async fn delete( } // Also remove all labels from this labeler. - let delete_labels_sql = adapt_sql("DELETE FROM labels WHERE src = $1", backend); + let delete_labels_sql = adapt_sql("DELETE FROM labels WHERE src = ?", backend); let _ = sqlx::query(&delete_labels_sql) .bind(&did) .execute(&state.db) diff --git a/src/admin/lexicons.rs b/src/admin/lexicons.rs index 92ff39b..b652ac2 100644 --- a/src/admin/lexicons.rs +++ b/src/admin/lexicons.rs @@ -85,7 +85,7 @@ pub(super) async fn upload_lexicon( let sql = adapt_sql( r#" INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, action, script, index_hook, token_cost, source, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'manual', $9) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manual', ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, backfill = EXCLUDED.backfill, @@ -96,7 +96,7 @@ pub(super) async fn upload_lexicon( token_cost = EXCLUDED.token_cost, source = 'manual', revision = lexicons.revision + 1, - updated_at = $9 + updated_at = ? RETURNING revision "#, backend, @@ -111,6 +111,7 @@ pub(super) async fn upload_lexicon( .bind(&body.index_hook) .bind(body.token_cost) .bind(&now) + .bind(&now) .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert lexicon: {e}")))?; @@ -276,7 +277,7 @@ pub(super) async fn get_lexicon( auth.require(Permission::LexiconsRead).await?; let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, index_hook, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM lexicons WHERE id = $1", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, script, index_hook, source, authority_did, last_fetched_at, created_at, updated_at, token_cost FROM lexicons WHERE id = ?", backend, ); #[allow(clippy::type_complexity)] @@ -363,7 +364,7 @@ pub(super) async fn delete_lexicon( ) -> Result { auth.require(Permission::LexiconsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM lexicons WHERE id = $1", backend); + let sql = adapt_sql("DELETE FROM lexicons WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(&id) .execute(&state.db) diff --git a/src/admin/network_lexicons.rs b/src/admin/network_lexicons.rs index bab3b1f..1b762fa 100644 --- a/src/admin/network_lexicons.rs +++ b/src/admin/network_lexicons.rs @@ -57,15 +57,15 @@ pub(super) async fn add( let sql = adapt_sql( r#" INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) - VALUES ($1, $2, 0, $3, 'network', $4, $5, $5) + VALUES (?, ?, 0, ?, 'network', ?, ?, ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, target_collection = EXCLUDED.target_collection, source = 'network', authority_did = EXCLUDED.authority_did, - last_fetched_at = $5, + last_fetched_at = ?, revision = lexicons.revision + 1, - updated_at = $5 + updated_at = ? RETURNING revision "#, backend, @@ -76,6 +76,9 @@ pub(super) async fn add( .bind(&body.target_collection) .bind(&authority_did) .bind(&now) + .bind(&now) + .bind(&now) + .bind(&now) .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert network lexicon: {e}")))?; @@ -162,7 +165,7 @@ pub(super) async fn remove( let backend = state.db_backend; let sql = adapt_sql( - "DELETE FROM lexicons WHERE id = $1 AND source = 'network'", + "DELETE FROM lexicons WHERE id = ? AND source = 'network'", backend, ); let result = sqlx::query(&sql) diff --git a/src/admin/rate_limits.rs b/src/admin/rate_limits.rs index bc45778..5019d94 100644 --- a/src/admin/rate_limits.rs +++ b/src/admin/rate_limits.rs @@ -87,14 +87,14 @@ pub(super) async fn upsert( let sql = adapt_sql( r#" INSERT INTO rate_limits (method, capacity, refill_rate, default_query_cost, default_procedure_cost, default_proxy_cost, created_at) - VALUES (NULL, $1, $2, $3, $4, $5, $6) + VALUES (NULL, ?, ?, ?, ?, ?, ?) ON CONFLICT (method) DO UPDATE SET capacity = EXCLUDED.capacity, refill_rate = EXCLUDED.refill_rate, default_query_cost = EXCLUDED.default_query_cost, default_procedure_cost = EXCLUDED.default_procedure_cost, default_proxy_cost = EXCLUDED.default_proxy_cost, - updated_at = $6 + updated_at = ? "#, backend, ); @@ -105,6 +105,7 @@ pub(super) async fn upsert( .bind(body.default_procedure_cost as i32) .bind(body.default_proxy_cost as i32) .bind(&now) + .bind(&now) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert rate limit: {e}")))?; @@ -148,8 +149,8 @@ pub(super) async fn set_enabled( let sql = adapt_sql( r#" INSERT INTO rate_limit_settings (key, value) - VALUES ('enabled', $1) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = $2 + VALUES ('enabled', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = ? "#, backend, ); @@ -209,7 +210,7 @@ pub(super) async fn add_allowlist( let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO rate_limit_allowlist (cidr, note, created_at) VALUES ($1, $2, $3)", + "INSERT INTO rate_limit_allowlist (cidr, note, created_at) VALUES (?, ?, ?)", backend, ); sqlx::query(&sql) @@ -247,7 +248,7 @@ pub(super) async fn remove_allowlist( auth.require(Permission::RateLimitsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM rate_limit_allowlist WHERE id = $1", backend); + let sql = adapt_sql("DELETE FROM rate_limit_allowlist WHERE id = ?", backend); let result = sqlx::query(&sql) .bind(id) .execute(&state.db) diff --git a/src/admin/records.rs b/src/admin/records.rs index 5891c05..b931134 100644 --- a/src/admin/records.rs +++ b/src/admin/records.rs @@ -63,7 +63,7 @@ pub(super) async fn list_records( .unwrap_or(0); let sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3", + "SELECT uri, did, record FROM records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -88,11 +88,9 @@ pub(super) async fn list_records( Vec::new() } else { let now = now_rfc3339(); - let placeholders: Vec = (1..=uris.len()).map(|i| format!("${i}")).collect(); - let ph_str = placeholders.join(", "); - let next_idx = uris.len() + 1; + let ph_str = (0..uris.len()).map(|_| "?").collect::>().join(", "); let raw_sql = format!( - "SELECT uri, src, val, cts FROM labels WHERE uri IN ({ph_str}) AND (exp IS NULL OR exp > ${next_idx})" + "SELECT uri, src, val, cts FROM labels WHERE uri IN ({ph_str}) AND (exp IS NULL OR exp > ?)" ); let sql = adapt_sql(&raw_sql, backend); let mut q = sqlx::query_as(&sql); @@ -169,7 +167,7 @@ pub(super) async fn delete_collection_records( auth.require(Permission::RecordsDeleteCollection).await?; auth.require(Permission::RecordsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE collection = $1", backend); + let sql = adapt_sql("DELETE FROM records WHERE collection = ?", backend); let result = sqlx::query(&sql) .bind(¶ms.collection) .execute(&state.db) @@ -189,7 +187,7 @@ pub(super) async fn delete_record( ) -> Result { auth.require(Permission::RecordsDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE uri = $1", backend); + let sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); let result = sqlx::query(&sql) .bind(¶ms.uri) .execute(&state.db) diff --git a/src/admin/script_variables.rs b/src/admin/script_variables.rs index 5e3c22a..fbb4cf7 100644 --- a/src/admin/script_variables.rs +++ b/src/admin/script_variables.rs @@ -57,8 +57,8 @@ pub(super) async fn upsert( let sql = adapt_sql( r#" INSERT INTO script_variables (key, value, created_at) - VALUES ($1, $2, $3) - ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = $3 + VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? "#, backend, ); @@ -66,6 +66,8 @@ pub(super) async fn upsert( .bind(&body.key) .bind(&body.value) .bind(&now) + .bind(&body.value) + .bind(&now) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to upsert script variable: {e}")))?; @@ -95,7 +97,7 @@ pub(super) async fn delete( auth.require(Permission::ScriptVariablesDelete).await?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM script_variables WHERE key = $1", backend); + let sql = adapt_sql("DELETE FROM script_variables WHERE key = ?", backend); let result = sqlx::query(&sql) .bind(&key) .execute(&state.db) diff --git a/src/admin/stats.rs b/src/admin/stats.rs index 950b7dc..3cc871c 100644 --- a/src/admin/stats.rs +++ b/src/admin/stats.rs @@ -2,7 +2,7 @@ use axum::Json; use axum::extract::State; use crate::AppState; -use crate::db::DatabaseBackend; +use crate::db::adapt_sql; use crate::error::AppError; use super::auth::UserAuth; @@ -20,36 +20,22 @@ pub(super) async fn stats( .await .map_err(|e| AppError::Internal(format!("failed to count records: {e}")))?; - let collection_sql = match state.db_backend { - DatabaseBackend::Postgres => { - r#" - SELECT c.collection, COALESCE(r.cnt, 0) AS count - FROM ( - SELECT id AS collection FROM lexicons - WHERE lexicon_json::jsonb->'defs'->'main'->>'type' = 'record' - ) c - LEFT JOIN ( - SELECT collection, COUNT(*) AS cnt FROM records GROUP BY collection - ) r ON r.collection = c.collection - ORDER BY c.collection - "# - } - DatabaseBackend::Sqlite => { - r#" - SELECT c.collection, COALESCE(r.cnt, 0) AS count - FROM ( - SELECT id AS collection FROM lexicons - WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record' - ) c - LEFT JOIN ( - SELECT collection, COUNT(*) AS cnt FROM records GROUP BY collection - ) r ON r.collection = c.collection - ORDER BY c.collection - "# - } - }; + let collection_sql = adapt_sql( + r#" + SELECT c.collection, COALESCE(r.cnt, 0) AS count + FROM ( + SELECT id AS collection FROM lexicons + WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record' + ) c + LEFT JOIN ( + SELECT collection, COUNT(*) AS cnt FROM records GROUP BY collection + ) r ON r.collection = c.collection + ORDER BY c.collection + "#, + state.db_backend, + ); - let collections: Vec<(String, i64)> = sqlx::query_as(collection_sql) + let collections: Vec<(String, i64)> = sqlx::query_as(&collection_sql) .fetch_all(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to count by collection: {e}")))?; diff --git a/src/admin/users.rs b/src/admin/users.rs index fb7ca2e..6b8aaf7 100644 --- a/src/admin/users.rs +++ b/src/admin/users.rs @@ -60,7 +60,7 @@ pub(super) async fn create_user( let backend = state.db_backend; let insert_sql = adapt_sql( - "INSERT INTO users (id, did, is_super, created_at) VALUES ($1, $2, $3, $4)", + "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?)", backend, ); @@ -74,7 +74,7 @@ pub(super) async fn create_user( .map_err(|e| AppError::Internal(format!("failed to create user: {e}")))?; let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); @@ -139,7 +139,7 @@ pub(super) async fn list_users( .map_err(|e| AppError::Internal(format!("failed to list users: {e}")))?; let perm_sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = $1 ORDER BY permission", + "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", backend, ); @@ -175,7 +175,7 @@ pub(super) async fn get_user( let backend = state.db_backend; let select_sql = adapt_sql( - "SELECT id, did, is_super, created_at, last_used_at FROM users WHERE id = $1", + "SELECT id, did, is_super, created_at, last_used_at FROM users WHERE id = ?", backend, ); @@ -190,7 +190,7 @@ pub(super) async fn get_user( }; let perm_sql = adapt_sql( - "SELECT permission FROM user_permissions WHERE user_id = $1 ORDER BY permission", + "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", backend, ); @@ -229,7 +229,7 @@ pub(super) async fn update_permissions( } // Cannot modify super user's permissions - let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = $1", backend); + let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = ?", backend); let target: Option<(i32,)> = sqlx::query_as(&select_sql) .bind(&id) .fetch_optional(&state.db) @@ -276,7 +276,7 @@ pub(super) async fn update_permissions( let now = now_rfc3339(); let grant_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); @@ -292,7 +292,7 @@ pub(super) async fn update_permissions( } let revoke_sql = adapt_sql( - "DELETE FROM user_permissions WHERE user_id = $1 AND permission = $2", + "DELETE FROM user_permissions WHERE user_id = ? AND permission = ?", backend, ); @@ -340,7 +340,7 @@ pub(super) async fn delete_user( } // Cannot delete super user - let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = $1", backend); + let select_sql = adapt_sql("SELECT is_super FROM users WHERE id = ?", backend); let target: Option<(i32,)> = sqlx::query_as(&select_sql) .bind(&id) .fetch_optional(&state.db) @@ -359,7 +359,7 @@ pub(super) async fn delete_user( let now = now_rfc3339(); let revoke_keys_sql = adapt_sql( - "UPDATE api_keys SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", + "UPDATE api_keys SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", backend, ); @@ -370,7 +370,7 @@ pub(super) async fn delete_user( .await .map_err(|e| AppError::Internal(format!("failed to revoke api keys: {e}")))?; - let delete_sql = adapt_sql("DELETE FROM users WHERE id = $1", backend); + let delete_sql = adapt_sql("DELETE FROM users WHERE id = ?", backend); let result = sqlx::query(&delete_sql) .bind(&id) @@ -414,7 +414,7 @@ pub(super) async fn transfer_super( let now = now_rfc3339(); // Remove super from current user - let update1_sql = adapt_sql("UPDATE users SET is_super = $1 WHERE id = $2", backend); + let update1_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); sqlx::query(&update1_sql) .bind(0_i32) .bind(&auth.user_id) @@ -423,7 +423,7 @@ pub(super) async fn transfer_super( .map_err(|e| AppError::Internal(format!("failed to remove super: {e}")))?; // Set super on target user - let update2_sql = adapt_sql("UPDATE users SET is_super = $1 WHERE id = $2", backend); + let update2_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); let result = sqlx::query(&update2_sql) .bind(1_i32) .bind(&body.target_user_id) @@ -433,7 +433,7 @@ pub(super) async fn transfer_super( if result.rows_affected() == 0 { // Restore super on current user - let restore_sql = adapt_sql("UPDATE users SET is_super = $1 WHERE id = $2", backend); + let restore_sql = adapt_sql("UPDATE users SET is_super = ? WHERE id = ?", backend); let _ = sqlx::query(&restore_sql) .bind(1_i32) .bind(&auth.user_id) @@ -447,7 +447,7 @@ pub(super) async fn transfer_super( // Ensure target has all permissions let perm_sql = adapt_sql( - "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + "INSERT INTO user_permissions (user_id, permission, granted_by, granted_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); diff --git a/src/bin/migrate_lua_sql.rs b/src/bin/migrate_lua_sql.rs new file mode 100644 index 0000000..b3e61e9 --- /dev/null +++ b/src/bin/migrate_lua_sql.rs @@ -0,0 +1,364 @@ +use regex::Regex; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A single SQL transformation with location info for reporting. +struct SqlChange { + file: PathBuf, + line: usize, + original: String, + converted: String, + warnings: Vec, +} + +fn main() { + let args: Vec = std::env::args().collect(); + + let mut dir = std::env::current_dir().expect("cannot determine current directory"); + let mut dry_run = false; + let mut report_only = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--dry-run" => dry_run = true, + "--report" => report_only = true, + "--help" | "-h" => { + eprintln!("Usage: migrate-lua-sql [OPTIONS] [DIRECTORY]"); + eprintln!(); + eprintln!("Converts Postgres SQL in Lua db.raw() calls to SQLite syntax."); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --dry-run Show what would change without modifying files"); + eprintln!(" --report Only report changes and warnings (implies --dry-run)"); + eprintln!(" -h, --help Show this help"); + std::process::exit(0); + } + other => { + dir = PathBuf::from(other); + } + } + i += 1; + } + + if report_only { + dry_run = true; + } + + let lua_files = find_lua_files(&dir); + if lua_files.is_empty() { + eprintln!("No .lua files found in {}", dir.display()); + std::process::exit(0); + } + + let mut total_changes = 0; + let mut total_warnings = 0; + let mut files_modified = 0; + + for path in &lua_files { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: cannot read {}: {}", path.display(), e); + continue; + } + }; + + let (new_content, changes) = transform_file(path, &content); + + if changes.is_empty() { + continue; + } + + files_modified += 1; + + for change in &changes { + total_changes += 1; + total_warnings += change.warnings.len(); + + if report_only || dry_run { + println!("--- {}:{}", change.file.display(), change.line); + println!(" Original: {}", change.original.trim()); + println!(" Converted: {}", change.converted.trim()); + for w in &change.warnings { + println!(" WARNING: {}", w); + } + println!(); + } + } + + if !dry_run && let Err(e) = fs::write(path, &new_content) { + eprintln!("Error writing {}: {}", path.display(), e); + } + } + + let mode = if report_only { + "Report" + } else if dry_run { + "Dry run" + } else { + "Applied" + }; + + println!( + "{}: {} changes across {} files ({} warnings)", + mode, total_changes, files_modified, total_warnings + ); +} + +fn find_lua_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + find_lua_files_recursive(dir, &mut files); + files.sort(); + files +} + +fn find_lua_files_recursive(dir: &Path, files: &mut Vec) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_lua_files_recursive(&path, files); + } else if path.extension().and_then(|s| s.to_str()) == Some("lua") { + files.push(path); + } + } +} + +/// Check if a string looks like it contains SQL. +/// Requires at least one strong SQL keyword (statement-level), not just connectors. +fn looks_like_sql(s: &str) -> bool { + let upper = s.to_uppercase(); + let strong_keywords = [ + "SELECT ", + "INSERT ", + "UPDATE ", + "DELETE ", + "FROM ", + "WHERE ", + "JOIN ", + "ORDER BY", + "GROUP BY", + "HAVING ", + "LIMIT ", + "VALUES", + "ON CONFLICT", + ]; + strong_keywords.iter().any(|kw| upper.contains(kw)) +} + +/// Transform an entire file, returning the new content and a list of changes. +/// +/// Scans every quoted string literal on each line. If the string looks like SQL +/// (contains common SQL keywords), it applies Postgres-to-SQLite transformations. +/// This handles both inline db.raw("...") calls and SQL built via variable +/// assignment with `..` concatenation. +fn transform_file(path: &Path, content: &str) -> (String, Vec) { + let mut changes = Vec::new(); + let mut result = String::with_capacity(content.len()); + + // Match double-quoted and single-quoted string literals + let sql_string_re = Regex::new(r#"("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')"#).unwrap(); + + for (line_idx, line) in content.lines().enumerate() { + let mut new_line = line.to_string(); + let mut line_changed = false; + + // Find all string literals on this line + let matches: Vec<_> = sql_string_re.find_iter(line).collect(); + + // Process matches in reverse order so byte offsets remain valid + for m in matches.into_iter().rev() { + let full_match = m.as_str(); + let quote_char = full_match.chars().next().unwrap(); + let inner = &full_match[1..full_match.len() - 1]; + + // Only process strings that look like SQL + if !looks_like_sql(inner) { + continue; + } + + let (converted, warnings) = convert_sql(inner); + + if converted != inner || !warnings.is_empty() { + let replacement = format!("{}{}{}", quote_char, converted, quote_char); + new_line = format!( + "{}{}{}", + &new_line[..m.start()], + replacement, + &new_line[m.end()..] + ); + line_changed = true; + + changes.push(SqlChange { + file: path.to_path_buf(), + line: line_idx + 1, + original: inner.to_string(), + converted: converted.clone(), + warnings, + }); + } + } + + if line_changed { + result.push_str(&new_line); + } else { + result.push_str(line); + } + result.push('\n'); + } + + // Preserve trailing newline behavior of original + if !content.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + (result, changes) +} + +/// Convert a single SQL string from Postgres to SQLite syntax. +/// Returns (converted_sql, warnings). +fn convert_sql(sql: &str) -> (String, Vec) { + let mut result = sql.to_string(); + let mut warnings = Vec::new(); + + // Check for unsupported patterns first (before any transformations) + check_unsupported_patterns(&result, &mut warnings); + + // 1. JSON operators: must be done before $N replacement since -> uses > char + result = convert_json_operators(&result); + + // 2. $1, $2, ... -> ? (in order) + result = convert_placeholders(&result); + + // 3. ILIKE -> LIKE (SQLite LIKE is already case-insensitive for ASCII) + let ilike_re = Regex::new(r"(?i)\bILIKE\b").unwrap(); + result = ilike_re.replace_all(&result, "LIKE").to_string(); + + // 4. NOW() +/- INTERVAL '...' -> datetime('now', '...') + // Must be done before bare NOW() replacement + result = convert_now_interval(&result); + + // 5. Bare NOW() -> datetime('now') + let now_re = Regex::new(r"(?i)\bNOW\(\)").unwrap(); + result = now_re.replace_all(&result, "datetime('now')").to_string(); + + // 6. Boolean literals: true -> 1, false -> 0 + let true_re = Regex::new(r"\btrue\b").unwrap(); + let false_re = Regex::new(r"\bfalse\b").unwrap(); + result = true_re.replace_all(&result, "1").to_string(); + result = false_re.replace_all(&result, "0").to_string(); + + (result, warnings) +} + +/// Convert $1, $2, etc. to ? in order. +fn convert_placeholders(sql: &str) -> String { + let re = Regex::new(r"\$(\d+)").unwrap(); + + // Track which placeholder numbers exist (for potential future validation) + let mut _seen: HashSet = HashSet::new(); + for cap in re.captures_iter(sql) { + let n: u32 = cap[1].parse().unwrap_or(0); + _seen.insert(n); + } + + // Replace all $N with ? + re.replace_all(sql, "?").to_string() +} + +/// Convert Postgres JSON operators to json_extract(). +/// +/// Handles chains like: +/// col->>'key' => json_extract(col, '$.key') +/// col->'a'->'b'->>'c' => json_extract(col, '$.a.b.c') +/// col::jsonb->'a'->>'b' => json_extract(col, '$.a.b') +fn convert_json_operators(sql: &str) -> String { + // Match a chain of -> / ->> operators starting from an identifier + // The identifier may have an optional ::jsonb cast which we strip + // Use a non-capturing group for the repetition so we capture the ENTIRE chain + let chain_re = + Regex::new(r"(\b[a-zA-Z_][a-zA-Z0-9_.]*)(::jsonb)?((?:\s*->>?\s*'[^']*')+)").unwrap(); + + chain_re + .replace_all(sql, |caps: ®ex::Captures| { + let col = &caps[1]; + // caps[2] is optional ::jsonb cast — we strip it + let chain_str = &caps[3]; // now captures the full chain + + // Parse each arrow and key from the chain + let arrow_re = Regex::new(r"(->>?)\s*'([^']*)'").unwrap(); + let mut keys: Vec = Vec::new(); + + for arrow_cap in arrow_re.captures_iter(chain_str) { + keys.push(arrow_cap[2].to_string()); + } + + let json_path = format!("$.{}", keys.join(".")); + format!("json_extract({}, '{}')", col, json_path) + }) + .to_string() +} + +/// Convert NOW() +/- INTERVAL 'X unit' to datetime('now', '+/-X unit'). +fn convert_now_interval(sql: &str) -> String { + let re = Regex::new(r"(?i)\bNOW\(\)\s*([+-])\s*INTERVAL\s*'([^']+)'").unwrap(); + + re.replace_all(sql, |caps: ®ex::Captures| { + let sign = &caps[1]; + let interval = &caps[2]; + format!("datetime('now', '{}{}')", sign, interval) + }) + .to_string() +} + +/// Check for patterns that cannot be auto-converted and add warnings. +fn check_unsupported_patterns(sql: &str, warnings: &mut Vec) { + // JSONB ? operator (contains-key): record->'approvedGames' ? $1 + let jsonb_contains_re = Regex::new(r"'\s+\?\s+\$\d+").unwrap(); + if jsonb_contains_re.is_match(sql) { + warnings.push( + "JSONB '?' (contains-key) operator detected -- no direct SQLite equivalent. \ + Consider using json_each() with an EXISTS subquery." + .to_string(), + ); + } + + // make_interval() + if sql.to_lowercase().contains("make_interval") { + warnings + .push("make_interval() is Postgres-specific -- needs manual conversion.".to_string()); + } + + // SIMILAR TO + let similar_re = Regex::new(r"(?i)\bSIMILAR\s+TO\b").unwrap(); + if similar_re.is_match(sql) { + warnings.push("SIMILAR TO is Postgres-specific -- use LIKE or GLOB instead.".to_string()); + } + + // ANY() / ALL() array operators + let any_re = Regex::new(r"(?i)\bANY\s*\(").unwrap(); + let all_re = Regex::new(r"(?i)\bALL\s*\(").unwrap(); + if any_re.is_match(sql) { + warnings.push("ANY() array operator detected -- no direct SQLite equivalent.".to_string()); + } + if all_re.is_match(sql) { + warnings.push("ALL() array operator detected -- no direct SQLite equivalent.".to_string()); + } + + // ::type casts other than ::jsonb + let cast_re = Regex::new(r"::[a-zA-Z_]+").unwrap(); + for m in cast_re.find_iter(sql) { + let cast = m.as_str(); + if cast != "::jsonb" { + warnings.push(format!( + "Type cast '{}' detected -- may need manual conversion.", + cast + )); + } + } +} diff --git a/src/db.rs b/src/db.rs index 903eb60..f1beecb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -35,130 +35,128 @@ impl DatabaseBackend { } } -/// Regex matching a JSON operator chain: `identifier->'key1'->'key2'->>'leaf'` -/// Also matches if `::jsonb` cast is already present. -/// Captures: (1) column name (may include `::jsonb`), (2) the full chain of `->` / `->>` and quoted keys. -static JSON_CHAIN_RE: LazyLock = LazyLock::new(|| { - // Match: word_or_dotted_name (optionally with ::jsonb) followed by ->/'key' or ->>'key' segments - // e.g. record->>'title' or lexicon_json::jsonb->'defs'->'main'->>'type' - Regex::new(r"(\w+(?:\.\w+)*(?:::jsonb)?)((?:\s*->>?\s*'[^']*')+)").unwrap() -}); - -/// Regex matching ILIKE: `expr ILIKE pattern` -static ILIKE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bILIKE\b").unwrap()); - -/// Convert SQL written in PostgreSQL dialect to work on the target backend. +/// Regex matching `json_extract(col, '$.path.to.leaf')` +/// Captures: (1) column name, (2) the JSON path after `$.` +static JSON_EXTRACT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"json_extract\((\w+(?:\.\w+)*),\s*'\$\.([^']+)'\)").unwrap()); + +/// Regex matching `datetime('now', '±N unit')` +/// Captures: (1) sign (+/-), (2) the interval value e.g. "7 days" +static DATETIME_INTERVAL_RE: LazyLock = + LazyLock::new(|| Regex::new(r"datetime\('now',\s*'([+-])(\d+\s+[^']+)'\)").unwrap()); + +/// Regex matching bare `datetime('now')` +static DATETIME_NOW_RE: LazyLock = + LazyLock::new(|| Regex::new(r"datetime\('now'\)").unwrap()); + +/// Convert SQL written in SQLite dialect to work on the target backend. /// -/// Handles: -/// - **Placeholders**: `$1, $2, ...` → `?` (SQLite) -/// - **JSON operators**: `col->>'key'` chains → -/// - Postgres: `col::jsonb->>'key'` (adds cast since columns are TEXT) -/// - SQLite: `json_extract(col, '$.key1.key2')` -/// - **ILIKE**: → SQLite `LIKE` (SQLite LIKE is case-insensitive for ASCII by default) -/// - **NOW()**: → SQLite `datetime('now')` -/// - **Boolean literals**: `true`/`false` → `1`/`0` (both backends store as INTEGER) +/// Source SQL uses SQLite syntax: +/// - `?` placeholders +/// - `json_extract(col, '$.path')` for JSON access +/// - `datetime('now')` / `datetime('now', '±N unit')` for timestamps +/// - `LIKE` for case-insensitive matching +/// - `0`/`1` for booleans +/// +/// For Postgres, converts to: +/// - `$1, $2, $3...` numbered placeholders +/// - `col::jsonb->'seg1'->'seg2'->>'leaf'` JSON chains +/// - `NOW()` / `NOW() ± INTERVAL 'N unit'` +/// - `LIKE` stays as-is (works on both) +/// - `0`/`1` stays as-is (works on both) pub fn adapt_sql(sql: &str, backend: DatabaseBackend) -> String { - let mut result = sql.to_string(); + match backend { + DatabaseBackend::Sqlite => { + // Source is already SQLite — no-op + sql.to_string() + } + DatabaseBackend::Postgres => { + let mut result = sql.to_string(); - // 1. JSON operator chains - result = adapt_json_operators(&result, backend); + // 1. json_extract → Postgres JSON chain + result = adapt_json_extract_to_postgres(&result); - // 2. ILIKE → LIKE (SQLite) - if backend == DatabaseBackend::Sqlite { - result = ILIKE_RE.replace_all(&result, "LIKE").to_string(); - } + // 2. datetime('now', '±N unit') → NOW() ± INTERVAL 'N unit' + // Must run before bare datetime('now') replacement. + result = DATETIME_INTERVAL_RE + .replace_all(&result, |caps: ®ex::Captures| { + let sign = &caps[1]; + let interval = &caps[2]; + format!("NOW() {sign} INTERVAL '{interval}'") + }) + .to_string(); - // 3. NOW() → datetime('now') (SQLite) - if backend == DatabaseBackend::Sqlite { - result = result.replace("NOW()", "datetime('now')"); - } + // 3. datetime('now') → NOW() + result = DATETIME_NOW_RE.replace_all(&result, "NOW()").to_string(); - // 4. Boolean literals → integers (both backends, columns are INTEGER) - result = adapt_booleans(&result); + // 4. ? → $1, $2, $3... (quote-aware) + result = adapt_placeholders_to_postgres(&result); - // 5. Placeholders: $1, $2, ... → ? (SQLite) - if backend == DatabaseBackend::Sqlite { - for i in (1..=50).rev() { - result = result.replace(&format!("${i}"), "?"); + result } } - - result } -/// Rewrite JSON operator chains for the target backend. -fn adapt_json_operators(sql: &str, backend: DatabaseBackend) -> String { - JSON_CHAIN_RE +/// Convert `json_extract(col, '$.seg1.seg2.leaf')` to Postgres `col::jsonb->'seg1'->'seg2'->>'leaf'`. +fn adapt_json_extract_to_postgres(sql: &str) -> String { + JSON_EXTRACT_RE .replace_all(sql, |caps: ®ex::Captures| { let col = &caps[1]; - let chain = &caps[2]; - - match backend { - DatabaseBackend::Postgres => { - // Add ::jsonb cast if not already present - if col.ends_with("::jsonb") { - format!("{col}{chain}") - } else { - format!("{col}::jsonb{chain}") - } - } - DatabaseBackend::Sqlite => { - // Parse the chain into path segments and determine final operator - let mut path_parts = Vec::new(); - let mut is_text_extract = false; - - // Split chain into individual segments: ->'key' or ->>'key' - let mut remaining = chain.trim(); - while !remaining.is_empty() { - if let Some(rest) = remaining.strip_prefix("->>") { - is_text_extract = true; - let rest = rest.trim().strip_prefix('\'').unwrap_or(rest.trim()); - if let Some((key, after)) = rest.split_once('\'') { - path_parts.push(key.to_string()); - remaining = after.trim(); - } else { - break; - } - } else if let Some(rest) = remaining.strip_prefix("->") { - is_text_extract = false; - let rest = rest.trim().strip_prefix('\'').unwrap_or(rest.trim()); - if let Some((key, after)) = rest.split_once('\'') { - path_parts.push(key.to_string()); - remaining = after.trim(); - } else { - break; - } - } else { - break; - } - } - - let json_path = format!("$.{}", path_parts.join(".")); - - if is_text_extract { - // ->> extracts as text (most common) - format!("json_extract({col}, '{json_path}')") - } else { - // -> extracts as JSON (returns JSON string) - format!("json_extract({col}, '{json_path}')") - } + let path = &caps[2]; // e.g. "defs.main.type" or "title" + + let segments: Vec<&str> = path.split('.').collect(); + let mut chain = format!("{col}::jsonb"); + + for (i, seg) in segments.iter().enumerate() { + if i == segments.len() - 1 { + // Last segment uses ->> (text extraction) + chain.push_str(&format!("->>'{seg}'")); + } else { + // Intermediate segments use -> (JSON traversal) + chain.push_str(&format!("->'{seg}'")); } } + + chain }) .to_string() } -/// Replace SQL boolean literals with integers. -/// Matches standalone `true` and `false` as SQL keywords (not inside strings). -fn adapt_booleans(sql: &str) -> String { - // Simple word-boundary replacement for boolean literals outside of strings. - // We do a basic approach: replace ` true` / ` false` / `=true` / `=false` etc. - // Using regex for word boundaries. - static BOOL_TRUE: LazyLock = LazyLock::new(|| Regex::new(r"\btrue\b").unwrap()); - static BOOL_FALSE: LazyLock = LazyLock::new(|| Regex::new(r"\bfalse\b").unwrap()); - - let result = BOOL_TRUE.replace_all(sql, "1"); - BOOL_FALSE.replace_all(&result, "0").to_string() +/// Convert `?` placeholders to `$1, $2, $3...` for Postgres, skipping `?` inside single-quoted strings. +fn adapt_placeholders_to_postgres(sql: &str) -> String { + let mut result = String::with_capacity(sql.len()); + let mut counter = 0u32; + let mut in_string = false; + + let chars: Vec = sql.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c == '\'' { + if in_string { + // Check for escaped quote '' + if i + 1 < chars.len() && chars[i + 1] == '\'' { + result.push('\''); + result.push('\''); + i += 2; + continue; + } + in_string = false; + } else { + in_string = true; + } + result.push(c); + } else if c == '?' && !in_string { + counter += 1; + result.push('$'); + result.push_str(&counter.to_string()); + } else { + result.push(c); + } + i += 1; + } + + result } /// Parse a database timestamp string to DateTime. @@ -296,17 +294,8 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn adapt_sql_postgres_keeps_placeholders() { - let sql = "SELECT * FROM foo WHERE id = $1 AND name = $2"; - assert_eq!( - adapt_sql(sql, DatabaseBackend::Postgres), - "SELECT * FROM foo WHERE id = $1 AND name = $2" - ); - } - - #[test] - fn adapt_sql_sqlite_converts_placeholders() { - let sql = "SELECT * FROM foo WHERE id = $1 AND name = $2"; + fn adapt_sql_sqlite_keeps_placeholders() { + let sql = "SELECT * FROM foo WHERE id = ? AND name = ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Sqlite), "SELECT * FROM foo WHERE id = ? AND name = ?" @@ -314,77 +303,68 @@ mod tests { } #[test] - fn adapt_sql_sqlite_handles_double_digit_placeholders() { - let sql = "INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; - let result = adapt_sql(sql, DatabaseBackend::Sqlite); - assert_eq!( - result, - "INSERT INTO t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); - } - - // ----------------------------------------------------------------------- - // adapt_sql: JSON operator conversion - // ----------------------------------------------------------------------- - - #[test] - fn adapt_sql_postgres_adds_jsonb_cast() { - let sql = "SELECT record->>'title' FROM records"; + fn adapt_sql_postgres_converts_placeholders() { + let sql = "SELECT * FROM foo WHERE id = ? AND name = ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "SELECT record::jsonb->>'title' FROM records" + "SELECT * FROM foo WHERE id = $1 AND name = $2" ); } #[test] - fn adapt_sql_postgres_no_double_cast() { - let sql = "SELECT record::jsonb->>'title' FROM records"; + fn adapt_sql_postgres_handles_many_placeholders() { + let sql = "INSERT INTO t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + let result = adapt_sql(sql, DatabaseBackend::Postgres); assert_eq!( - adapt_sql(sql, DatabaseBackend::Postgres), - "SELECT record::jsonb->>'title' FROM records" + result, + "INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)" ); } #[test] - fn adapt_sql_postgres_chained_json_operators() { - let sql = "WHERE lexicon_json->'defs'->'main'->>'type' = 'record'"; + fn adapt_sql_postgres_skips_question_marks_in_strings() { + let sql = "SELECT * FROM foo WHERE name = ? AND note LIKE '??%'"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "WHERE lexicon_json::jsonb->'defs'->'main'->>'type' = 'record'" + "SELECT * FROM foo WHERE name = $1 AND note LIKE '??%'" ); } + // ----------------------------------------------------------------------- + // adapt_sql: JSON operator conversion + // ----------------------------------------------------------------------- + #[test] - fn adapt_sql_sqlite_simple_json_extract() { - let sql = "SELECT record->>'title' FROM records WHERE collection = $1"; + fn adapt_sql_sqlite_keeps_json_extract() { + let sql = "SELECT json_extract(record, '$.title') FROM records"; assert_eq!( adapt_sql(sql, DatabaseBackend::Sqlite), - "SELECT json_extract(record, '$.title') FROM records WHERE collection = ?" + "SELECT json_extract(record, '$.title') FROM records" ); } #[test] - fn adapt_sql_sqlite_chained_json_extract() { - let sql = "WHERE lexicon_json->'defs'->'main'->>'type' = 'record'"; + fn adapt_sql_postgres_converts_simple_json_extract() { + let sql = "SELECT json_extract(record, '$.title') FROM records WHERE collection = ?"; assert_eq!( - adapt_sql(sql, DatabaseBackend::Sqlite), - "WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record'" + adapt_sql(sql, DatabaseBackend::Postgres), + "SELECT record::jsonb->>'title' FROM records WHERE collection = $1" ); } #[test] - fn adapt_sql_sqlite_json_arrow_only() { - // Single -> (not ->>) extracts as JSON - let sql = "SELECT record->'value' FROM records"; + fn adapt_sql_postgres_converts_chained_json_extract() { + let sql = "WHERE json_extract(lexicon_json, '$.defs.main.type') = 'record'"; assert_eq!( - adapt_sql(sql, DatabaseBackend::Sqlite), - "SELECT json_extract(record, '$.value') FROM records" + adapt_sql(sql, DatabaseBackend::Postgres), + "WHERE lexicon_json::jsonb->'defs'->'main'->>'type' = 'record'" ); } #[test] fn adapt_sql_multiple_json_expressions() { - let sql = "SELECT record->>'title', record->>'year' FROM records"; + let sql = + "SELECT json_extract(record, '$.title'), json_extract(record, '$.year') FROM records"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), "SELECT record::jsonb->>'title', record::jsonb->>'year' FROM records" @@ -396,82 +376,100 @@ mod tests { } // ----------------------------------------------------------------------- - // adapt_sql: ILIKE conversion + // adapt_sql: LIKE stays as-is // ----------------------------------------------------------------------- #[test] - fn adapt_sql_postgres_keeps_ilike() { - let sql = "WHERE name ILIKE $1"; + fn adapt_sql_postgres_keeps_like() { + let sql = "WHERE name LIKE ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "WHERE name ILIKE $1" + "WHERE name LIKE $1" ); } #[test] - fn adapt_sql_sqlite_converts_ilike_to_like() { - let sql = "WHERE name ILIKE $1"; + fn adapt_sql_sqlite_keeps_like() { + let sql = "WHERE name LIKE ?"; assert_eq!(adapt_sql(sql, DatabaseBackend::Sqlite), "WHERE name LIKE ?"); } // ----------------------------------------------------------------------- - // adapt_sql: NOW() conversion + // adapt_sql: datetime('now') conversion // ----------------------------------------------------------------------- #[test] - fn adapt_sql_postgres_keeps_now() { - let sql = "INSERT INTO t (created_at) VALUES (NOW())"; + fn adapt_sql_sqlite_keeps_datetime_now() { + let sql = "INSERT INTO t (created_at) VALUES (datetime('now'))"; assert_eq!( - adapt_sql(sql, DatabaseBackend::Postgres), - "INSERT INTO t (created_at) VALUES (NOW())" + adapt_sql(sql, DatabaseBackend::Sqlite), + "INSERT INTO t (created_at) VALUES (datetime('now'))" ); } #[test] - fn adapt_sql_sqlite_converts_now() { - let sql = "INSERT INTO t (created_at) VALUES (NOW())"; + fn adapt_sql_postgres_converts_datetime_now() { + let sql = "INSERT INTO t (created_at) VALUES (datetime('now'))"; assert_eq!( - adapt_sql(sql, DatabaseBackend::Sqlite), - "INSERT INTO t (created_at) VALUES (datetime('now'))" + adapt_sql(sql, DatabaseBackend::Postgres), + "INSERT INTO t (created_at) VALUES (NOW())" ); } // ----------------------------------------------------------------------- - // adapt_sql: boolean literal conversion + // adapt_sql: datetime('now', '±N unit') conversion // ----------------------------------------------------------------------- #[test] - fn adapt_sql_converts_boolean_true() { - let sql = "UPDATE t SET active = true WHERE id = $1"; + fn adapt_sql_sqlite_keeps_datetime_interval() { + let sql = "WHERE indexed_at > datetime('now', '-7 days')"; + assert_eq!( + adapt_sql(sql, DatabaseBackend::Sqlite), + "WHERE indexed_at > datetime('now', '-7 days')" + ); + } + + #[test] + fn adapt_sql_postgres_converts_datetime_minus_interval() { + let sql = "WHERE indexed_at > datetime('now', '-7 days')"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "UPDATE t SET active = 1 WHERE id = $1" + "WHERE indexed_at > NOW() - INTERVAL '7 days'" ); + } + + #[test] + fn adapt_sql_postgres_converts_datetime_plus_interval() { + let sql = "WHERE expires_at < datetime('now', '+30 minutes')"; assert_eq!( - adapt_sql(sql, DatabaseBackend::Sqlite), - "UPDATE t SET active = 1 WHERE id = ?" + adapt_sql(sql, DatabaseBackend::Postgres), + "WHERE expires_at < NOW() + INTERVAL '30 minutes'" ); } #[test] - fn adapt_sql_converts_boolean_false() { - let sql = "INSERT INTO t (backfill) VALUES (false)"; + fn adapt_sql_postgres_interval_with_json_and_placeholders() { + let sql = "SELECT json_extract(record, '$.subject') FROM records WHERE collection = ? AND indexed_at > datetime('now', '-7 days') LIMIT ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "INSERT INTO t (backfill) VALUES (0)" + "SELECT record::jsonb->>'subject' FROM records WHERE collection = $1 AND indexed_at > NOW() - INTERVAL '7 days' LIMIT $2" ); } + // ----------------------------------------------------------------------- + // adapt_sql: boolean literals stay as 0/1 + // ----------------------------------------------------------------------- + #[test] - fn adapt_sql_boolean_does_not_replace_inside_strings() { - // The word "true" in a string value like 'attribute' should not be replaced - // Note: our simple regex WILL match inside SQL string literals. This is - // acceptable because column names won't contain 'true'/'false' as substrings - // in practice, and the Lua db.raw() API uses bind parameters for values. - let sql = "SELECT * FROM t WHERE status = $1"; + fn adapt_sql_keeps_integer_booleans() { + let sql = "UPDATE t SET active = 1 WHERE id = ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "SELECT * FROM t WHERE status = $1" + "UPDATE t SET active = 1 WHERE id = $1" + ); + assert_eq!( + adapt_sql(sql, DatabaseBackend::Sqlite), + "UPDATE t SET active = 1 WHERE id = ?" ); } @@ -480,11 +478,11 @@ mod tests { // ----------------------------------------------------------------------- #[test] - fn adapt_sql_combined_json_ilike_placeholders() { - let sql = "SELECT * FROM records WHERE record->>'title' ILIKE $1 LIMIT $2"; + fn adapt_sql_combined_json_like_placeholders() { + let sql = "SELECT * FROM records WHERE json_extract(record, '$.title') LIKE ? LIMIT ?"; assert_eq!( adapt_sql(sql, DatabaseBackend::Postgres), - "SELECT * FROM records WHERE record::jsonb->>'title' ILIKE $1 LIMIT $2" + "SELECT * FROM records WHERE record::jsonb->>'title' LIKE $1 LIMIT $2" ); assert_eq!( adapt_sql(sql, DatabaseBackend::Sqlite), @@ -494,8 +492,11 @@ mod tests { #[test] fn adapt_sql_no_json_operators_unchanged() { - let sql = "SELECT COUNT(*) FROM records WHERE collection = $1"; - assert_eq!(adapt_sql(sql, DatabaseBackend::Postgres), sql); + let sql = "SELECT COUNT(*) FROM records WHERE collection = ?"; + assert_eq!( + adapt_sql(sql, DatabaseBackend::Postgres), + "SELECT COUNT(*) FROM records WHERE collection = $1" + ); } // ----------------------------------------------------------------------- diff --git a/src/event_log.rs b/src/event_log.rs index edcb628..3371b31 100644 --- a/src/event_log.rs +++ b/src/event_log.rs @@ -41,15 +41,16 @@ pub async fn spawn_retention_cleanup(db: AnyPool, retention_days: u32, backend: let interval = tokio::time::Duration::from_secs(3600); // 1 hour // Build database-specific cleanup query + // Cannot use adapt_sql: Postgres uses make_interval(days => $1) which has no SQLite equivalent pattern. let cleanup_sql = match backend { - DatabaseBackend::Postgres => { - "DELETE FROM event_logs WHERE created_at < NOW() - make_interval(days => $1)" - .to_string() - } DatabaseBackend::Sqlite => { "DELETE FROM event_logs WHERE created_at < datetime('now', '-' || ? || ' days')" .to_string() } + DatabaseBackend::Postgres => { + "DELETE FROM event_logs WHERE created_at < NOW() - make_interval(days => $1)" + .to_string() + } }; loop { @@ -81,7 +82,7 @@ pub async fn log_event(db: &AnyPool, event: EventLog, backend: DatabaseBackend) let created_at = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO event_logs (id, event_type, severity, actor_did, subject, detail, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO event_logs (id, event_type, severity, actor_did, subject, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); diff --git a/src/labeler.rs b/src/labeler.rs index dd89354..b8dc1e9 100644 --- a/src/labeler.rs +++ b/src/labeler.rs @@ -149,7 +149,7 @@ async fn run_subscription_once( // Read cursor from database. let cursor_sql = adapt_sql( - "SELECT cursor FROM labeler_subscriptions WHERE did = $1", + "SELECT cursor FROM labeler_subscriptions WHERE did = ?", state.db_backend, ); let cursor: Option<(Option,)> = sqlx::query_as(&cursor_sql) @@ -327,7 +327,7 @@ async fn apply_label(db: &sqlx::AnyPool, label: &Label, backend: DatabaseBackend if label.neg { // Negation label — remove it. let delete_sql = adapt_sql( - "DELETE FROM labels WHERE src = $1 AND uri = $2 AND val = $3", + "DELETE FROM labels WHERE src = ? AND uri = ? AND val = ?", backend, ); if let Err(e) = sqlx::query(&delete_sql) @@ -347,7 +347,7 @@ async fn apply_label(db: &sqlx::AnyPool, label: &Label, backend: DatabaseBackend let insert_sql = adapt_sql( r#" INSERT INTO labels (src, uri, val, cts, exp) - VALUES ($1, $2, $3, $4, $5) + VALUES (?, ?, ?, ?, ?) ON CONFLICT (src, uri, val) DO UPDATE SET cts = EXCLUDED.cts, exp = EXCLUDED.exp @@ -375,7 +375,7 @@ async fn apply_label(db: &sqlx::AnyPool, label: &Label, backend: DatabaseBackend async fn persist_cursor(db: &sqlx::AnyPool, did: &str, seq: i64, backend: DatabaseBackend) { let now = now_rfc3339(); let update_sql = adapt_sql( - "UPDATE labeler_subscriptions SET cursor = $1, updated_at = $2 WHERE did = $3", + "UPDATE labeler_subscriptions SET cursor = ?, updated_at = ? WHERE did = ?", backend, ); if let Err(e) = sqlx::query(&update_sql) @@ -469,18 +469,16 @@ pub async fn spawn_label_gc(db: sqlx::AnyPool, backend: DatabaseBackend) { let interval = tokio::time::Duration::from_secs(3600); // 1 hour // Build database-specific cleanup query for expired labels - let expired_sql = match backend { - DatabaseBackend::Postgres => "DELETE FROM labels WHERE exp IS NOT NULL AND exp < NOW()", - DatabaseBackend::Sqlite => { - "DELETE FROM labels WHERE exp IS NOT NULL AND exp < datetime('now')" - } - }; + let expired_sql = adapt_sql( + "DELETE FROM labels WHERE exp IS NOT NULL AND exp < datetime('now')", + backend, + ); loop { tokio::time::sleep(interval).await; // Delete expired labels. - let expired = sqlx::query(expired_sql).execute(&db).await; + let expired = sqlx::query(&expired_sql).execute(&db).await; let expired_count = match expired { Ok(r) => r.rows_affected(), diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index 9f6f163..e7d9e6b 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -33,11 +33,10 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { let backend = state.db_backend; let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val, cts FROM labels WHERE uri = $1 AND (exp IS NULL OR exp > $2)", + "SELECT src, uri, val, cts FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); - let rows: Vec<(String, String, String, String)> = - sqlx::query_as(&sql) + let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) .bind(&uri) .bind(&now) .fetch_all(&state.db) @@ -58,18 +57,16 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { } // Check for self-labels in the record itself. - let record_sql = adapt_sql( - "SELECT did, record FROM records WHERE uri = $1", - backend, - ); + let record_sql = adapt_sql("SELECT did, record FROM records WHERE uri = ?", backend); let record: Option<(String, String)> = sqlx::query_as(&record_sql) - .bind(&uri) - .fetch_optional(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; + .bind(&uri) + .fetch_optional(&state.db) + .await + .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; if let Some((did, record_str)) = record { - let record_val: serde_json::Value = serde_json::from_str(&record_str).unwrap_or(serde_json::json!({})); + let record_val: serde_json::Value = + serde_json::from_str(&record_str).unwrap_or(serde_json::json!({})); if let Some(labels) = record_val.get("labels") && let Some(values) = labels.get("values") && let Some(arr) = values.as_array() @@ -108,23 +105,26 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { // Query labels for all URIs (one query per URI since AnyPool doesn't support array binding). let label_sql = adapt_sql( - "SELECT src, uri, val, cts FROM labels WHERE uri = $1 AND (exp IS NULL OR exp > $2)", + "SELECT src, uri, val, cts FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let mut rows: Vec<(String, String, String, String)> = Vec::new(); for uri in &uri_list { - let mut uri_rows: Vec<(String, String, String, String)> = sqlx::query_as(&label_sql) - .bind(uri) - .bind(&now) - .fetch_all(&state.db) - .await - .map_err(|e| mlua::Error::runtime(format!("label batch query failed: {e}")))?; + let mut uri_rows: Vec<(String, String, String, String)> = + sqlx::query_as(&label_sql) + .bind(uri) + .bind(&now) + .fetch_all(&state.db) + .await + .map_err(|e| { + mlua::Error::runtime(format!("label batch query failed: {e}")) + })?; rows.append(&mut uri_rows); } // Query records for self-labels. let record_sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE uri = $1", + "SELECT uri, did, record FROM records WHERE uri = ?", backend, ); let mut records: Vec<(String, String, String)> = Vec::new(); @@ -141,7 +141,8 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { let result = lua.create_table()?; // Initialize empty arrays for each URI. - let mut counters: std::collections::HashMap = std::collections::HashMap::new(); + let mut counters: std::collections::HashMap = + std::collections::HashMap::new(); for uri in &uri_list { result.set(uri.as_str(), lua.create_table()?)?; counters.insert(uri.clone(), 1); @@ -163,7 +164,8 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { // Add self-labels from records. for (uri, did, record_str) in &records { - let record_val: serde_json::Value = serde_json::from_str(record_str).unwrap_or(serde_json::json!({})); + let record_val: serde_json::Value = + serde_json::from_str(record_str).unwrap_or(serde_json::json!({})); if let Some(labels) = record_val.get("labels") && let Some(values) = labels.get("values") && let Some(arr) = values.as_array() diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index e6cc00a..1844823 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -76,14 +76,14 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { format!("{sort_field} {direction}") } else { match backend { - DatabaseBackend::Postgres => format!("record::jsonb->'value'->>'{sort_field}' {direction}"), DatabaseBackend::Sqlite => format!("json_extract(record, '$.value.{sort_field}') {direction}"), + DatabaseBackend::Postgres => format!("record::jsonb->'value'->>'{sort_field}' {direction}"), } }; let rows: Vec<(String, String, String)> = if let Some(ref did) = did { let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 ORDER BY {order_expr} LIMIT $3 OFFSET $4"), + &format!("SELECT uri, did, record FROM records WHERE collection = ? AND did = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), backend, ); sqlx::query_as(&sql) @@ -96,7 +96,7 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { .map_err(|e| mlua::Error::runtime(format!("DB query failed: {e}")))? } else { let sql = adapt_sql( - &format!("SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY {order_expr} LIMIT $2 OFFSET $3"), + &format!("SELECT uri, did, record FROM records WHERE collection = ? ORDER BY {order_expr} LIMIT ? OFFSET ?"), backend, ); sqlx::query_as(&sql) @@ -143,15 +143,16 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { (Some(did), Some((cursor_ts, cursor_uri))) => { let sql = adapt_sql( "SELECT uri, did, record, created_at FROM records \ - WHERE collection = $1 AND did = $2 AND (created_at < $3 OR (created_at = $3 AND uri < $4)) \ + WHERE collection = ? AND did = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ ORDER BY created_at DESC, uri DESC \ - LIMIT $5", + LIMIT ?", backend, ); sqlx::query_as(&sql) .bind(&collection) .bind(did) .bind(cursor_ts) + .bind(cursor_ts) .bind(cursor_uri) .bind(limit) .fetch_all(&state.db) @@ -161,9 +162,9 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { (Some(did), None) => { let sql = adapt_sql( "SELECT uri, did, record, created_at FROM records \ - WHERE collection = $1 AND did = $2 \ + WHERE collection = ? AND did = ? \ ORDER BY created_at DESC, uri DESC \ - LIMIT $3", + LIMIT ?", backend, ); sqlx::query_as(&sql) @@ -177,14 +178,15 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { (None, Some((cursor_ts, cursor_uri))) => { let sql = adapt_sql( "SELECT uri, did, record, created_at FROM records \ - WHERE collection = $1 AND (created_at < $2 OR (created_at = $2 AND uri < $3)) \ + WHERE collection = ? AND (created_at < ? OR (created_at = ? AND uri < ?)) \ ORDER BY created_at DESC, uri DESC \ - LIMIT $4", + LIMIT ?", backend, ); sqlx::query_as(&sql) .bind(&collection) .bind(cursor_ts) + .bind(cursor_ts) .bind(cursor_uri) .bind(limit) .fetch_all(&state.db) @@ -194,9 +196,9 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { (None, None) => { let sql = adapt_sql( "SELECT uri, did, record, created_at FROM records \ - WHERE collection = $1 \ + WHERE collection = ? \ ORDER BY created_at DESC, uri DESC \ - LIMIT $2", + LIMIT ?", backend, ); sqlx::query_as(&sql) @@ -248,7 +250,7 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let state = state_get.clone(); async move { let backend = state.db_backend; - let sql = adapt_sql("SELECT record FROM records WHERE uri = $1", backend); + let sql = adapt_sql("SELECT record FROM records WHERE uri = ?", backend); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(&uri) .fetch_optional(&state.db) @@ -290,52 +292,51 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let like_pattern = format!("%{query}%"); + // Cannot use adapt_sql: Postgres reuses $3 for two bind positions, + // while SQLite needs separate ? for each. Different bind counts. let rows: Vec<(String, String, String)> = match backend { - DatabaseBackend::Postgres => { - let sql = adapt_sql( - &format!( - "SELECT uri, did, record FROM records \ - WHERE collection = $1 \ - AND record::jsonb->>'{field}' ILIKE $2 \ - ORDER BY \ - CASE \ - WHEN LOWER(record::jsonb->>'{field}') = LOWER($3) THEN 0 \ - WHEN LOWER(record::jsonb->>'{field}') LIKE LOWER($3) || '%' THEN 1 \ - ELSE 2 \ - END, \ - record::jsonb->>'{field}' \ - LIMIT $4" - ), - backend, + DatabaseBackend::Sqlite => { + let sql = format!( + "SELECT uri, did, record FROM records \ + WHERE collection = ? \ + AND json_extract(record, '$.{field}') LIKE ? COLLATE NOCASE \ + ORDER BY \ + CASE \ + WHEN LOWER(json_extract(record, '$.{field}')) = LOWER(?) THEN 0 \ + WHEN LOWER(json_extract(record, '$.{field}')) LIKE LOWER(?) || '%' THEN 1 \ + ELSE 2 \ + END, \ + json_extract(record, '$.{field}') \ + LIMIT ?" ); sqlx::query_as(&sql) .bind(&collection) .bind(&like_pattern) .bind(&query) + .bind(&query) .bind(limit) .fetch_all(&state.db) .await .map_err(|e| mlua::Error::runtime(format!("DB search failed: {e}")))? } - DatabaseBackend::Sqlite => { + DatabaseBackend::Postgres => { let sql = format!( "SELECT uri, did, record FROM records \ - WHERE collection = ? \ - AND json_extract(record, '$.{field}') LIKE ? COLLATE NOCASE \ + WHERE collection = $1 \ + AND record::jsonb->>'{field}' ILIKE $2 \ ORDER BY \ CASE \ - WHEN LOWER(json_extract(record, '$.{field}')) = LOWER(?) THEN 0 \ - WHEN LOWER(json_extract(record, '$.{field}')) LIKE LOWER(?) || '%' THEN 1 \ + WHEN LOWER(record::jsonb->>'{field}') = LOWER($3) THEN 0 \ + WHEN LOWER(record::jsonb->>'{field}') LIKE LOWER($3) || '%' THEN 1 \ ELSE 2 \ END, \ - json_extract(record, '$.{field}') \ - LIMIT ?" + record::jsonb->>'{field}' \ + LIMIT $4" ); sqlx::query_as(&sql) .bind(&collection) .bind(&like_pattern) .bind(&query) - .bind(&query) .bind(limit) .fetch_all(&state.db) .await @@ -378,7 +379,7 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let backend = state.db_backend; let count: (i64,) = if let Some(ref did) = did { let sql = adapt_sql( - "SELECT COUNT(*) FROM records WHERE collection = $1 AND did = $2", + "SELECT COUNT(*) FROM records WHERE collection = ? AND did = ?", backend, ); sqlx::query_as(&sql) @@ -388,10 +389,8 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { .await .map_err(|e| mlua::Error::runtime(format!("DB count failed: {e}")))? } else { - let sql = adapt_sql( - "SELECT COUNT(*) FROM records WHERE collection = $1", - backend, - ); + let sql = + adapt_sql("SELECT COUNT(*) FROM records WHERE collection = ?", backend); sqlx::query_as(&sql) .bind(&collection) .fetch_one(&state.db) @@ -425,10 +424,10 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let sql = adapt_sql( "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ INNER JOIN record_refs ref ON ref.source_uri = r.uri \ - WHERE ref.target_uri = $1 AND ref.collection = $2 AND r.did = $3 \ - AND (r.created_at < $4 OR (r.created_at = $4 AND r.uri < $5)) \ + WHERE ref.target_uri = ? AND ref.collection = ? AND r.did = ? \ + AND (r.created_at < ? OR (r.created_at = ? AND r.uri < ?)) \ ORDER BY r.created_at DESC, r.uri DESC \ - LIMIT $6", + LIMIT ?", backend, ); sqlx::query_as(&sql) @@ -436,6 +435,7 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { .bind(&collection) .bind(did) .bind(cursor_ts) + .bind(cursor_ts) .bind(cursor_uri) .bind(limit) .fetch_all(&state.db) @@ -446,9 +446,9 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let sql = adapt_sql( "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ INNER JOIN record_refs ref ON ref.source_uri = r.uri \ - WHERE ref.target_uri = $1 AND ref.collection = $2 AND r.did = $3 \ + WHERE ref.target_uri = ? AND ref.collection = ? AND r.did = ? \ ORDER BY r.created_at DESC, r.uri DESC \ - LIMIT $4", + LIMIT ?", backend, ); sqlx::query_as(&sql) @@ -464,16 +464,17 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let sql = adapt_sql( "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ INNER JOIN record_refs ref ON ref.source_uri = r.uri \ - WHERE ref.target_uri = $1 AND ref.collection = $2 \ - AND (r.created_at < $3 OR (r.created_at = $3 AND r.uri < $4)) \ + WHERE ref.target_uri = ? AND ref.collection = ? \ + AND (r.created_at < ? OR (r.created_at = ? AND r.uri < ?)) \ ORDER BY r.created_at DESC, r.uri DESC \ - LIMIT $5", + LIMIT ?", backend, ); sqlx::query_as(&sql) .bind(&uri) .bind(&collection) .bind(cursor_ts) + .bind(cursor_ts) .bind(cursor_uri) .bind(limit) .fetch_all(&state.db) @@ -484,9 +485,9 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { let sql = adapt_sql( "SELECT r.uri, r.did, r.record, r.created_at FROM records r \ INNER JOIN record_refs ref ON ref.source_uri = r.uri \ - WHERE ref.target_uri = $1 AND ref.collection = $2 \ + WHERE ref.target_uri = ? AND ref.collection = ? \ ORDER BY r.created_at DESC, r.uri DESC \ - LIMIT $3", + LIMIT ?", backend, ); sqlx::query_as(&sql) diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 339ed0b..c6cda5a 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -827,7 +827,7 @@ pub async fn execute_hook_script(event: &HookEvent<'_>) -> Option { let dead_letter_sql = adapt_sql( r#" INSERT INTO dead_letter_hooks (lexicon_id, uri, did, collection, rkey, action, record, error, attempts, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, backend, ); diff --git a/src/lua/record.rs b/src/lua/record.rs index 07bbf1f..de3ce91 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -105,11 +105,11 @@ pub fn register_record_api( let data_str = serde_json::to_string(&data).unwrap_or_default(); let upsert_sql = adapt_sql( r#"INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, - indexed_at = $7"#, + indexed_at = ?"#, backend, ); let _ = sqlx::query(&upsert_sql) @@ -120,6 +120,8 @@ pub fn register_record_api( .bind(&data_str) .bind(cid) .bind(&now) + .bind(&now) + .bind(&now) .execute(&state.db) .await; @@ -171,7 +173,7 @@ pub fn register_record_api( let now = now_rfc3339(); let upsert_sql = adapt_sql( r#"INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid"#, @@ -256,7 +258,7 @@ pub fn register_record_api( } // Delete from local DB - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = $1", backend); + let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); let _ = sqlx::query(&delete_sql).bind(&uri).execute(&state.db).await; // Clear _uri and _cid @@ -539,11 +541,11 @@ pub fn register_record_api( let data_str = serde_json::to_string(&data).unwrap_or_default(); let upsert_sql = adapt_sql( r#"INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, - indexed_at = $7"#, + indexed_at = ?"#, backend, ); let _ = sqlx::query(&upsert_sql) @@ -554,6 +556,8 @@ pub fn register_record_api( .bind(&data_str) .bind(cid) .bind(&now) + .bind(&now) + .bind(&now) .execute(&state.db) .await; @@ -610,7 +614,7 @@ pub fn register_record_api( let now = now_rfc3339(); let upsert_sql = adapt_sql( r#"INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid"#, @@ -665,7 +669,7 @@ pub fn register_record_api( async move { let backend = state.db_backend; let sql = adapt_sql( - "SELECT collection, record, cid FROM records WHERE uri = $1", + "SELECT collection, record, cid FROM records WHERE uri = ?", backend, ); let row: Option<(String, String, String)> = sqlx::query_as(&sql) @@ -732,7 +736,7 @@ pub fn register_record_api( let uri = uri.clone(); async move { let sql = adapt_sql( - "SELECT collection, record, cid FROM records WHERE uri = $1", + "SELECT collection, record, cid FROM records WHERE uri = ?", backend, ); let row: Option<(String, String, String)> = sqlx::query_as(&sql) diff --git a/src/main.rs b/src/main.rs index 9b64e3d..b25443b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,7 @@ async fn main() { let mut processed = 0usize; let query = db::adapt_sql( - "SELECT uri, collection, record FROM records ORDER BY uri LIMIT $1 OFFSET $2", + "SELECT uri, collection, record FROM records ORDER BY uri LIMIT ? OFFSET ?", backend, ); @@ -125,7 +125,7 @@ async fn main() { Ok(parsed) => { let now = db::now_rfc3339(); let update_sql = db::adapt_sql( - "UPDATE lexicons SET lexicon_json = $1, last_fetched_at = $2, revision = revision + 1, updated_at = $3 WHERE id = $4 AND source = 'network'", + "UPDATE lexicons SET lexicon_json = ?, last_fetched_at = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND source = 'network'", db_backend, ); let lexicon_json_str = diff --git a/src/record_refs.rs b/src/record_refs.rs index 5b5dbb0..0a39527 100644 --- a/src/record_refs.rs +++ b/src/record_refs.rs @@ -42,7 +42,7 @@ pub async fn sync_refs( let uris = extract_at_uris(record); // Delete existing refs for this source - let delete_sql = adapt_sql("DELETE FROM record_refs WHERE source_uri = $1", backend); + let delete_sql = adapt_sql("DELETE FROM record_refs WHERE source_uri = ?", backend); sqlx::query(&delete_sql) .bind(source_uri) .execute(db) @@ -50,7 +50,7 @@ pub async fn sync_refs( // Insert new refs let insert_sql = adapt_sql( - "INSERT INTO record_refs (source_uri, target_uri, collection) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + "INSERT INTO record_refs (source_uri, target_uri, collection) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", backend, ); for target_uri in &uris { diff --git a/src/tap.rs b/src/tap.rs index 6feb418..0a11132 100644 --- a/src/tap.rs +++ b/src/tap.rs @@ -529,11 +529,11 @@ async fn handle_record_event(state: &AppState, record: &TapRecordEvent) { let insert_sql = adapt_sql( r#" INSERT INTO records (uri, did, collection, rkey, record, cid, indexed_at, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, - indexed_at = $7 + indexed_at = ? "#, backend, ); @@ -545,6 +545,8 @@ async fn handle_record_event(state: &AppState, record: &TapRecordEvent) { .bind(serde_json::to_string(&rec_to_store).unwrap_or_default()) .bind(cid) .bind(&now) + .bind(&now) + .bind(&now) .execute(db) .await { @@ -640,7 +642,7 @@ async fn handle_record_event(state: &AppState, record: &TapRecordEvent) { } } - let delete_sql = adapt_sql("DELETE FROM records WHERE uri = $1", backend); + let delete_sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); match sqlx::query(&delete_sql).bind(&uri).execute(db).await { Ok(_) => { log_event( @@ -701,7 +703,7 @@ async fn handle_lexicon_schema_event(state: &AppState, did: &str, record: &TapRe // Check if this NSID is one we're tracking and the DID matches the authority. let select_sql = adapt_sql( - "SELECT target_collection FROM lexicons WHERE id = $1 AND source = 'network' AND authority_did = $2", + "SELECT target_collection FROM lexicons WHERE id = ? AND source = 'network' AND authority_did = ?", backend, ); let tracked: Option<(Option,)> = sqlx::query_as(&select_sql) @@ -746,13 +748,13 @@ async fn handle_lexicon_schema_event(state: &AppState, did: &str, record: &TapRe let upsert_sql = adapt_sql( r#" INSERT INTO lexicons (id, lexicon_json, backfill, target_collection, source, authority_did, last_fetched_at, created_at) - VALUES ($1, $2, 0, $3, 'network', $4, $5, $5) + VALUES (?, ?, 0, ?, 'network', ?, ?, ?) ON CONFLICT (id) DO UPDATE SET lexicon_json = EXCLUDED.lexicon_json, target_collection = EXCLUDED.target_collection, - last_fetched_at = $5, + last_fetched_at = ?, revision = lexicons.revision + 1, - updated_at = $5 + updated_at = ? "#, backend, ); @@ -762,6 +764,9 @@ async fn handle_lexicon_schema_event(state: &AppState, did: &str, record: &TapRe .bind(&target_collection) .bind(did) .bind(&now) + .bind(&now) + .bind(&now) + .bind(&now) .execute(db) .await { @@ -779,7 +784,7 @@ async fn handle_lexicon_schema_event(state: &AppState, did: &str, record: &TapRe } "delete" => { // Remove from lexicons table and registry. - let delete_sql = adapt_sql("DELETE FROM lexicons WHERE id = $1", backend); + let delete_sql = adapt_sql("DELETE FROM lexicons WHERE id = ?", backend); let _ = sqlx::query(&delete_sql).bind(nsid).execute(db).await; let was_present = lexicons.remove(nsid).await; diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index eb9fa49..1cf3504 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -93,7 +93,7 @@ async fn handle_create_record( let sql = adapt_sql( r#" INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid @@ -181,11 +181,11 @@ async fn handle_put_record( let sql = adapt_sql( r#" INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, - indexed_at = $7 + indexed_at = ? "#, backend, ); @@ -197,6 +197,7 @@ async fn handle_put_record( .bind(&record_str) .bind(cid) .bind(&now) + .bind(&now) .execute(&state.db) .await; @@ -246,7 +247,7 @@ async fn handle_delete_record( .map_err(|e| AppError::Internal(format!("failed to read PDS response: {e}")))?; let backend = state.db_backend; - let sql = adapt_sql("DELETE FROM records WHERE uri = $1", backend); + let sql = adapt_sql("DELETE FROM records WHERE uri = ?", backend); let _ = sqlx::query(&sql).bind(uri).execute(&state.db).await; Ok(( diff --git a/src/xrpc/query.rs b/src/xrpc/query.rs index 287361f..7c54c2f 100644 --- a/src/xrpc/query.rs +++ b/src/xrpc/query.rs @@ -51,7 +51,7 @@ pub(super) async fn handle_query( let rows: Vec<(String, String, String)> = if let Some(did) = did { let sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 ORDER BY indexed_at DESC LIMIT $3 OFFSET $4", + "SELECT uri, did, record FROM records WHERE collection = ? AND did = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); sqlx::query_as(&sql) @@ -64,7 +64,7 @@ pub(super) async fn handle_query( .map_err(|e| AppError::Internal(format!("DB query failed: {e}")))? } else { let sql = adapt_sql( - "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3", + "SELECT uri, did, record FROM records WHERE collection = ? ORDER BY indexed_at DESC LIMIT ? OFFSET ?", backend, ); sqlx::query_as(&sql) @@ -103,7 +103,7 @@ pub(super) async fn handle_query( pub(super) async fn handle_get_record(state: &AppState, uri: &str) -> Result { let backend = state.db_backend; - let sql = adapt_sql("SELECT record FROM records WHERE uri = $1", backend); + let sql = adapt_sql("SELECT record FROM records WHERE uri = ?", backend); let row: Option<(String,)> = sqlx::query_as(&sql) .bind(uri) .fetch_optional(&state.db) diff --git a/tests/common/app.rs b/tests/common/app.rs index 4a5ed93..2d44b8f 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -44,10 +44,11 @@ impl TestApp { }; let sql = adapt_sql( - "INSERT INTO users (did, is_super, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + "INSERT INTO users (id, did, is_super, created_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", backend, ); sqlx::query(&sql) + .bind(uuid::Uuid::new_v4().to_string()) .bind(&admin_did) .bind(1_i32) .bind(now_rfc3339()) diff --git a/tests/e2e_admin.rs b/tests/e2e_admin.rs index 4942c0e..89dc9d2 100644 --- a/tests/e2e_admin.rs +++ b/tests/e2e_admin.rs @@ -152,7 +152,7 @@ async fn admin_auto_bootstrap_first_user() { assert_eq!(resp.status(), StatusCode::OK); // Verify the DID was inserted. - let sql = adapt_sql("SELECT COUNT(*) FROM users WHERE did = $1", backend); + let sql = adapt_sql("SELECT COUNT(*) FROM users WHERE did = ?", backend); let count: (i64,) = sqlx::query_as(&sql) .bind(bootstrap_did) .fetch_one(&app.state.db) @@ -425,7 +425,7 @@ async fn stats_with_seeded_records() { }); let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO lexicons (id, lexicon_json, created_at) VALUES ($1, $2, $3)", + "INSERT INTO lexicons (id, lexicon_json, created_at) VALUES (?, ?, ?)", backend, ); sqlx::query(&sql) @@ -439,7 +439,7 @@ async fn stats_with_seeded_records() { // Seed records directly let record_val = serde_json::json!({"title": "test"}); let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) diff --git a/tests/e2e_labelers.rs b/tests/e2e_labelers.rs index b08dd76..60390eb 100644 --- a/tests/e2e_labelers.rs +++ b/tests/e2e_labelers.rs @@ -347,7 +347,7 @@ async fn labeler_delete_removes_labels() { // Seed some labels from that labeler let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts) VALUES ($1, $2, $3, $4)", + "INSERT INTO labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -370,7 +370,7 @@ async fn labeler_delete_removes_labels() { .unwrap(); // Verify labels were also removed - let sql = adapt_sql("SELECT COUNT(*) FROM labels WHERE src = $1", backend); + let sql = adapt_sql("SELECT COUNT(*) FROM labels WHERE src = ?", backend); let count: (i64,) = sqlx::query_as(&sql) .bind("did:plc:lab1") .fetch_one(&app.state.db) diff --git a/tests/e2e_network_lexicons.rs b/tests/e2e_network_lexicons.rs index 3281361..1954152 100644 --- a/tests/e2e_network_lexicons.rs +++ b/tests/e2e_network_lexicons.rs @@ -46,16 +46,18 @@ async fn seed_network_lexicon(app: &TestApp, nsid: &str, authority_did: &str) { let sql = adapt_sql( r#" INSERT INTO lexicons (id, lexicon_json, backfill, source, authority_did, last_fetched_at, created_at) - VALUES ($1, $2, 0, 'network', $3, $4, $4) + VALUES (?, ?, 0, 'network', ?, ?, ?) ON CONFLICT (id) DO NOTHING "#, backend, ); + let now = now_rfc3339(); sqlx::query(&sql) .bind(nsid) .bind(serde_json::to_string(&lexicon_json).unwrap_or_default()) .bind(authority_did) - .bind(now_rfc3339()) + .bind(&now) + .bind(&now) .execute(&app.state.db) .await .expect("failed to seed network lexicon"); @@ -131,7 +133,7 @@ async fn network_lexicon_delete_removes_tracking_and_lexicon() { // Verify lexicon is removed. let sql = adapt_sql( - "SELECT COUNT(*) FROM lexicons WHERE id = $1 AND source = 'network'", + "SELECT COUNT(*) FROM lexicons WHERE id = ? AND source = 'network'", backend, ); let count: (i64,) = sqlx::query_as(&sql) diff --git a/tests/e2e_xrpc.rs b/tests/e2e_xrpc.rs index ecf3661..f9b0794 100644 --- a/tests/e2e_xrpc.rs +++ b/tests/e2e_xrpc.rs @@ -92,7 +92,7 @@ async fn seed_record(app: &TestApp, uri: &str, did: &str, collection: &str, reco let rkey = uri.split('/').next_back().unwrap_or("1"); let backend = app.state.db_backend; let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -495,7 +495,7 @@ async fn xrpc_delete_procedure_removes_record() { seed_record(&app, uri, did, "games.gamesgamesgamesgames.game", &record).await; // Verify record exists - let sql = adapt_sql("SELECT COUNT(*) FROM records WHERE uri = $1", backend); + let sql = adapt_sql("SELECT COUNT(*) FROM records WHERE uri = ?", backend); let count: (i64,) = sqlx::query_as(&sql) .bind(uri) .fetch_one(&app.state.db) diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 5f7d103..0c6acdc 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -56,7 +56,7 @@ async fn seed_record( record: serde_json::Value, ) { let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -82,7 +82,7 @@ async fn seed_label( ) { if let Some(exp) = exp { let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts, exp) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO labels (src, uri, val, cts, exp) VALUES (?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -96,7 +96,7 @@ async fn seed_label( .expect("failed to seed label"); } else { let sql = adapt_sql( - "INSERT INTO labels (src, uri, val, cts) VALUES ($1, $2, $3, $4)", + "INSERT INTO labels (src, uri, val, cts) VALUES (?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -146,7 +146,7 @@ async fn get_labels_returns_external_labels() { let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = $1 AND (exp IS NULL OR exp > $2)", + "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -196,7 +196,7 @@ async fn get_labels_filters_expired() { let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = $1 AND (exp IS NULL OR exp > $2)", + "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -230,7 +230,7 @@ async fn get_labels_includes_self_labels() { }); seed_record(&pool, backend, uri, "did:plc:author", record.clone()).await; - let sql = adapt_sql("SELECT did, record FROM records WHERE uri = $1", backend); + let sql = adapt_sql("SELECT did, record FROM records WHERE uri = ?", backend); let fetched: Option<(String, String)> = sqlx::query_as(&sql) .bind(uri) .fetch_optional(&pool) @@ -273,7 +273,7 @@ async fn get_labels_empty_for_unlabeled_record() { let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri = $1 AND (exp IS NULL OR exp > $2)", + "SELECT src, uri, val FROM labels WHERE uri = ? AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -311,7 +311,7 @@ async fn get_labels_batch_returns_labels_per_uri() { .await; let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) @@ -332,7 +332,7 @@ async fn get_labels_batch_returns_labels_per_uri() { let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri IN ($1, $2) AND (exp IS NULL OR exp > $3)", + "SELECT src, uri, val FROM labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -365,7 +365,7 @@ async fn get_labels_batch_empty_for_no_labels() { let now = now_rfc3339(); let sql = adapt_sql( - "SELECT src, uri, val FROM labels WHERE uri IN ($1, $2) AND (exp IS NULL OR exp > $3)", + "SELECT src, uri, val FROM labels WHERE uri IN (?, ?) AND (exp IS NULL OR exp > ?)", backend, ); let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) @@ -398,7 +398,7 @@ async fn label_negation_removes_row() { // Verify it exists let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = $1 AND uri = $2 AND val = $3", + "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) @@ -412,7 +412,7 @@ async fn label_negation_removes_row() { // Simulate negation (same logic as labeler.rs) let sql = adapt_sql( - "DELETE FROM labels WHERE src = $1 AND uri = $2 AND val = $3", + "DELETE FROM labels WHERE src = ? AND uri = ? AND val = ?", backend, ); sqlx::query(&sql) @@ -425,7 +425,7 @@ async fn label_negation_removes_row() { // Verify it's gone let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = $1 AND uri = $2 AND val = $3", + "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) @@ -474,7 +474,7 @@ async fn label_upsert_is_idempotent() { } let sql = adapt_sql( - "SELECT COUNT(*) FROM labels WHERE src = $1 AND uri = $2 AND val = $3", + "SELECT COUNT(*) FROM labels WHERE src = ? AND uri = ? AND val = ?", backend, ); let count: (i64,) = sqlx::query_as(&sql) diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index f57c50f..d8d4ed4 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -81,7 +81,7 @@ async fn seed_records(pool: &sqlx::AnyPool, backend: DatabaseBackend) { let now = now_rfc3339(); let sql = adapt_sql( - "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); for (uri, did, collection, rkey, record, cid) in &records { -- 2.51.2