DOK-04: Custom-Fields- und Klassifikationsvorlagen-Migration #264

Merged
patrick merged 2 commits from feature/dok-04-custom-fields-klassifikationsvorlagen-migration into main 2026-10-14 17:17:17 +02:00
4 changed files with 546 additions and 0 deletions

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# DOK-04 – Prüfprotokoll
Migration: `dms/internal/migrationmetadata` — Custom Fields (MET-02,
`metadata_fields`/`metadata_field_versions`) und Klassifikationsvorlagen
(MET-04, `document_types`/`document_type_fields`) aus archivdms, inklusive
bereits befüllter Feldwerte an durch DOK-01 migrierten Dokumenten
(`document_metadata_values`). Keine neue SQL-Migration nötig — alle
Zieltabellen existieren bereits (MET-02/MET-04 sind fertig gemergt).
## Prüfung 1 — Stichprobe von 5 synthetischen Vorlagen/Feldern vor/nach Migration
Synthetische Quelle (`dms/internal/migrationmetadata/migrate_test.go`,
`newSyntheticSource`): 5 Custom Fields (Text/Datum/Zahl/Bool/Auswahl, je mit
Pflichtkennzeichen, Standardwert bzw. Validierungsregel) und 5 Klassifikations-
vorlagen (Name, Aufbewahrungsklasse, gebündelte Felder).
Ausführung: `go test ./internal/migrationmetadata/... -run TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition -v`
gegen `dms_dev` (Host 192.168.1.204).
Zielwert: Label, Feldtyp und Pflichtkennzeichen jedes der 5 Felder sowie Name,
Aufbewahrungsklasse und Anzahl gebündelter Felder jeder der 5 Vorlagen stimmen
nach der Migration exakt mit der Quelle überein.
Ergebnis: **PASS** (`TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition`,
0,29s, 2026-10-14).
## Prüfung 2 — Feldwerte einer Stichprobe migrierter Dokumente gegen Quelle
Synthetische Quelle: 3 Feldwerte an 2 synthetischen Dokumenten, deren IDs
über dieselbe deterministische Ableitung (`deriveUUID("document", ...)`) wie
in DOK-01 erzeugt werden — simuliert den Zustand nach einer vorangegangenen
DOK-01-Dokumentenmigration.
Ausführung: `go test ./internal/migrationmetadata/... -run TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten -v`
gegen `dms_dev`.
Zielwert: alle 3 Feldwerte sind nach der Migration unter der korrekten
Dokument-/Feld-ID-Kombination mit identischem Wert vorhanden.
Ergebnis: **PASS** (`TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten`,
0,25s, 2026-10-14).
## Zusatzprüfung — Wiederholbarkeit
`TestMigrateAll_AkzeptanzkriteriumWiederholbar`: ein zweiter Lauf gegen
dieselbe Quelle migriert 0 neue Felder/Vorlagen/Werte und erzeugt keine
Duplikate (deterministische IDs, `ON CONFLICT DO NOTHING`).
Ergebnis: **PASS** (0,25s, 2026-10-14).
## Regressionsprüfung
`go build ./internal/migrationmetadata/...`, `go vet ./internal/migrationmetadata/...`
sowie die Bestandstests `./internal/metadatafield/...` und
`./internal/documenttype/...` liefen nach der Änderung unverändert grün
(10 Tests, alle PASS) — keine Rückwirkung auf MET-02/MET-04 selbst.
## Umgebung
Host 192.168.1.204 (Wegwerf-Checkout `/root/dok04-work-<timestamp>`, nach
Testlauf entfernt), DB `dms_dev`, Go 1.26.5 / Postgres 17.10. Ausschließlich
synthetische Testfixtures, keine echten archivdms-Daten.
+201
View File
@@ -0,0 +1,201 @@
// Package migrationmetadata implementiert DOK-04: Uebernahme von
// Custom-Fields (MET-02, dms/internal/metadatafield) und Klassifikations-
// vorlagen (MET-04, dms/internal/documenttype) aus archivdms, inklusive der
// bereits an migrierten Dokumenten (DOK-01) befuellten Feldwerte. Reines
// SQL-Metadaten-Geschaeft, keine Binaerdaten (Produkt-DNA). Das trainierte
// ML-Klassifikator-Modell selbst wird bewusst NICHT migriert (siehe DOK-01).
package migrationmetadata
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
func marshalRule(rule map[string]any) ([]byte, error) {
if rule == nil {
rule = map[string]any{}
}
return json.Marshal(rule)
}
// SourceCustomField ist eine Zeile aus archivdms' Felddefinitionstabelle
// (SQL-Export), Gegenstueck zu metadatafield.Field.
type SourceCustomField struct {
SourceFieldID string
Key string
Label string
Type string
Required bool
DefaultValue *string
ValidationRule map[string]any
}
// SourceClassificationTemplate ist eine Zeile aus archivdms' Klassifikations-
// vorlagentabelle, Gegenstueck zu documenttype.DocumentType. Tag-Buendelung
// ist nicht Bestandteil von DOK-04 (eigenes Tag-Migrationsticket).
type SourceClassificationTemplate struct {
SourceTemplateID string
Name string
RetentionClass *string
SourceFieldIDs []string
}
// SourceFieldValue ist ein bereits in archivdms befuellter Feldwert an einem
// Dokument, das DOK-01 bereits in dieses Ziel-Tenant migriert hat.
type SourceFieldValue struct {
SourceDocumentID string
SourceFieldID string
Value string
}
// Source kapselt den lesenden Zugriff auf archivdms' Custom-Field-/
// Klassifikationsvorlagen-Schema. Eine echte Implementierung liest gegen
// archivdms, Tests verwenden ausschliesslich synthetische Fixtures.
type Source interface {
CustomFields(ctx context.Context) ([]SourceCustomField, error)
ClassificationTemplates(ctx context.Context) ([]SourceClassificationTemplate, error)
FieldValues(ctx context.Context) ([]SourceFieldValue, error)
}
// Migrator uebertraegt Custom Fields, Klassifikationsvorlagen und Feldwerte
// einer Quelle in genau einen Ziel-Tenant.
type Migrator struct {
pool *pgxpool.Pool
source Source
tenantSlug string
}
func NewMigrator(pool *pgxpool.Pool, source Source, tenantSlug string) *Migrator {
return &Migrator{pool: pool, source: source, tenantSlug: tenantSlug}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
CustomFieldsMigrated int
TemplatesMigrated int
FieldValuesMigrated int
}
// MigrateAll uebertraegt zuerst die Custom Fields, dann die Klassifikations-
// vorlagen (die Felder referenzieren), dann die Feldwerte (die Dokumente
// referenzieren). Ziel-IDs werden deterministisch aus den Quell-IDs
// abgeleitet (deriveUUID) — ein erneuter Lauf trifft so immer dieselben
// Zeilen und erzeugt keine Duplikate.
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
var res Result
fields, err := m.source.CustomFields(ctx)
if err != nil {
return res, fmt.Errorf("migrationmetadata: custom fields aus quelle lesen: %w", err)
}
for _, f := range fields {
migrated, err := m.migrateCustomField(ctx, f)
if err != nil {
return res, err
}
if migrated {
res.CustomFieldsMigrated++
}
}
templates, err := m.source.ClassificationTemplates(ctx)
if err != nil {
return res, fmt.Errorf("migrationmetadata: klassifikationsvorlagen aus quelle lesen: %w", err)
}
for _, t := range templates {
migrated, err := m.migrateTemplate(ctx, t)
if err != nil {
return res, err
}
if migrated {
res.TemplatesMigrated++
}
}
values, err := m.source.FieldValues(ctx)
if err != nil {
return res, fmt.Errorf("migrationmetadata: feldwerte aus quelle lesen: %w", err)
}
for _, v := range values {
migrated, err := m.migrateFieldValue(ctx, v)
if err != nil {
return res, err
}
if migrated {
res.FieldValuesMigrated++
}
}
return res, nil
}
func (m *Migrator) migrateCustomField(ctx context.Context, f SourceCustomField) (bool, error) {
fieldID := deriveUUID("metadatafield", m.tenantSlug, f.SourceFieldID)
ruleJSON, err := marshalRule(f.ValidationRule)
if err != nil {
return false, fmt.Errorf("migrationmetadata: validierungsregel fuer feld %q serialisieren: %w", f.SourceFieldID, err)
}
tag, err := m.pool.Exec(ctx, `
INSERT INTO metadata_fields (id, field_key, label, field_type, required, default_value, validation_rule, version)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1)
ON CONFLICT (id) DO NOTHING
`, fieldID, f.Key, f.Label, f.Type, f.Required, f.DefaultValue, ruleJSON)
if err != nil {
return false, fmt.Errorf("migrationmetadata: custom field %q anlegen: %w", f.SourceFieldID, err)
}
if tag.RowsAffected() == 0 {
return false, nil
}
if _, err := m.pool.Exec(ctx, `
INSERT INTO metadata_field_versions (field_id, version, label, field_type, required, default_value, validation_rule)
VALUES ($1, 1, $2, $3, $4, $5, $6)
`, fieldID, f.Label, f.Type, f.Required, f.DefaultValue, ruleJSON); err != nil {
return false, fmt.Errorf("migrationmetadata: versionshistorie fuer feld %q schreiben: %w", f.SourceFieldID, err)
}
return true, nil
}
func (m *Migrator) migrateTemplate(ctx context.Context, t SourceClassificationTemplate) (bool, error) {
templateID := deriveUUID("documenttype", m.tenantSlug, t.SourceTemplateID)
tag, err := m.pool.Exec(ctx, `
INSERT INTO document_types (id, name, retention_class) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING
`, templateID, t.Name, t.RetentionClass)
if err != nil {
return false, fmt.Errorf("migrationmetadata: klassifikationsvorlage %q anlegen: %w", t.SourceTemplateID, err)
}
if tag.RowsAffected() == 0 {
return false, nil
}
for _, sourceFieldID := range t.SourceFieldIDs {
fieldID := deriveUUID("metadatafield", m.tenantSlug, sourceFieldID)
if _, err := m.pool.Exec(ctx, `
INSERT INTO document_type_fields (document_type_id, field_id) VALUES ($1, $2)
ON CONFLICT (document_type_id, field_id) DO NOTHING
`, templateID, fieldID); err != nil {
return false, fmt.Errorf("migrationmetadata: feld %q an vorlage %q binden: %w", sourceFieldID, t.SourceTemplateID, err)
}
}
return true, nil
}
func (m *Migrator) migrateFieldValue(ctx context.Context, v SourceFieldValue) (bool, error) {
documentID := deriveUUID("document", m.tenantSlug, v.SourceDocumentID)
fieldID := deriveUUID("metadatafield", m.tenantSlug, v.SourceFieldID)
tag, err := m.pool.Exec(ctx, `
INSERT INTO document_metadata_values (document_id, field_id, value) VALUES ($1, $2, $3)
ON CONFLICT (document_id, field_id) DO NOTHING
`, documentID, fieldID, v.Value)
if err != nil {
return false, fmt.Errorf("migrationmetadata: feldwert fuer dokument %q/feld %q schreiben: %w", v.SourceDocumentID, v.SourceFieldID, err)
}
return tag.RowsAffected() > 0, nil
}
@@ -0,0 +1,258 @@
package migrationmetadata
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb"
)
// memorySource ist eine In-Memory-Testquelle: simuliert archivdms' Custom-
// Field-/Klassifikationsvorlagen-Export rein synthetisch (Produktarchitektur-
// Vorgabe: keine echten archivdms-Daten in Tests).
type memorySource struct {
fields []SourceCustomField
templates []SourceClassificationTemplate
values []SourceFieldValue
}
func (s *memorySource) CustomFields(ctx context.Context) ([]SourceCustomField, error) {
return s.fields, nil
}
func (s *memorySource) ClassificationTemplates(ctx context.Context) ([]SourceClassificationTemplate, error) {
return s.templates, nil
}
func (s *memorySource) FieldValues(ctx context.Context) ([]SourceFieldValue, error) {
return s.values, nil
}
// newSyntheticSource liefert 5 synthetische Vorlagen/Felder (Pruefung 1:
// Stichprobe von 5), plus Feldwerte an zwei synthetischen, bereits
// migrierten Dokumenten.
func newSyntheticSource() *memorySource {
return &memorySource{
fields: []SourceCustomField{
{SourceFieldID: "archivdms-field-1", Key: "aktenzeichen", Label: "Aktenzeichen", Type: "text", Required: true, ValidationRule: map[string]any{"pattern": "^[A-Z0-9-]+$"}},
{SourceFieldID: "archivdms-field-2", Key: "faelligkeit", Label: "Faelligkeit", Type: "date", Required: false},
{SourceFieldID: "archivdms-field-3", Key: "betrag", Label: "Betrag", Type: "number", Required: false, ValidationRule: map[string]any{"min": float64(0)}},
{SourceFieldID: "archivdms-field-4", Key: "geprueft", Label: "Geprueft", Type: "bool", Required: false, DefaultValue: strPtr("false")},
{SourceFieldID: "archivdms-field-5", Key: "kategorie", Label: "Kategorie", Type: "select", Required: false, ValidationRule: map[string]any{"options": []any{"privat", "geschaeftlich"}}},
},
templates: []SourceClassificationTemplate{
{SourceTemplateID: "archivdms-tpl-1", Name: "Rechnungseingang", RetentionClass: strPtr("10-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-3"}},
{SourceTemplateID: "archivdms-tpl-2", Name: "Vertrag", RetentionClass: strPtr("30-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-2"}},
{SourceTemplateID: "archivdms-tpl-3", Name: "Korrespondenz", RetentionClass: nil, SourceFieldIDs: []string{"archivdms-field-5"}},
{SourceTemplateID: "archivdms-tpl-4", Name: "Mahnung", RetentionClass: strPtr("6-jahre"), SourceFieldIDs: []string{"archivdms-field-1", "archivdms-field-3", "archivdms-field-4"}},
{SourceTemplateID: "archivdms-tpl-5", Name: "Sonstiges", RetentionClass: nil, SourceFieldIDs: nil},
},
values: []SourceFieldValue{
{SourceDocumentID: "archivdms-doc-1", SourceFieldID: "archivdms-field-1", Value: "AZ-2024-001"},
{SourceDocumentID: "archivdms-doc-1", SourceFieldID: "archivdms-field-3", Value: "199.99"},
{SourceDocumentID: "archivdms-doc-2", SourceFieldID: "archivdms-field-1", Value: "AZ-2024-002"},
},
}
}
func strPtr(s string) *string { return &s }
func setupMigratorTest(t *testing.T) (*Migrator, *pgxpool.Pool) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY, folder_id UUID, title TEXT NOT NULL, current_revision_id UUID,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS metadata_fields (
id UUID PRIMARY KEY, field_key TEXT NOT NULL UNIQUE, label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'bool', 'select')),
required BOOLEAN NOT NULL DEFAULT false, default_value TEXT, validation_rule JSONB NOT NULL DEFAULT '{}'::jsonb,
version INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS metadata_field_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
version INT NOT NULL, label TEXT NOT NULL, field_type TEXT NOT NULL, required BOOLEAN NOT NULL,
default_value TEXT, validation_rule JSONB NOT NULL DEFAULT '{}'::jsonb, changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (field_id, version)
);
CREATE TABLE IF NOT EXISTS document_metadata_values (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL, PRIMARY KEY (document_id, field_id)
);
CREATE TABLE IF NOT EXISTS document_types (
id UUID PRIMARY KEY, name TEXT NOT NULL UNIQUE, retention_class TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS document_type_fields (
document_type_id UUID NOT NULL REFERENCES document_types(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
PRIMARY KEY (document_type_id, field_id)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = pool.Exec(ctx, `TRUNCATE document_type_fields, document_types, document_metadata_values, metadata_field_versions, metadata_fields, documents, users CASCADE`)
})
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO users (email, name) VALUES ($1, 'Migrations-Testbenutzer') RETURNING id
`, fmt.Sprintf("dok04-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
// Dokumente, an denen bereits Feldwerte haengen, simulieren den Stand
// NACH DOK-01 — dieselbe deterministische Ableitung ("document"-Namespace)
// wie dort liefert hier wie dort dieselbe ID.
for _, sourceDocID := range []string{"archivdms-doc-1", "archivdms-doc-2"} {
documentID := deriveUUID("document", "acme-test", sourceDocID)
if _, err := pool.Exec(ctx, `
INSERT INTO documents (id, title, created_by) VALUES ($1, $2, $3)
`, documentID, "Testakte "+sourceDocID, userID); err != nil {
t.Fatalf("testdokument %q anlegen: %v", sourceDocID, err)
}
}
migrator := NewMigrator(pool, newSyntheticSource(), "acme-test")
return migrator, pool
}
// TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition prueft
// Akzeptanzkriterium 1 (Pruefung 1: Stichprobe von 5 Vorlagen/Feldern
// vor/nach Migration verglichen): jedes Custom Field und jede Klassifikations-
// vorlage ist nach der Migration mit identischer Definition vorhanden.
func TestMigrateAll_AkzeptanzkriteriumIdentischeDefinition(t *testing.T) {
migrator, pool := setupMigratorTest(t)
ctx := context.Background()
res, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("MigrateAll: %v", err)
}
if res.CustomFieldsMigrated != 5 || res.TemplatesMigrated != 5 {
t.Fatalf("erwartet 5 felder/5 vorlagen, habe %+v", res)
}
src := newSyntheticSource()
for _, f := range src.fields {
fieldID := deriveUUID("metadatafield", "acme-test", f.SourceFieldID)
var label, fieldType, defaultValue *string
var required bool
var ruleJSON []byte
if err := pool.QueryRow(ctx, `
SELECT label, field_type, required, default_value, validation_rule FROM metadata_fields WHERE id = $1
`, fieldID).Scan(&label, &fieldType, &required, &defaultValue, &ruleJSON); err != nil {
t.Fatalf("feld %q lesen: %v", f.SourceFieldID, err)
}
if label == nil || *label != f.Label || fieldType == nil || *fieldType != f.Type || required != f.Required {
t.Fatalf("feld %q: definition weicht von quelle ab (label=%v typ=%v pflicht=%v)", f.SourceFieldID, label, fieldType, required)
}
}
for _, tpl := range src.templates {
templateID := deriveUUID("documenttype", "acme-test", tpl.SourceTemplateID)
var name string
var retentionClass *string
if err := pool.QueryRow(ctx, `
SELECT name, retention_class FROM document_types WHERE id = $1
`, templateID).Scan(&name, &retentionClass); err != nil {
t.Fatalf("vorlage %q lesen: %v", tpl.SourceTemplateID, err)
}
if name != tpl.Name {
t.Fatalf("vorlage %q: name weicht ab, erwartet %q habe %q", tpl.SourceTemplateID, tpl.Name, name)
}
if (retentionClass == nil) != (tpl.RetentionClass == nil) {
t.Fatalf("vorlage %q: aufbewahrungsklasse weicht ab", tpl.SourceTemplateID)
}
var boundFieldCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM document_type_fields WHERE document_type_id = $1`, templateID).Scan(&boundFieldCount); err != nil {
t.Fatalf("gebundene felder fuer vorlage %q zaehlen: %v", tpl.SourceTemplateID, err)
}
if boundFieldCount != len(tpl.SourceFieldIDs) {
t.Fatalf("vorlage %q: erwartet %d gebundene felder, habe %d", tpl.SourceTemplateID, len(tpl.SourceFieldIDs), boundFieldCount)
}
}
}
// TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten prueft Akzeptanz-
// kriterium 2 (Pruefung 2: Feldwerte einer Stichprobe migrierter Dokumente
// gegen Quelle geprueft): bereits befuellte Feldwerte an migrierten
// Dokumenten bleiben erhalten.
func TestMigrateAll_AkzeptanzkriteriumFeldwerteErhalten(t *testing.T) {
migrator, pool := setupMigratorTest(t)
ctx := context.Background()
res, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("MigrateAll: %v", err)
}
if res.FieldValuesMigrated != 3 {
t.Fatalf("erwartet 3 feldwerte, habe %+v", res)
}
for _, v := range newSyntheticSource().values {
documentID := deriveUUID("document", "acme-test", v.SourceDocumentID)
fieldID := deriveUUID("metadatafield", "acme-test", v.SourceFieldID)
var value string
if err := pool.QueryRow(ctx, `
SELECT value FROM document_metadata_values WHERE document_id = $1 AND field_id = $2
`, documentID, fieldID).Scan(&value); err != nil {
t.Fatalf("feldwert fuer dokument %q/feld %q lesen: %v", v.SourceDocumentID, v.SourceFieldID, err)
}
if value != v.Value {
t.Fatalf("feldwert weicht von quelle ab: erwartet %q habe %q", v.Value, value)
}
}
}
// TestMigrateAll_AkzeptanzkriteriumWiederholbar prueft, dass ein erneuter
// Lauf gegen dieselbe Quelle keine Duplikate erzeugt (gleiches Idempotenz-
// Muster wie DOK-01).
func TestMigrateAll_AkzeptanzkriteriumWiederholbar(t *testing.T) {
migrator, pool := setupMigratorTest(t)
ctx := context.Background()
if _, err := migrator.MigrateAll(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
res2, err := migrator.MigrateAll(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if res2.CustomFieldsMigrated != 0 || res2.TemplatesMigrated != 0 || res2.FieldValuesMigrated != 0 {
t.Fatalf("zweiter lauf haette nichts neu migrieren duerfen, habe %+v", res2)
}
var fieldCount, templateCount, valueCount int
_ = pool.QueryRow(ctx, `SELECT count(*) FROM metadata_fields`).Scan(&fieldCount)
_ = pool.QueryRow(ctx, `SELECT count(*) FROM document_types`).Scan(&templateCount)
_ = pool.QueryRow(ctx, `SELECT count(*) FROM document_metadata_values`).Scan(&valueCount)
if fieldCount != 5 || templateCount != 5 || valueCount != 3 {
t.Fatalf("erneuter lauf hat duplikate erzeugt: %d felder/%d vorlagen/%d werte", fieldCount, templateCount, valueCount)
}
}
+25
View File
@@ -0,0 +1,25 @@
package migrationmetadata
import (
"crypto/sha256"
"fmt"
)
// deriveUUID ist identisch zu migrationdocument.deriveUUID (bewusst dupliziert,
// nicht importiert): Namespace "document" muss hier dieselben Ziel-IDs treffen
// wie DOK-01, damit Feldwerte an den DORT bereits migrierten Dokumenten
// verankert werden koennen, ohne eine zusaetzliche Mapping-Tabelle.
func deriveUUID(namespace string, parts ...string) string {
h := sha256.New()
h.Write([]byte(namespace))
for _, p := range parts {
h.Write([]byte{0})
h.Write([]byte(p))
}
sum := h.Sum(nil)
sum[6] = (sum[6] & 0x0f) | 0x50
sum[8] = (sum[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
}