diff --git a/models/common.go b/models/common.go new file mode 100644 index 0000000..ebffcd1 --- /dev/null +++ b/models/common.go @@ -0,0 +1,6 @@ +package models + +var ( + // Установка схемы (для баз, где они есть) + SchemeName = "" +) diff --git a/models/document.go b/models/document.go new file mode 100644 index 0000000..ee8ba80 --- /dev/null +++ b/models/document.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Document — логическая сущность (например, пользователь, настройки и т.п.) +type Document struct { + ID uuid.UUID `gorm:"type:char(36);primaryKey"` + Name string + CreatedAt time.Time + UpdatedAt time.Time + LatestVersion uuid.UUID `gorm:"type:char(36);"` +} + +func (s *Document) TableName() (res string) { + if SchemeName != "" { + res = SchemeName + ".document" + } else { + res = "document" + } + return +} diff --git a/models/models.go b/models/version.go similarity index 62% rename from models/models.go rename to models/version.go index 8223310..676faf9 100644 --- a/models/models.go +++ b/models/version.go @@ -7,15 +7,6 @@ import ( "github.com/google/uuid" ) -// Document — логическая сущность (например, пользователь, настройки и т.п.) -type Document struct { - ID uuid.UUID `gorm:"type:char(36);primaryKey"` - Name string - CreatedAt time.Time - UpdatedAt time.Time - LatestVersion uuid.UUID `gorm:"type:char(36);"` -} - // Version — конкретная версия документа type Version struct { ID uuid.UUID `gorm:"type:char(36);primaryKey"` @@ -26,3 +17,12 @@ type Version struct { Snapshot json.RawMessage `gorm:"type:json"` // Полный JSON, если IsSnapshot = true CreatedAt time.Time } + +func (s *Version) TableName() (res string) { + if SchemeName != "" { + res = SchemeName + ".version" + } else { + res = "version" + } + return +} diff --git a/service/version.go b/service/version.go index 37c5bc6..d0f16d5 100644 --- a/service/version.go +++ b/service/version.go @@ -1,7 +1,6 @@ package service import ( - "errors" "fmt" "time" @@ -9,7 +8,6 @@ import ( "git.gm6.ru/icewind/seadoc/store" jsonpatch "github.com/evanphx/json-patch/v5" "github.com/google/uuid" - "gorm.io/gorm" ) // VersionService — слой логики версионирования, использующий интерфейс VersionStorage. @@ -20,8 +18,8 @@ type VersionService struct { } // NewVersionService создаёт новый сервис -func NewVersionService(storage store.IVersionStorage) *VersionService { - return &VersionService{storage: storage, snapshotInterval: 0} +func NewVersionService(storage store.IVersionStorage, snapshotInterval int) *VersionService { + return &VersionService{storage: storage, snapshotInterval: snapshotInterval} } // SetSnapshotInterval задаёт через сколько версий сохранять snapshot (если >0) @@ -29,111 +27,94 @@ func (s *VersionService) SetSnapshotInterval(n int) { s.snapshotInterval = n } +func (s *VersionService) CreateDocument(name string) (*models.Document, error) { + doc := models.Document{ + ID: uuid.New(), + Name: name, + } + if err := s.storage.CreateDocument(&doc); err != nil { + return nil, err + } + return &doc, nil +} + // SaveNewVersion создаёт новую версию документа (merge-patch или snapshot) func (s *VersionService) SaveNewVersion(docID uuid.UUID, newJSON []byte) (*models.Version, error) { lastVer, err := s.storage.GetLatestVersion(docID) - - var patchBytes []byte - isSnapshot := false - var parentID *uuid.UUID - if err != nil { - // первая версия — snapshot - if errors.Is(err, gorm.ErrRecordNotFound) { - isSnapshot = true - parentID = nil - } else { + return nil, err + } + + // Если нет предыдущих версий — первая версия всегда snapshot + if lastVer == nil { + newVer := models.Version{ + ID: uuid.New(), + DocumentID: docID, + ParentID: nil, + IsSnapshot: true, + Snapshot: newJSON, + CreatedAt: time.Now(), + } + if err := s.storage.CreateVersion(&newVer); err != nil { return nil, err } - } else { - // есть предыдущая версия — создаём merge-patch - parentID = &lastVer.ID + _ = s.storage.UpdateLatestVersion(docID, newVer.ID) + return &newVer, nil + } - var oldJSON []byte - if lastVer.IsSnapshot { - oldJSON = lastVer.Snapshot - } else { - oldJSON, err = s.ReconstructVersion(lastVer.ID) - if err != nil { + // Если включён интервал снапшота — проверяем глубину до создания патча + if s.snapshotInterval > 0 { + depth, err := s.storage.CountSinceLastSnapshot(docID) + if err != nil { + return nil, err + } + // если глубина достигла порога, сохраняем snapshot ВМЕСТО патча + if depth >= s.snapshotInterval { + snapVer := models.Version{ + ID: uuid.New(), + DocumentID: docID, + ParentID: &lastVer.ID, + IsSnapshot: true, + Snapshot: newJSON, + CreatedAt: time.Now(), + } + if err := s.storage.CreateVersion(&snapVer); err != nil { return nil, err } + _ = s.storage.UpdateLatestVersion(docID, snapVer.ID) + return &snapVer, nil } + } - // Создаём JSON Merge Patch (RFC 7386) - // CreateMergePatch возвращает []byte с содержимым merge-patch - patchBytes, err = jsonpatch.CreateMergePatch(oldJSON, newJSON) + // Иначе: создаём merge-patch относительно старой версии + var old []byte + if lastVer.IsSnapshot { + old = lastVer.Snapshot + } else { + old, err = s.ReconstructVersion(lastVer.ID) if err != nil { - return nil, fmt.Errorf("create merge patch: %w", err) + return nil, err } + } - // Если patch пустой (документы равны), можно не сохранять новую версию - // В данном примере создаём версию даже если patch пустой — можно изменить поведение + patch, err := jsonpatch.CreateMergePatch(old, newJSON) + if err != nil { + return nil, err } newVer := models.Version{ ID: uuid.New(), DocumentID: docID, - ParentID: parentID, - IsSnapshot: isSnapshot, - Patch: patchBytes, + ParentID: &lastVer.ID, + IsSnapshot: false, + Patch: patch, CreatedAt: time.Now(), } - if isSnapshot { - newVer.Snapshot = newJSON - } - - // Сохраняем версию в хранилище if err := s.storage.CreateVersion(&newVer); err != nil { return nil, err } - - // Обновляем ссылку на последнюю версию документа - if err := s.storage.UpdateLatestVersion(docID, newVer.ID); err != nil { - return nil, err - } - - // Опционально: если указан snapshotInterval, проверяем нужно ли создать snapshot - if s.snapshotInterval > 0 && !isSnapshot { - // считаем количество версий от этого snapshot'а до новой версии - // простая реализация: пройти вверх по родителям и посчитать - count := 0 - curr := &newVer - for { - count++ - if curr.ParentID == nil { - break - } - parent, err := s.storage.GetParentVersion(curr) - if err != nil { - break - } - if parent == nil { - break - } - curr = parent - if curr.IsSnapshot { - break - } - } - if count >= s.snapshotInterval { - // делаем snapshot: восстанавливаем полную версию и сохраняем snapshot-версию - full, err := s.ReconstructVersion(newVer.ID) - if err == nil { - snapVer := models.Version{ - ID: uuid.New(), - DocumentID: docID, - ParentID: &newVer.ID, - IsSnapshot: true, - Snapshot: full, - CreatedAt: time.Now(), - } - // сохраняем snapshot-версию и обновляем latest_version - _ = s.storage.CreateVersion(&snapVer) - _ = s.storage.UpdateLatestVersion(docID, snapVer.ID) - } - } - } + _ = s.storage.UpdateLatestVersion(docID, newVer.ID) return &newVer, nil } diff --git a/store/gorm.go b/store/gorm.go index ed996ce..bf9c67a 100644 --- a/store/gorm.go +++ b/store/gorm.go @@ -15,15 +15,9 @@ func NewGormStorage(db *gorm.DB) *GormStorage { return &GormStorage{db: db} } -func (s *GormStorage) CreateDocument(name string) (uuid.UUID, error) { - doc := models.Document{ - ID: uuid.New(), - Name: name, - } - if err := s.db.Create(&doc).Error; err != nil { - return uuid.Nil, err - } - return doc.ID, nil +func (s *GormStorage) CreateDocument(doc *models.Document) error { + err := s.db.Create(&doc).Error + return err } func (s *GormStorage) GetDocument(id uuid.UUID) (*models.Document, error) { @@ -46,13 +40,14 @@ func (s *GormStorage) CreateVersion(v *models.Version) error { func (s *GormStorage) GetLatestVersion(docID uuid.UUID) (*models.Version, error) { var v models.Version - if err := s.db. + err := s.db. Where("document_id = ?", docID). Order("created_at desc"). - First(&v).Error; err != nil { - return nil, err + First(&v).Error + if err == gorm.ErrRecordNotFound { + return nil, nil } - return &v, nil + return &v, err } func (s *GormStorage) GetVersionByID(id uuid.UUID) (*models.Version, error) { @@ -73,3 +68,38 @@ func (s *GormStorage) GetParentVersion(v *models.Version) (*models.Version, erro } return &parent, nil } + +// CountSinceLastSnapshot возвращает количество версий (включая текущую) +// с момента последнего snapshot +func (s *GormStorage) CountSinceLastSnapshot(docID uuid.UUID) (int, error) { + // Получаем последнюю версию + v, err := s.GetLatestVersion(docID) + if err != nil { + return 0, err + } + if v == nil { + return 0, nil + } + + count := 0 + for v != nil { + count++ + + // Если это снапшот, считать больше не нужно + if v.IsSnapshot { + break + } + + // Переходим к родителю + if v.ParentID == nil { + break + } + + v, err = s.GetVersionByID(*v.ParentID) + if err != nil { + return count, err + } + } + + return count, nil +} diff --git a/store/store.go b/store/store.go index 42628d5..d378142 100644 --- a/store/store.go +++ b/store/store.go @@ -7,9 +7,10 @@ import ( type IVersionStorage interface { // Document - CreateDocument(name string) (uuid.UUID, error) + CreateDocument(doc *models.Document) error GetDocument(id uuid.UUID) (*models.Document, error) UpdateLatestVersion(docID, versionID uuid.UUID) error + CountSinceLastSnapshot(docID uuid.UUID) (int, error) // Version CreateVersion(v *models.Version) error diff --git a/tests/service_test.go b/tests/service_test.go index 504ffe0..95c5c26 100644 --- a/tests/service_test.go +++ b/tests/service_test.go @@ -23,15 +23,15 @@ func newTestService(t *testing.T) (*gorm.DB, *service.VersionService, *store.Gor } storage := store.NewGormStorage(db) - service := service.NewVersionService(storage) + service := service.NewVersionService(storage, 5) return db, service, storage } func TestVersioningLifecycle(t *testing.T) { - db, service, store := newTestService(t) + db, service, _ := newTestService(t) // 1️⃣ Создаём документ - docID, err := store.CreateDocument("test.json") + doc, err := service.CreateDocument("test.json") if err != nil { t.Fatalf("create document: %v", err) } @@ -42,7 +42,7 @@ func TestVersioningLifecycle(t *testing.T) { v3 := []byte(`{"name": "Alice", "age": 32, "city": "Paris", "lang": "fr"}`) // 3️⃣ Сохраняем версии - ver1, err := service.SaveNewVersion(docID, v1) + ver1, err := service.SaveNewVersion(doc.ID, v1) if err != nil { t.Fatalf("save v1: %v", err) } @@ -50,7 +50,7 @@ func TestVersioningLifecycle(t *testing.T) { t.Errorf("v1 должен быть snapshot, но IsSnapshot=%v", ver1.IsSnapshot) } - ver2, err := service.SaveNewVersion(docID, v2) + ver2, err := service.SaveNewVersion(doc.ID, v2) if err != nil { t.Fatalf("save v2: %v", err) } @@ -58,7 +58,7 @@ func TestVersioningLifecycle(t *testing.T) { t.Errorf("v2 не должен быть snapshot") } - ver3, err := service.SaveNewVersion(docID, v3) + ver3, err := service.SaveNewVersion(doc.ID, v3) if err != nil { t.Fatalf("save v3: %v", err) } @@ -102,15 +102,21 @@ func jsonEqual(a, b []byte) bool { } func TestDiffVersions(t *testing.T) { - _, service, store := newTestService(t) + _, service, _ := newTestService(t) - docID, _ := store.CreateDocument("diff.json") + doc, _ := service.CreateDocument("diff.json") v1 := []byte(`{"name":"Alice","age":30}`) v2 := []byte(`{"name":"Alice","age":31,"city":"Paris"}`) - ver1, _ := service.SaveNewVersion(docID, v1) - ver2, _ := service.SaveNewVersion(docID, v2) + ver1, err := service.SaveNewVersion(doc.ID, v1) + if err != nil { + t.Fatal(err) + } + ver2, err := service.SaveNewVersion(doc.ID, v2) + if err != nil { + t.Fatal(err) + } diff, err := service.DiffVersions(ver1.ID, ver2.ID) if err != nil { diff --git a/tests/snapshot_test.go b/tests/snapshot_test.go new file mode 100644 index 0000000..74e5303 --- /dev/null +++ b/tests/snapshot_test.go @@ -0,0 +1,103 @@ +package tests + +import ( + "testing" + + "git.gm6.ru/icewind/seadoc/models" + "git.gm6.ru/icewind/seadoc/service" + "git.gm6.ru/icewind/seadoc/store" + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSnapshotInterval(t *testing.T) { + // --- Setup in-memory DB --- + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + + if err := db.AutoMigrate(&models.Document{}, &models.Version{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + storage := store.NewGormStorage(db) + + // --- Service with snapshot interval = 3 --- + svc := service.NewVersionService(storage, 3) + + //doc := models.Document{ID: uuid.New()} + var doc *models.Document + if doc, err = svc.CreateDocument("test"); err != nil { + t.Fatalf("failed to create document: %v", err) + } + + // v1 — snapshot + _, err = svc.SaveNewVersion(doc.ID, []byte(`{"count": 1}`)) + if err != nil { + t.Fatalf("v1 failed: %v", err) + } + + // v2, v3, v4 — patch-версии + _, _ = svc.SaveNewVersion(doc.ID, []byte(`{"count": 2}`)) + _, _ = svc.SaveNewVersion(doc.ID, []byte(`{"count": 3}`)) + _, _ = svc.SaveNewVersion(doc.ID, []byte(`{"count": 4}`)) + + // теперь должен создаться **автоматический snapshot** + latest, err := storage.GetLatestVersion(doc.ID) + if err != nil { + t.Fatalf("failed to get latest version: %v", err) + } + + if !latest.IsSnapshot { + t.Fatalf("expected latest version to be snapshot, got patch") + } + + // восстановим документ — должен быть {"count":4} + full, err := svc.ReconstructVersion(latest.ID) + if err != nil { + t.Fatalf("failed to reconstruct version: %v", err) + } + + if string(full) != `{"count": 4}` { + t.Fatalf("snapshot contains wrong data: %s", full) + } +} + +func TestDiffVersions_Multiple(t *testing.T) { + _, service, _ := newTestService(t) + + doc, err := service.CreateDocument("multi-diff.json") + if err != nil { + t.Fatal(err) + } + + versions := [][]byte{ + []byte(`{"name":"Alice","age":30}`), + []byte(`{"name":"Alice","age":31}`), + []byte(`{"name":"Alice","age":31,"city":"Paris"}`), + []byte(`{"name":"Alice","age":31,"city":"Paris","active":true}`), + } + + stored := make([]uuid.UUID, 0, len(versions)) + + for _, v := range versions { + ver, err := service.SaveNewVersion(doc.ID, v) + if err != nil { + t.Fatal(err) + } + stored = append(stored, ver.ID) + } + + // сравним самую первую и последнюю версии + diff, err := service.DiffVersions(stored[0], stored[len(stored)-1]) + if err != nil { + t.Fatalf("diff error: %v", err) + } + + expected := `{"age":31,"city":"Paris","active":true}` + if !jsonEqual(diff, []byte(expected)) { + t.Errorf("unexpected diff:\n got: %s\nwant: %s", diff, expected) + } +}