upd store
This commit is contained in:
6
models/common.go
Normal file
6
models/common.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
var (
|
||||
// Установка схемы (для баз, где они есть)
|
||||
SchemeName = ""
|
||||
)
|
||||
25
models/document.go
Normal file
25
models/document.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
103
tests/snapshot_test.go
Normal file
103
tests/snapshot_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user