Port upstream PR #359: entity_tags dialect DDL test coverage + driver-matrix tests - #33
Conversation
Ports joestump#359, implements joestump#307. The fork already had the core of upstream PR 359: dedicated MySQL/MariaDB dialect DDL (BIGINT AUTO_INCREMENT, table-level FKs, single-statement Exec with an information_schema index guard) and lib/pq + go-sql-driver/mysql as direct go.mod requires. This ports the remaining gaps, adapted to the fork's structure: - internal/database/entity_tags_test.go: per-dialect DDL token unit test (TestEntityTagsDDLDialects) now covering Postgres and SQLite in addition to the fork's existing MySQL invariants; unknown-driver fallback test; driver-matrix integration test (TestCreateEntityTagsTable_Matrix) — SQLite in-memory always, Postgres and MariaDB gated behind SPOTTER_TEST_POSTGRES_DSN / SPOTTER_TEST_MYSQL_DSN with graceful skips (also skips when the DSN is set but the server is unreachable). The matrix proves idempotency, insert/select round-trip, the (tag_id, entity_type, entity_id) unique constraint, column types via PRAGMA/information_schema, and (fork extension) that the MySQL information_schema index guard sees both indexes after migration. - internal/database/entity_tags.go: MySQL created_at is now DATETIME instead of TIMESTAMP (avoids the 2038 range limit and MySQL's implicit TIMESTAMP auto-init/auto-update behavior) and ENGINE=InnoDB is pinned explicitly (FK + ON DELETE CASCADE require InnoDB), matching upstream's dialect parity. Existing tables are unaffected (CREATE TABLE IF NOT EXISTS). Defect fixed while porting: upstream's reject-token check for the MySQL dialect used a substring that would false-positive on "CURRENT_TIMESTAMP,"; the ported test rejects "created_at TIMESTAMP" instead. Upstream's destructive DROP TABLE cleanup in the live matrix helpers is retained but now called out with an explicit warning to use throwaway databases. Governing: SPEC-0016 REQ "Denormalized Entity Tags Table", ADR-0023. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xyerp7w1SH5vG48z93DxN
joestump-agent
left a comment
There was a problem hiding this comment.
Adversarial review of the port of upstream joestump#359 (issue joestump#307). Verdict: APPROVE (posted as COMMENT because the review account authored the PR and GitHub forbids self-approval). No blocking findings.
Verified mechanically on the branch: make generate, go build ./..., go vet ./..., gofmt -l (clean), go test ./internal/database/... -run EntityTags -v (all pass; postgres/mysql matrix subtests skip cleanly), and full go test ./... green. Also probed the stale-DSN path with unreachable DSNs — openLiveDB skips gracefully on ping failure rather than failing.
Findings (all NON-BLOCKING):
-
NON-BLOCKING — internal/database/entity_tags.go:122 — the TIMESTAMP→DATETIME change only affects fresh databases.
CREATE TABLE IF NOT EXISTSnever migrates, so existing MySQL deployments keep a TIMESTAMP column (UTC-normalized, 2038-limited) while new installs get DATETIME (session-timezone-literal). This is benign today: I grepped every entity_tags code path — created_at is never SELECTed, UPDATEd, or ORDERed on (inserts in internal/tags/upsert.go omit it and rely on the column DEFAULT, which DATETIME DEFAULT CURRENT_TIMESTAMP supports on MySQL >= 5.6.5/MariaDB >= 10.0.1; the old DDL had no implicit ON UPDATE either, since an explicit DEFAULT suppresses it). But if a future feature starts reading created_at, the fleet will have mixed type/timezone semantics. Worth a one-line note in the DDL comment (or a follow-up ALTER migration issue) so this doesn't surprise anyone later. -
NON-BLOCKING — internal/database/entity_tags_test.go:96-107 — token assertions are exact-substring, case- and spacing-sensitive. I checked the dangerous collision cases and they are actually sound as written: reject "AUTOINCREMENT" cannot substring-match "AUTO_INCREMENT" (underscore), reject "created_at TIMESTAMP" cannot match "DEFAULT CURRENT_TIMESTAMP" (prefixed column name scopes it). Remaining brittleness is minor: a regression to lowercase
created_at timestampor double-spaced tokens would false-pass the reject list, and a legitimate whitespace refactor (e.g.ENGINE = InnoDB, reorderedDEFAULT CURRENT_TIMESTAMP NOT NULL) would false-fail the want list. Acceptable for a DDL-invariant test; just be aware these pins are stylistic as well as semantic. -
NON-BLOCKING — internal/database/entity_tags_test.go:186-231, 250-262 — the live-DB subtests DROP users/tags/entity_tags gated only on presence of SPOTTER_TEST_POSTGRES_DSN / SPOTTER_TEST_MYSQL_DSN. Gating is effectively airtight in practice: the var names are project-specific, nothing in .github/ or the Makefile sets them, and unreachable servers skip instead of fail. If you want belt-and-suspenders against someone exporting these globally in a shell profile pointed at a real DB, a second opt-in var (e.g. SPOTTER_TEST_DESTRUCTIVE=1) would close it — the in-test WARNING comment is a reasonable substitute.
-
NON-BLOCKING (observation) — internal/database/entity_tags_test.go:145 — the new test cements the silent unknown-driver→SQLite fallback in CreateEntityTagsTable. This matches the pre-existing
driverToStdlibcontract in internal/database/db.go:67 (the whole app treats unknown drivers as sqlite3), and a genuinely wrong driver would fail loudly anyway (SQLite AUTOINCREMENT syntax is invalid on the other servers), so the fallback cannot silently corrupt a real database. Fine to test-pin it; erroring instead would be a separate, app-wide design change.
Probed without success: substring collisions in the reject-token logic, semicolon leakage into the single-statement MySQL DDL (ENGINE=InnoDB suffix keeps the pre-existing NotContains-";" assertion honest), shared-cache in-memory SQLite name collisions between tests, subtest ordering/cleanup dependencies (drop order respects FKs; each live subtest resets its own state), and information_schema case-sensitivity in the column assertions (scans are positional, values lowercased).
Generated by Claude Code
CREATE TABLE IF NOT EXISTS never migrates, so existing MySQL deployments keep a TIMESTAMP created_at (UTC-normalized, 2038-limited) while new installs get DATETIME (session-timezone literal). Benign while created_at is never read; the note prevents surprise if a future feature reads it. Addresses PR #33 review feedback. Governing: SPEC-0016 REQ "Denormalized Entity Tags Table", ADR-0023. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xyerp7w1SH5vG48z93DxN
Ports upstream joestump#359, implementing joestump#307, adapted to this fork's existing structure.
Gap analysis (what the fork already had)
internal/database/entity_tags.go:entityTagsMySQLwithBIGINT AUTO_INCREMENT, table-level FK constraints, single-statementExecContext(works with the default MySQL DSN withoutmultiStatements=true), and index creation guarded by aninformation_schema.statisticslookup (mysqlIndexExists) since MySQL 8 lacksCREATE INDEX IF NOT EXISTS. Functionally equivalent to upstream's inline-KEYapproach.BIGSERIAL PRIMARY KEY,TIMESTAMPTZ,CREATE INDEX IF NOT EXISTS— already matches upstream.lib/pqandgo-sql-driver/mysqlwere already direct requires. No go.mod changes needed.DriverNamedialect-detection test.What was ported
TestEntityTagsDDLDialects: per-dialect DDL token unit test, now covering Postgres and SQLite (fork previously only asserted MySQL invariants), including reject-tokens so dialect syntax can't leak across dialects.TestCreateEntityTagsTable_UnknownDriverFallsBackToSQLite: unknown drivers take the SQLite path, matchingdriverToStdlib.TestCreateEntityTagsTable_Matrix: driver-matrix integration test — SQLite in-memory always; Postgres/MariaDB gated behindSPOTTER_TEST_POSTGRES_DSN/SPOTTER_TEST_MYSQL_DSNwith graceful skips (also skips if the DSN is set but the server is unreachable). Proves idempotency across two migration runs, insert/select round-trip, the(tag_id, entity_type, entity_id)unique constraint, and column types viaPRAGMA table_info/information_schema.columns. Fork extension: the MySQL sub-test also verifies both indexes exist via the fork'smysqlIndexExistsguard.created_atis nowDATETIMEinstead ofTIMESTAMP(avoids the 2038 range limit and MySQL's implicit TIMESTAMP auto-init/auto-update behavior) andENGINE=InnoDBis pinned explicitly (FK +ON DELETE CASCADErequire InnoDB). Existing deployments are unaffected — the table is only created withIF NOT EXISTS.Preserved fork-specific behavior: the
mysqlIndexExistsinformation_schema guard, single-Exec Postgres/SQLite DDL,driverPostgresconstant usage, testify style, and the fork's MariaDB startup fixes (#21).Defects found while porting
TIMESTAMP,for the MySQL dialect matches the substring insideCURRENT_TIMESTAMP,; the ported test rejectscreated_at TIMESTAMPinstead. (Upstream's ownAUTOINCREMENTreject-token is safe only because the underscore inAUTO_INCREMENTbreaks the substring — the ported test keeps explicit per-dialect reject lists.)DROP TABLE IF EXISTS entity_tags/tags/usersin the target database. Retained (needed for clean re-runs) but now carries an explicit warning to point the DSNs at throwaway databases only.openLiveDBskips (rather than fails) when a configured DSN is unreachable — intentional per upstream so a stale env var doesn't break local runs, but it can mask CI misconfiguration.mysqlIndexExistsguard uses parameterized queries.Test results
make generate— cleango build ./...— cleango vet ./...— cleango test ./internal/database/...— PASS (matrix Postgres/MySQL sub-tests SKIP gracefully, no servers in CI)go test ./...— all packages PASS🤖 Generated with Claude Code
https://claude.ai/code/session_019Xyerp7w1SH5vG48z93DxN
Generated by Claude Code