chore: auto-commit (57 archivos)
- frontend/functions/core/format_datetime_short.md - frontend/functions/core/format_datetime_short.test.ts - frontend/functions/core/format_datetime_short.ts - frontend/functions/core/format_duration.md - frontend/functions/core/format_duration.test.ts - frontend/functions/core/format_duration.ts - frontend/functions/core/month_grid.md - frontend/functions/core/month_grid.test.ts - frontend/functions/core/month_grid.ts - frontend/functions/core/string_hash_palette.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionCookieClear invalidates the named session cookie by setting
|
||||
// MaxAge=-1. The browser removes the cookie immediately on receipt.
|
||||
// It does not return an error because http.SetCookie never fails at runtime.
|
||||
func SessionCookieClear(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: http_session_cookie_clear
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieClear(w http.ResponseWriter, name string)"
|
||||
description: "Invalida la cookie de sesion en el browser fijando MaxAge=-1. Path='/', HttpOnly=true, SameSite=Lax. No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, logout, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie a invalidar (debe coincidir con el nombre usado al crearla)"
|
||||
output: "escribe el header Set-Cookie con MaxAge=-1 en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie clear setea MaxAge negativo"
|
||||
- "cookie clear valor es vacio"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_clear_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_clear.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func handleLogout(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token != "" {
|
||||
_ = infra.SessionDelete(db.conn, token)
|
||||
}
|
||||
infra.SessionCookieClear(w, "my_session")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. MaxAge=-1 hace que el browser elimine la cookie inmediatamente independientemente de la fecha Expires original. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers y nunca falla. Complemento de `http_session_cookie_set_go_infra`.
|
||||
@@ -0,0 +1,50 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionCookieClear(t *testing.T) {
|
||||
t.Run("cookie clear setea MaxAge negativo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "my_session")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.MaxAge >= 0 {
|
||||
t.Errorf("MaxAge: got %d, want negative", c.MaxAge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie clear valor es vacio", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("no cookie set")
|
||||
}
|
||||
if cookies[0].Value != "" {
|
||||
t.Errorf("Value: got %q, want empty", cookies[0].Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookieSet writes a session cookie to the response.
|
||||
// The cookie is HttpOnly, Path="/", SameSite=Lax and expires at the
|
||||
// Unix timestamp expiresAt (seconds). It does not return an error
|
||||
// because http.SetCookie never fails at runtime.
|
||||
func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(expiresAt, 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: http_session_cookie_set
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64)"
|
||||
description: "Escribe una cookie de sesion HttpOnly en la respuesta HTTP. Path='/', SameSite=Lax, Expires=time.Unix(expiresAt,0). No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http", "time"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie (p.ej. 'kanban_session')"
|
||||
- name: token
|
||||
desc: "valor del token de sesion"
|
||||
- name: expiresAt
|
||||
desc: "timestamp Unix (segundos) de expiracion de la cookie"
|
||||
output: "escribe el header Set-Cookie en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie set con nombre y token correctos"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_set_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_set.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
sess, err := infra.SessionCreate(db, userID, 7*24*time.Hour, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
infra.SessionCookieSet(w, "my_session", sess.Token, sess.ExpiresAt)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers del ResponseWriter y nunca falla. El campo `error_type` se omite porque la firma no tiene retorno de error — hay precedente en el registry (componentes C++ y otros helpers HTTP impuros sin error_type).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionCookieSet(t *testing.T) {
|
||||
t.Run("cookie set con nombre y token correctos", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
SessionCookieSet(w, "my_session", "tok_abc", expires)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.Value != "tok_abc" {
|
||||
t.Errorf("Value: got %q, want %q", c.Value, "tok_abc")
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Errorf("Path: got %q, want %q", c.Path, "/")
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Errorf("expected HttpOnly=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieSet(w, "s", "v", time.Now().Add(time.Hour).Unix())
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionTokenExtract extracts a session token from the request.
|
||||
// It checks the cookie named cookieName first; if present and non-empty,
|
||||
// that value is returned. Otherwise it checks the Authorization header
|
||||
// for a "Bearer <token>" prefix and returns the token part.
|
||||
// Returns "" if no token is found in either source.
|
||||
func SessionTokenExtract(r *http.Request, cookieName string) string {
|
||||
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: http_session_token_extract
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SessionTokenExtract(r *http.Request, cookieName string) string"
|
||||
description: "Extrae el token de sesion de un request HTTP. Comprueba primero la cookie con el nombre indicado; si no esta, parsea el header Authorization 'Bearer <token>'. Retorna cadena vacia si no hay token."
|
||||
tags: [http, session, cookie, bearer, auth, token]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: r
|
||||
desc: "request HTTP entrante"
|
||||
- name: cookieName
|
||||
desc: "nombre de la cookie de sesion a buscar (p.ej. 'kanban_session')"
|
||||
output: "token extraido de la cookie o del header Authorization; cadena vacia si no hay token en ninguna fuente"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie present retorna token de cookie"
|
||||
- "bearer header retorna token de header"
|
||||
- "cookie gana sobre bearer header"
|
||||
- "sin token retorna cadena vacia"
|
||||
test_file_path: "functions/infra/http_session_token_extract_test.go"
|
||||
file_path: "functions/infra/http_session_token_extract.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// validate token...
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. Funcion pura: solo lee el request, no muta estado. La cookie tiene precedencia sobre el header Authorization para mantener consistencia con el comportamiento del browser (la cookie es el canal primario; el header es para clientes API que no gestionan cookies).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionTokenExtract(t *testing.T) {
|
||||
const cookieName = "test_session"
|
||||
|
||||
t.Run("cookie present retorna token de cookie", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bearer header retorna token de header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_bearer" {
|
||||
t.Errorf("got %q, want %q", got, "tok_bearer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie gana sobre bearer header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q (cookie should win)", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin token retorna cadena vacia", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ApplyMigrations reads SQL files matching glob from fsys, sorts them
|
||||
// lexicographically (NNN_name.sql order), and executes every statement
|
||||
// found in each file against conn.
|
||||
//
|
||||
// If glob is empty, it defaults to "migrations/*.sql".
|
||||
//
|
||||
// Each statement is executed individually. Errors that look like
|
||||
// idempotent SQLite errors (duplicate column, already exists) are
|
||||
// silently ignored so that migrations can be replayed safely against
|
||||
// a database that was partially migrated.
|
||||
//
|
||||
// NOTE: the internal statement parser splits on ";" at the end of a
|
||||
// trimmed line. This is intentionally simple and will break if SQL
|
||||
// strings contain a literal semicolon at end-of-line. Avoid using
|
||||
// such patterns in migration files.
|
||||
func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error {
|
||||
if glob == "" {
|
||||
glob = "migrations/*.sql"
|
||||
}
|
||||
files, err := fs.Glob(fsys, glob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := fs.ReadFile(fsys, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitSQLStatements(string(b)) {
|
||||
s := strings.TrimSpace(stmt)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
if isIdempotentMigrationError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitSQLStatements splits a SQL script into individual statements.
|
||||
// Lines starting with "--" (comments) and blank lines are skipped.
|
||||
// A statement ends when a trimmed line ends with ";".
|
||||
func splitSQLStatements(s string) []string {
|
||||
out := []string{}
|
||||
cur := strings.Builder{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "--") || trim == "" {
|
||||
continue
|
||||
}
|
||||
cur.WriteString(line)
|
||||
cur.WriteString("\n")
|
||||
if strings.HasSuffix(trim, ";") {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isIdempotentMigrationError returns true for SQLite errors that arise
|
||||
// from re-applying a migration that was already applied (duplicate
|
||||
// column, table/index already exists).
|
||||
func isIdempotentMigrationError(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate column") ||
|
||||
strings.Contains(msg, "already exists")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: sqlite_apply_migrations
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error"
|
||||
description: "Aplica migraciones SQL desde un fs.FS en orden lexicografico. Lee archivos con glob (default 'migrations/*.sql'), divide por sentencias y ejecuta cada una contra conn. Errores idempotentes (duplicate column, already exists) se ignoran."
|
||||
tags: [database, sqlite, migration, schema, embed, fs]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt", "io/fs", "sort", "strings"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion *sql.DB donde se ejecutan las migraciones"
|
||||
- name: fsys
|
||||
desc: "sistema de archivos con los .sql (tipicamente embed.FS del caller)"
|
||||
- name: glob
|
||||
desc: "patron glob para seleccionar archivos (vacio = 'migrations/*.sql')"
|
||||
output: "nil si todas las migraciones se aplicaron correctamente; error del primer fallo no idempotente con nombre de archivo incluido"
|
||||
tested: true
|
||||
tests:
|
||||
- "una migracion se aplica correctamente"
|
||||
- "multiples migraciones en orden"
|
||||
- "error real se propaga"
|
||||
- "ALTER TABLE ADD COLUMN duplicado se ignora"
|
||||
test_file_path: "functions/infra/sqlite_apply_migrations_test.go"
|
||||
file_path: "functions/infra/sqlite_apply_migrations.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
db, err := infra.SQLiteOpen(path, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := infra.ApplyMigrations(db, migrationsFS, ""); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. El parser de sentencias SQL es intencionalmente simple: separa por `;` al final de una linea (ignorando comentarios `--` y lineas vacias). Esta logica falla si el SQL contiene un `;` dentro de un string literal al final de linea — evitar ese patron en los archivos de migracion.
|
||||
|
||||
Los errores idempotentes (`duplicate column`, `already exists`) se ignoran para que las migraciones sean re-ejecutables contra DBs que ya tenian parte del schema. Esto permite un flujo sin tabla `_migrations` para proyectos pequenos; para proyectos con muchas migraciones y rollback, usar `migration_up_go_infra` / `migration_down_go_infra`.
|
||||
@@ -0,0 +1,98 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// makeFS builds a simple in-memory FS with the given path→content pairs.
|
||||
func makeFS(files map[string]string) fstest.MapFS {
|
||||
m := make(fstest.MapFS, len(files))
|
||||
for path, content := range files {
|
||||
m[path] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestApplyMigrations(t *testing.T) {
|
||||
t.Run("una migracion se aplica correctamente", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT);`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Verify table exists.
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("table 'items' not created; count=%d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples migraciones en orden", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);`,
|
||||
"migrations/002_add_column.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Insert using the new column.
|
||||
if _, err := db.Exec(`INSERT INTO t (id, val) VALUES (1, 'hello')`); err != nil {
|
||||
t.Fatalf("insert failed (column may be missing): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error real se propaga", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_bad.sql": `THIS IS NOT VALID SQL;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err == nil {
|
||||
t.Errorf("expected error for invalid SQL, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ALTER TABLE ADD COLUMN duplicado se ignora", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
setup := `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);`
|
||||
if _, err := db.Exec(setup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Run ALTER that would fail with "duplicate column".
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_dup.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Errorf("duplicate column error should be ignored, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ColumnExists reports whether the named column exists in the given table
|
||||
// by querying PRAGMA table_info. Returns false if the table does not exist.
|
||||
func ColumnExists(conn *sql.DB, table, name string) (bool, error) {
|
||||
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if colName == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: sqlite_column_exists
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ColumnExists(conn *sql.DB, table, name string) (bool, error)"
|
||||
description: "Comprueba si una columna existe en una tabla SQLite consultando PRAGMA table_info. Retorna false sin error si la tabla no existe."
|
||||
tags: [database, sqlite, schema, pragma, column, migration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion SQLite abierta"
|
||||
- name: table
|
||||
desc: "nombre de la tabla a inspeccionar"
|
||||
- name: name
|
||||
desc: "nombre de la columna a buscar"
|
||||
output: "true si la columna existe, false si no existe o la tabla no existe; error si la query falla"
|
||||
tested: true
|
||||
tests:
|
||||
- "columna existente retorna true"
|
||||
- "columna inexistente retorna false"
|
||||
- "tabla inexistente retorna false sin error"
|
||||
test_file_path: "functions/infra/sqlite_column_exists_test.go"
|
||||
file_path: "functions/infra/sqlite_column_exists.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
exists, err := ColumnExists(db, "cards", "assignee_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
_, err = db.Exec(`ALTER TABLE cards ADD COLUMN assignee_id TEXT`)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. Util como comprobacion previa a ALTER TABLE ADD COLUMN en scripts de migracion que necesitan ser idempotentes. PRAGMA table_info retorna cero filas si la tabla no existe, por lo que la funcion retorna false sin error en ese caso.
|
||||
@@ -0,0 +1,59 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func openMemDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open :memory: db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestColumnExists(t *testing.T) {
|
||||
t.Run("columna existente retorna true", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("expected true for existing column 'name'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("columna inexistente retorna false", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for missing column")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tabla inexistente retorna false sin error", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
got, err := ColumnExists(db, "no_such_table", "col")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for non-existent table")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user