Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ require (
entgo.io/ent v0.14.5
github.com/a-h/templ v0.3.977
github.com/go-chi/chi/v5 v5.2.3
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/lib/pq v1.11.2
github.com/mattn/go-sqlite3 v1.14.17
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/image v0.34.0
golang.org/x/oauth2 v0.34.0
golang.org/x/time v0.14.0
)

require (
Expand All @@ -23,13 +27,10 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
Expand All @@ -46,6 +47,5 @@ require (
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
92 changes: 71 additions & 21 deletions internal/database/entity_tags.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Governing: SPEC-0014 REQ "Denormalized Entity Tags Table", ADR-0023 (PostgreSQL), ADR-0004 (Ent ORM)
// Governing: SPEC-0014 REQ "Denormalized Entity Tags Table", ADR-0023 (multi-database support), ADR-0004 (Ent ORM)
package database

import (
Expand All @@ -10,22 +10,42 @@ import (
// CreateEntityTagsTable creates the denormalized entity_tags query table
// if it does not already exist. This table lives outside the Ent schema
// and is maintained via raw SQL for cross-entity filtered tag lookups.
//
// Governing: SPEC-0014 REQ "Denormalized Entity Tags Table" — DDL is
// emitted per-dialect for all three supported drivers (sqlite3, postgres,
// mysql) per ADR-0023.
func CreateEntityTagsTable(ctx context.Context, driver string, db *sql.DB) error {
var ddl string
if driver == "postgres" {
ddl = entityTagsPostgres
} else {
ddl = entityTagsSQLite
// Statements are executed one at a time rather than as a single
// multi-statement batch: go-sql-driver/mysql rejects multi-statement
// Exec calls unless the DSN opts into multiStatements=true, which the
// SPEC-0014 default MySQL DSN does not.
for _, stmt := range entityTagsDDL(driver) {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to create entity_tags table: %w", err)
}
}
return nil
}

if _, err := db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("failed to create entity_tags table: %w", err)
// entityTagsDDL returns the dialect-specific DDL statements for the
// entity_tags table. Statements are idempotent (IF NOT EXISTS) so the
// migration is safe to run on every startup.
//
// Governing: ADR-0023 — driver values are "postgres", "mysql", and the
// SQLite default ("sqlite3").
func entityTagsDDL(driver string) []string {
switch driver {
case "postgres":
return entityTagsPostgres
case "mysql":
return entityTagsMySQL
default:
return entityTagsSQLite
}
return nil
}

const entityTagsPostgres = `
CREATE TABLE IF NOT EXISTS entity_tags (
var entityTagsPostgres = []string{
`CREATE TABLE IF NOT EXISTS entity_tags (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
Expand All @@ -35,13 +55,43 @@ CREATE TABLE IF NOT EXISTS entity_tags (
entity_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT entity_tags_unique UNIQUE (tag_id, entity_type, entity_id)
);
CREATE INDEX IF NOT EXISTS idx_entity_tags_lookup ON entity_tags (user_id, tag_type, tag_name, entity_type);
CREATE INDEX IF NOT EXISTS idx_entity_tags_entity ON entity_tags (entity_type, entity_id);
`
)`,
`CREATE INDEX IF NOT EXISTS idx_entity_tags_lookup ON entity_tags (user_id, tag_type, tag_name, entity_type)`,
`CREATE INDEX IF NOT EXISTS idx_entity_tags_entity ON entity_tags (entity_type, entity_id)`,
}

// entityTagsMySQL is the MariaDB/MySQL dialect DDL. Differences from the
// other dialects (Governing: ADR-0023, SPEC-0014 REQ "Denormalized Entity
// Tags Table"):
// - BIGINT AUTO_INCREMENT PRIMARY KEY (MySQL has no SERIAL/AUTOINCREMENT
// keyword in the SQLite/Postgres sense)
// - Secondary indexes are declared inline in CREATE TABLE because MySQL
// (unlike MariaDB 10.1+) does not support CREATE INDEX IF NOT EXISTS,
// and inline KEY clauses keep the statement idempotent on both.
// - DATETIME DEFAULT CURRENT_TIMESTAMP instead of TIMESTAMPTZ (and avoids
// the TIMESTAMP 2038 range limit)
// - ENGINE=InnoDB pinned explicitly: FK + ON DELETE CASCADE semantics
// require InnoDB.
var entityTagsMySQL = []string{
`CREATE TABLE IF NOT EXISTS entity_tags (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
tag_type VARCHAR(20) NOT NULL,
tag_name VARCHAR(255) NOT NULL,
entity_type VARCHAR(20) NOT NULL,
entity_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT entity_tags_unique UNIQUE (tag_id, entity_type, entity_id),
KEY idx_entity_tags_lookup (user_id, tag_type, tag_name, entity_type),
KEY idx_entity_tags_entity (entity_type, entity_id),
CONSTRAINT entity_tags_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT entity_tags_tag_fk FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB`,
}

const entityTagsSQLite = `
CREATE TABLE IF NOT EXISTS entity_tags (
var entityTagsSQLite = []string{
`CREATE TABLE IF NOT EXISTS entity_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
Expand All @@ -51,7 +101,7 @@ CREATE TABLE IF NOT EXISTS entity_tags (
entity_id INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (tag_id, entity_type, entity_id)
);
CREATE INDEX IF NOT EXISTS idx_entity_tags_lookup ON entity_tags (user_id, tag_type, tag_name, entity_type);
CREATE INDEX IF NOT EXISTS idx_entity_tags_entity ON entity_tags (entity_type, entity_id);
`
)`,
`CREATE INDEX IF NOT EXISTS idx_entity_tags_lookup ON entity_tags (user_id, tag_type, tag_name, entity_type)`,
`CREATE INDEX IF NOT EXISTS idx_entity_tags_entity ON entity_tags (entity_type, entity_id)`,
}
Loading
Loading