refactor: implement database-specific schema architecture with schema-as-query pattern

🏗️ **Major Database Schema Refactoring**

**Problem Solved**: Eliminated model duplication and multiple sources of truth by:
- Removed duplicate models (`internal/models/content.go`)
- Replaced inlined schema strings with sqlc-generated setup functions
- Implemented database-specific schemas with proper NOT NULL constraints

**Key Improvements**:
 **Single Source of Truth**: Database schemas define all types, no manual sync needed
 **Clean Generated Types**: sqlc generates `string` and `int64` instead of `sql.NullString/sql.NullTime`
 **Schema-as-Query Pattern**: Setup functions generated by sqlc for type safety
 **Database-Specific Optimization**: SQLite INTEGER timestamps, PostgreSQL BIGINT timestamps
 **Cross-Database Compatibility**: Single codebase supports both SQLite and PostgreSQL

**Architecture Changes**:
- `db/sqlite/` - SQLite-specific schema and setup queries
- `db/postgresql/` - PostgreSQL-specific schema and setup queries
- `db/queries/` - Cross-database CRUD queries using `sqlc.arg()` syntax
- `internal/db/database.go` - Database abstraction with runtime selection
- `internal/api/models.go` - Clean API models for requests/responses

**Version Control System**: Complete element-level history with user attribution and rollback

**Verification**:  Full API workflow tested (create → update → rollback → versions)
**Production Ready**: Supports SQLite (development) → PostgreSQL (production) migration
This commit is contained in:
2025-09-09 00:25:07 +02:00
parent 161c320304
commit bab329b429
41 changed files with 3703 additions and 561 deletions

View File

@@ -0,0 +1,214 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: content.sql
package postgresql
import (
"context"
"strings"
)
const createContent = `-- name: CreateContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
`
type CreateContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
func (q *Queries) CreateContent(ctx context.Context, arg CreateContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, createContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.Type,
arg.LastEditedBy,
)
var i Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
)
return i, err
}
const deleteContent = `-- name: DeleteContent :exec
DELETE FROM content
WHERE id = $1 AND site_id = $2
`
type DeleteContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
}
func (q *Queries) DeleteContent(ctx context.Context, arg DeleteContentParams) error {
_, err := q.db.ExecContext(ctx, deleteContent, arg.ID, arg.SiteID)
return err
}
const getAllContent = `-- name: GetAllContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = $1
ORDER BY updated_at DESC
`
func (q *Queries) GetAllContent(ctx context.Context, siteID string) ([]Content, error) {
rows, err := q.db.QueryContext(ctx, getAllContent, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Content
for rows.Next() {
var i Content
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getBulkContent = `-- name: GetBulkContent :many
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
FROM content
WHERE site_id = $1 AND id IN ($2)
`
type GetBulkContentParams struct {
SiteID string `json:"site_id"`
Ids []string `json:"ids"`
}
func (q *Queries) GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error) {
query := getBulkContent
var queryParams []interface{}
queryParams = append(queryParams, arg.SiteID)
if len(arg.Ids) > 0 {
for _, v := range arg.Ids {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(arg.Ids))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Content
for rows.Next() {
var i Content
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getContent = `-- name: GetContent :one
SELECT id, site_id, value, type, created_at, updated_at, last_edited_by
FROM content
WHERE id = $1 AND site_id = $2
`
type GetContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
}
func (q *Queries) GetContent(ctx context.Context, arg GetContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, getContent, arg.ID, arg.SiteID)
var i Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
)
return i, err
}
const updateContent = `-- name: UpdateContent :one
UPDATE content
SET value = $1, type = $2, last_edited_by = $3
WHERE id = $4 AND site_id = $5
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
`
type UpdateContentParams struct {
Value string `json:"value"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
ID string `json:"id"`
SiteID string `json:"site_id"`
}
func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, updateContent,
arg.Value,
arg.Type,
arg.LastEditedBy,
arg.ID,
arg.SiteID,
)
var i Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
)
return i, err
}

View File

@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package postgresql
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,25 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package postgresql
type Content struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastEditedBy string `json:"last_edited_by"`
}
type ContentVersion struct {
VersionID int32 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`
}

View File

@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package postgresql
import (
"context"
)
type Querier interface {
CreateContent(ctx context.Context, arg CreateContentParams) (Content, error)
CreateContentSiteIndex(ctx context.Context) error
CreateContentUpdatedAtIndex(ctx context.Context) error
CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error
CreateUpdateFunction(ctx context.Context) error
CreateVersionsLookupIndex(ctx context.Context) error
DeleteContent(ctx context.Context, arg DeleteContentParams) error
DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error
GetAllContent(ctx context.Context, siteID string) ([]Content, error)
GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error)
GetBulkContent(ctx context.Context, arg GetBulkContentParams) ([]Content, error)
GetContent(ctx context.Context, arg GetContentParams) (Content, error)
GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error)
GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error)
InitializeSchema(ctx context.Context) error
InitializeVersionsTable(ctx context.Context) error
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,87 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: setup.sql
package postgresql
import (
"context"
)
const createContentSiteIndex = `-- name: CreateContentSiteIndex :exec
CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id)
`
func (q *Queries) CreateContentSiteIndex(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, createContentSiteIndex)
return err
}
const createContentUpdatedAtIndex = `-- name: CreateContentUpdatedAtIndex :exec
CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at)
`
func (q *Queries) CreateContentUpdatedAtIndex(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, createContentUpdatedAtIndex)
return err
}
const createUpdateFunction = `-- name: CreateUpdateFunction :exec
CREATE OR REPLACE FUNCTION update_content_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = EXTRACT(EPOCH FROM NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`
func (q *Queries) CreateUpdateFunction(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, createUpdateFunction)
return err
}
const createVersionsLookupIndex = `-- name: CreateVersionsLookupIndex :exec
CREATE INDEX IF NOT EXISTS idx_content_versions_lookup ON content_versions(content_id, site_id, created_at DESC)
`
func (q *Queries) CreateVersionsLookupIndex(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, createVersionsLookupIndex)
return err
}
const initializeSchema = `-- name: InitializeSchema :exec
CREATE TABLE IF NOT EXISTS content (
id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
updated_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL,
PRIMARY KEY (id, site_id)
)
`
func (q *Queries) InitializeSchema(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, initializeSchema)
return err
}
const initializeVersionsTable = `-- name: InitializeVersionsTable :exec
CREATE TABLE IF NOT EXISTS content_versions (
version_id SERIAL PRIMARY KEY,
content_id TEXT NOT NULL,
site_id TEXT NOT NULL,
value TEXT NOT NULL,
type TEXT NOT NULL,
created_at BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW())) NOT NULL,
created_by TEXT DEFAULT 'system' NOT NULL
)
`
func (q *Queries) InitializeVersionsTable(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, initializeVersionsTable)
return err
}

View File

@@ -0,0 +1,175 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: versions.sql
package postgresql
import (
"context"
"database/sql"
)
const createContentVersion = `-- name: CreateContentVersion :exec
INSERT INTO content_versions (content_id, site_id, value, type, created_by)
VALUES ($1, $2, $3, $4, $5)
`
type CreateContentVersionParams struct {
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
CreatedBy string `json:"created_by"`
}
func (q *Queries) CreateContentVersion(ctx context.Context, arg CreateContentVersionParams) error {
_, err := q.db.ExecContext(ctx, createContentVersion,
arg.ContentID,
arg.SiteID,
arg.Value,
arg.Type,
arg.CreatedBy,
)
return err
}
const deleteOldVersions = `-- name: DeleteOldVersions :exec
DELETE FROM content_versions
WHERE created_at < $1 AND site_id = $2
`
type DeleteOldVersionsParams struct {
CreatedBefore int64 `json:"created_before"`
SiteID string `json:"site_id"`
}
func (q *Queries) DeleteOldVersions(ctx context.Context, arg DeleteOldVersionsParams) error {
_, err := q.db.ExecContext(ctx, deleteOldVersions, arg.CreatedBefore, arg.SiteID)
return err
}
const getAllVersionsForSite = `-- name: GetAllVersionsForSite :many
SELECT
cv.version_id, cv.content_id, cv.site_id, cv.value, cv.type, cv.created_at, cv.created_by,
c.value as current_value
FROM content_versions cv
LEFT JOIN content c ON cv.content_id = c.id AND cv.site_id = c.site_id
WHERE cv.site_id = $1
ORDER BY cv.created_at DESC
LIMIT $2
`
type GetAllVersionsForSiteParams struct {
SiteID string `json:"site_id"`
LimitCount int32 `json:"limit_count"`
}
type GetAllVersionsForSiteRow struct {
VersionID int32 `json:"version_id"`
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
CreatedAt int64 `json:"created_at"`
CreatedBy string `json:"created_by"`
CurrentValue sql.NullString `json:"current_value"`
}
func (q *Queries) GetAllVersionsForSite(ctx context.Context, arg GetAllVersionsForSiteParams) ([]GetAllVersionsForSiteRow, error) {
rows, err := q.db.QueryContext(ctx, getAllVersionsForSite, arg.SiteID, arg.LimitCount)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllVersionsForSiteRow
for rows.Next() {
var i GetAllVersionsForSiteRow
if err := rows.Scan(
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
&i.CurrentValue,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getContentVersion = `-- name: GetContentVersion :one
SELECT version_id, content_id, site_id, value, type, created_at, created_by
FROM content_versions
WHERE version_id = $1
`
func (q *Queries) GetContentVersion(ctx context.Context, versionID int32) (ContentVersion, error) {
row := q.db.QueryRowContext(ctx, getContentVersion, versionID)
var i ContentVersion
err := row.Scan(
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
)
return i, err
}
const getContentVersionHistory = `-- name: GetContentVersionHistory :many
SELECT version_id, content_id, site_id, value, type, created_at, created_by
FROM content_versions
WHERE content_id = $1 AND site_id = $2
ORDER BY created_at DESC
LIMIT $3
`
type GetContentVersionHistoryParams struct {
ContentID string `json:"content_id"`
SiteID string `json:"site_id"`
LimitCount int32 `json:"limit_count"`
}
func (q *Queries) GetContentVersionHistory(ctx context.Context, arg GetContentVersionHistoryParams) ([]ContentVersion, error) {
rows, err := q.db.QueryContext(ctx, getContentVersionHistory, arg.ContentID, arg.SiteID, arg.LimitCount)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ContentVersion
for rows.Next() {
var i ContentVersion
if err := rows.Scan(
&i.VersionID,
&i.ContentID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.CreatedBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}