aboutsummaryrefslogtreecommitdiffstats
path: root/entity
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2022-07-25 13:16:16 +0200
committerMichael Muré <batolettre@gmail.com>2022-07-25 13:27:17 +0200
commit3d454d9dc8ba2409046c0938618a70864e6eb8ef (patch)
tree8745f656cc8218654632ce003f997a39988d3043 /entity
parent2ade8fb1d570ddcb4aedc9386af46d208b129daa (diff)
downloadgit-bug-3d454d9dc8ba2409046c0938618a70864e6eb8ef.tar.gz
entity/dag: proper base operation for simplified implementation
- reduce boilerplace necessary to implement an operation - consolidate what an operation is in the core, which in turn pave the way for a generic cache layer mechanism - avoid the previously complex unmarshalling process - support operation metadata from the core - simplified testing
Diffstat (limited to 'entity')
-rw-r--r--entity/dag/common_test.go77
-rw-r--r--entity/dag/entity.go2
-rw-r--r--entity/dag/entity_test.go2
-rw-r--r--entity/dag/example_test.go142
-rw-r--r--entity/dag/op_noop.go39
-rw-r--r--entity/dag/op_noop_test.go13
-rw-r--r--entity/dag/op_set_metadata.go68
-rw-r--r--entity/dag/op_set_metadata_test.go106
-rw-r--r--entity/dag/operation.go232
-rw-r--r--entity/dag/operation_pack.go7
-rw-r--r--entity/dag/operation_pack_test.go62
-rw-r--r--entity/dag/operation_testing.go57
12 files changed, 627 insertions, 180 deletions
diff --git a/entity/dag/common_test.go b/entity/dag/common_test.go
index c2177683..774acba8 100644
--- a/entity/dag/common_test.go
+++ b/entity/dag/common_test.go
@@ -18,78 +18,73 @@ import (
Operations
*/
-type op1 struct {
- author identity.Interface
+const (
+ _ OperationType = iota
+ Op1
+ Op2
+)
- OperationType int `json:"type"`
- Field1 string `json:"field_1"`
- Files []repository.Hash `json:"files"`
+type op1 struct {
+ OpBase
+ Field1 string `json:"field_1"`
+ Files []repository.Hash `json:"files"`
}
func newOp1(author identity.Interface, field1 string, files ...repository.Hash) *op1 {
- return &op1{author: author, OperationType: 1, Field1: field1, Files: files}
+ return &op1{OpBase: NewOpBase(Op1, author, 0), Field1: field1, Files: files}
}
-func (o *op1) Id() entity.Id {
- data, _ := json.Marshal(o)
- return entity.DeriveId(data)
+func (op *op1) Id() entity.Id {
+ return IdOperation(op, &op.OpBase)
}
-func (o *op1) Validate() error { return nil }
+func (op *op1) Validate() error { return nil }
-func (o *op1) Author() identity.Interface {
- return o.author
-}
-
-func (o *op1) GetFiles() []repository.Hash {
- return o.Files
+func (op *op1) GetFiles() []repository.Hash {
+ return op.Files
}
type op2 struct {
- author identity.Interface
-
- OperationType int `json:"type"`
- Field2 string `json:"field_2"`
+ OpBase
+ Field2 string `json:"field_2"`
}
func newOp2(author identity.Interface, field2 string) *op2 {
- return &op2{author: author, OperationType: 2, Field2: field2}
+ return &op2{OpBase: NewOpBase(Op2, author, 0), Field2: field2}
}
-func (o *op2) Id() entity.Id {
- data, _ := json.Marshal(o)
- return entity.DeriveId(data)
+func (op *op2) Id() entity.Id {
+ return IdOperation(op, &op.OpBase)
}
-func (o *op2) Validate() error { return nil }
-
-func (o *op2) Author() identity.Interface {
- return o.author
-}
+func (op *op2) Validate() error { return nil }
-func unmarshaler(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error) {
+func unmarshaler(raw json.RawMessage, resolver identity.Resolver) (Operation, error) {
var t struct {
- OperationType int `json:"type"`
+ OperationType OperationType `json:"type"`
}
if err := json.Unmarshal(raw, &t); err != nil {
return nil, err
}
+ var op Operation
+
switch t.OperationType {
- case 1:
- op := &op1{}
- err := json.Unmarshal(raw, &op)
- op.author = author
- return op, err
- case 2:
- op := &op2{}
- err := json.Unmarshal(raw, &op)
- op.author = author
- return op, err
+ case Op1:
+ op = &op1{}
+ case Op2:
+ op = &op2{}
default:
return nil, fmt.Errorf("unknown operation type %v", t.OperationType)
}
+
+ err := json.Unmarshal(raw, &op)
+ if err != nil {
+ return nil, err
+ }
+
+ return op, nil
}
/*
diff --git a/entity/dag/entity.go b/entity/dag/entity.go
index f3229b7e..4ccf0e0e 100644
--- a/entity/dag/entity.go
+++ b/entity/dag/entity.go
@@ -26,7 +26,7 @@ type Definition struct {
// the Namespace in git references (bugs, prs, ...)
Namespace string
// a function decoding a JSON message into an Operation
- OperationUnmarshaler func(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (Operation, error)
+ OperationUnmarshaler func(raw json.RawMessage, resolver identity.Resolver) (Operation, error)
// the expected format version number, that can be used for data migration/upgrade
FormatVersion uint
}
diff --git a/entity/dag/entity_test.go b/entity/dag/entity_test.go
index 6d621bbe..e399b6c7 100644
--- a/entity/dag/entity_test.go
+++ b/entity/dag/entity_test.go
@@ -50,6 +50,8 @@ func TestWriteReadMultipleAuthor(t *testing.T) {
}
func assertEqualEntities(t *testing.T, a, b *Entity) {
+ t.Helper()
+
// testify doesn't support comparing functions and systematically fail if they are not nil
// so we have to set them to nil temporarily
diff --git a/entity/dag/example_test.go b/entity/dag/example_test.go
index 5d4ea34d..94850bd9 100644
--- a/entity/dag/example_test.go
+++ b/entity/dag/example_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
+ "time"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/entity/dag"
@@ -64,10 +65,8 @@ type Operation interface {
Apply(snapshot *Snapshot)
}
-type OperationType int
-
const (
- _ OperationType = iota
+ _ dag.OperationType = iota
SetSignatureRequiredOp
AddAdministratorOp
RemoveAdministratorOp
@@ -75,37 +74,30 @@ const (
// SetSignatureRequired is an operation to set/unset if git signature are required.
type SetSignatureRequired struct {
- author identity.Interface
- OperationType OperationType `json:"type"`
- Value bool `json:"value"`
+ dag.OpBase
+ Value bool `json:"value"`
}
func NewSetSignatureRequired(author identity.Interface, value bool) *SetSignatureRequired {
- return &SetSignatureRequired{author: author, OperationType: SetSignatureRequiredOp, Value: value}
+ return &SetSignatureRequired{
+ OpBase: dag.NewOpBase(SetSignatureRequiredOp, author, time.Now().Unix()),
+ Value: value,
+ }
}
func (ssr *SetSignatureRequired) Id() entity.Id {
// the Id of the operation is the hash of the serialized data.
- // we could memorize the Id when deserializing, but that will do
- data, _ := json.Marshal(ssr)
- return entity.DeriveId(data)
+ return dag.IdOperation(ssr, &ssr.OpBase)
}
func (ssr *SetSignatureRequired) Validate() error {
- if ssr.author == nil {
- return fmt.Errorf("author not set")
- }
- return ssr.author.Validate()
-}
-
-func (ssr *SetSignatureRequired) Author() identity.Interface {
- return ssr.author
+ return ssr.OpBase.Validate(ssr, SetSignatureRequiredOp)
}
// Apply is the function that makes changes on the snapshot
func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) {
// check that we are allowed to change the config
- if _, ok := snapshot.Administrator[ssr.author]; !ok {
+ if _, ok := snapshot.Administrator[ssr.Author()]; !ok {
return
}
snapshot.SignatureRequired = ssr.Value
@@ -113,24 +105,20 @@ func (ssr *SetSignatureRequired) Apply(snapshot *Snapshot) {
// AddAdministrator is an operation to add a new administrator in the set
type AddAdministrator struct {
- author identity.Interface
- OperationType OperationType `json:"type"`
- ToAdd []identity.Interface `json:"to_add"`
-}
-
-// addAdministratorJson is a helper struct to deserialize identities with a concrete type.
-type addAdministratorJson struct {
- ToAdd []identity.IdentityStub `json:"to_add"`
+ dag.OpBase
+ ToAdd []identity.Interface `json:"to_add"`
}
func NewAddAdministratorOp(author identity.Interface, toAdd ...identity.Interface) *AddAdministrator {
- return &AddAdministrator{author: author, OperationType: AddAdministratorOp, ToAdd: toAdd}
+ return &AddAdministrator{
+ OpBase: dag.NewOpBase(AddAdministratorOp, author, time.Now().Unix()),
+ ToAdd: toAdd,
+ }
}
func (aa *AddAdministrator) Id() entity.Id {
- // we could memorize the Id when deserializing, but that will do
- data, _ := json.Marshal(aa)
- return entity.DeriveId(data)
+ // the Id of the operation is the hash of the serialized data.
+ return dag.IdOperation(aa, &aa.OpBase)
}
func (aa *AddAdministrator) Validate() error {
@@ -138,20 +126,13 @@ func (aa *AddAdministrator) Validate() error {
if len(aa.ToAdd) == 0 {
return fmt.Errorf("nothing to add")
}
- if aa.author == nil {
- return fmt.Errorf("author not set")
- }
- return aa.author.Validate()
-}
-
-func (aa *AddAdministrator) Author() identity.Interface {
- return aa.author
+ return aa.OpBase.Validate(aa, AddAdministratorOp)
}
// Apply is the function that makes changes on the snapshot
func (aa *AddAdministrator) Apply(snapshot *Snapshot) {
// check that we are allowed to change the config ... or if there is no admin yet
- if !snapshot.HasAdministrator(aa.author) && len(snapshot.Administrator) != 0 {
+ if !snapshot.HasAdministrator(aa.Author()) && len(snapshot.Administrator) != 0 {
return
}
for _, toAdd := range aa.ToAdd {
@@ -161,25 +142,20 @@ func (aa *AddAdministrator) Apply(snapshot *Snapshot) {
// RemoveAdministrator is an operation to remove an administrator from the set
type RemoveAdministrator struct {
- author identity.Interface
- OperationType OperationType `json:"type"`
- ToRemove []identity.Interface `json:"to_remove"`
-}
-
-// removeAdministratorJson is a helper struct to deserialize identities with a concrete type.
-type removeAdministratorJson struct {
+ dag.OpBase
ToRemove []identity.Interface `json:"to_remove"`
}
func NewRemoveAdministratorOp(author identity.Interface, toRemove ...identity.Interface) *RemoveAdministrator {
- return &RemoveAdministrator{author: author, OperationType: RemoveAdministratorOp, ToRemove: toRemove}
+ return &RemoveAdministrator{
+ OpBase: dag.NewOpBase(RemoveAdministratorOp, author, time.Now().Unix()),
+ ToRemove: toRemove,
+ }
}
func (ra *RemoveAdministrator) Id() entity.Id {
// the Id of the operation is the hash of the serialized data.
- // we could memorize the Id when deserializing, but that will do
- data, _ := json.Marshal(ra)
- return entity.DeriveId(data)
+ return dag.IdOperation(ra, &ra.OpBase)
}
func (ra *RemoveAdministrator) Validate() error {
@@ -188,26 +164,19 @@ func (ra *RemoveAdministrator) Validate() error {
if len(ra.ToRemove) == 0 {
return fmt.Errorf("nothing to remove")
}
- if ra.author == nil {
- return fmt.Errorf("author not set")
- }
- return ra.author.Validate()
-}
-
-func (ra *RemoveAdministrator) Author() identity.Interface {
- return ra.author
+ return ra.OpBase.Validate(ra, RemoveAdministratorOp)
}
// Apply is the function that makes changes on the snapshot
func (ra *RemoveAdministrator) Apply(snapshot *Snapshot) {
// check if we are allowed to make changes
- if !snapshot.HasAdministrator(ra.author) {
+ if !snapshot.HasAdministrator(ra.Author()) {
return
}
// special rule: we can't end up with no administrator
stillSome := false
for admin, _ := range snapshot.Administrator {
- if admin != ra.author {
+ if admin != ra.Author() {
stillSome = true
break
}
@@ -245,71 +214,52 @@ var def = dag.Definition{
// operationUnmarshaller is a function doing the de-serialization of the JSON data into our own
// concrete Operations. If needed, we can use the resolver to connect to other entities.
-func operationUnmarshaller(author identity.Interface, raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
+func operationUnmarshaller(raw json.RawMessage, resolver identity.Resolver) (dag.Operation, error) {
var t struct {
- OperationType OperationType `json:"type"`
+ OperationType dag.OperationType `json:"type"`
}
if err := json.Unmarshal(raw, &t); err != nil {
return nil, err
}
- var value interface{}
+ var op dag.Operation
switch t.OperationType {
case AddAdministratorOp:
- value = &addAdministratorJson{}
+ op = &AddAdministrator{}
case RemoveAdministratorOp:
- value = &removeAdministratorJson{}
+ op = &RemoveAdministrator{}
case SetSignatureRequiredOp:
- value = &SetSignatureRequired{}
+ op = &SetSignatureRequired{}
default:
panic(fmt.Sprintf("unknown operation type %v", t.OperationType))
}
- err := json.Unmarshal(raw, &value)
+ err := json.Unmarshal(raw, &op)
if err != nil {
return nil, err
}
- var op Operation
-
- switch value := value.(type) {
- case *SetSignatureRequired:
- value.author = author
- op = value
- case *addAdministratorJson:
- // We need something less straightforward to deserialize and resolve identities
- aa := &AddAdministrator{
- author: author,
- OperationType: AddAdministratorOp,
- ToAdd: make([]identity.Interface, len(value.ToAdd)),
- }
- for i, stub := range value.ToAdd {
+ switch op := op.(type) {
+ case *AddAdministrator:
+ // We need to resolve identities
+ for i, stub := range op.ToAdd {
iden, err := resolver.ResolveIdentity(stub.Id())
if err != nil {
return nil, err
}
- aa.ToAdd[i] = iden
+ op.ToAdd[i] = iden
}
- op = aa
- case *removeAdministratorJson:
- // We need something less straightforward to deserialize and resolve identities
- ra := &RemoveAdministrator{
- author: author,
- OperationType: RemoveAdministratorOp,
- ToRemove: make([]identity.Interface, len(value.ToRemove)),
- }
- for i, stub := range value.ToRemove {
+ case *RemoveAdministrator:
+ // We need to resolve identities
+ for i, stub := range op.ToRemove {
iden, err := resolver.ResolveIdentity(stub.Id())
if err != nil {
return nil, err
}
- ra.ToRemove[i] = iden
+ op.ToRemove[i] = iden
}
- op = ra
- default:
- panic(fmt.Sprintf("unknown operation type %T", value))
}
return op, nil
diff --git a/entity/dag/op_noop.go b/entity/dag/op_noop.go
new file mode 100644
index 00000000..2bc53578
--- /dev/null
+++ b/entity/dag/op_noop.go
@@ -0,0 +1,39 @@
+package dag
+
+import (
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/identity"
+)
+
+var _ Operation = &NoOpOperation[Snapshot]{}
+var _ OperationDoesntChangeSnapshot = &NoOpOperation[Snapshot]{}
+
+// NoOpOperation is an operation that does not change the entity state. It can
+// however be used to store arbitrary metadata in the entity history, for example
+// to support a bridge feature.
+type NoOpOperation[SnapT Snapshot] struct {
+ OpBase
+}
+
+func NewNoOpOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64) *NoOpOperation[SnapT] {
+ return &NoOpOperation[SnapT]{
+ OpBase: NewOpBase(opType, author, unixTime),
+ }
+}
+
+func (op *NoOpOperation[SnapT]) Id() entity.Id {
+ return IdOperation(op, &op.OpBase)
+}
+
+func (op *NoOpOperation[SnapT]) Apply(snapshot SnapT) {
+ // Nothing to do
+}
+
+func (op *NoOpOperation[SnapT]) Validate() error {
+ if err := op.OpBase.Validate(op, op.OperationType); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (op *NoOpOperation[SnapT]) DoesntChangeSnapshot() {}
diff --git a/entity/dag/op_noop_test.go b/entity/dag/op_noop_test.go
new file mode 100644
index 00000000..e2f5cde0
--- /dev/null
+++ b/entity/dag/op_noop_test.go
@@ -0,0 +1,13 @@
+package dag
+
+import (
+ "testing"
+
+ "github.com/MichaelMure/git-bug/identity"
+)
+
+func TestNoopSerialize(t *testing.T) {
+ SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *NoOpOperation[*snapshotMock] {
+ return NewNoOpOp[*snapshotMock](1, author, unixTime)
+ })
+}
diff --git a/entity/dag/op_set_metadata.go b/entity/dag/op_set_metadata.go
new file mode 100644
index 00000000..bf32171f
--- /dev/null
+++ b/entity/dag/op_set_metadata.go
@@ -0,0 +1,68 @@
+package dag
+
+import (
+ "fmt"
+
+ "github.com/pkg/errors"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/identity"
+ "github.com/MichaelMure/git-bug/util/text"
+)
+
+var _ Operation = &SetMetadataOperation[Snapshot]{}
+var _ OperationDoesntChangeSnapshot = &SetMetadataOperation[Snapshot]{}
+
+type SetMetadataOperation[SnapT Snapshot] struct {
+ OpBase
+ Target entity.Id `json:"target"`
+ NewMetadata map[string]string `json:"new_metadata"`
+}
+
+func NewSetMetadataOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation[SnapT] {
+ return &SetMetadataOperation[SnapT]{
+ OpBase: NewOpBase(opType, author, unixTime),
+ Target: target,
+ NewMetadata: newMetadata,
+ }
+}
+
+func (op *SetMetadataOperation[SnapT]) Id() entity.Id {
+ return IdOperation(op, &op.OpBase)
+}
+
+func (op *SetMetadataOperation[SnapT]) Apply(snapshot SnapT) {
+ for _, target := range snapshot.AllOperations() {
+ if target.Id() == op.Target {
+ // Apply the metadata in an immutable way: if a metadata already
+ // exist, it's not possible to override it.
+ for key, value := range op.NewMetadata {
+ target.setExtraMetadataImmutable(key, value)
+ }
+ return
+ }
+ }
+}
+
+func (op *SetMetadataOperation[SnapT]) Validate() error {
+ if err := op.OpBase.Validate(op, op.OperationType); err != nil {
+ return err
+ }
+
+ if err := op.Target.Validate(); err != nil {
+ return errors.Wrap(err, "target invalid")
+ }
+
+ for key, val := range op.NewMetadata {
+ if !text.SafeOneLine(key) {
+ return fmt.Errorf("metadata key is unsafe")
+ }
+ if !text.Safe(val) {
+ return fmt.Errorf("metadata value is not fully printable")
+ }
+ }
+
+ return nil
+}
+
+func (op *SetMetadataOperation[SnapT]) DoesntChangeSnapshot() {}
diff --git a/entity/dag/op_set_metadata_test.go b/entity/dag/op_set_metadata_test.go
new file mode 100644
index 00000000..4dab8a96
--- /dev/null
+++ b/entity/dag/op_set_metadata_test.go
@@ -0,0 +1,106 @@
+package dag
+
+import (
+ "testing"
+ "time"
+
+ "github.com/MichaelMure/git-bug/identity"
+ "github.com/MichaelMure/git-bug/repository"
+
+ "github.com/stretchr/testify/require"
+)
+
+type snapshotMock struct {
+ ops []Operation
+}
+
+func (s *snapshotMock) AllOperations() []Operation {
+ return s.ops
+}
+
+func TestSetMetadata(t *testing.T) {
+ snap := &snapshotMock{}
+
+ repo := repository.NewMockRepo()
+
+ rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ unix := time.Now().Unix()
+
+ target1 := NewNoOpOp[*snapshotMock](1, rene, unix)
+ target1.SetMetadata("key", "value")
+ snap.ops = append(snap.ops, target1)
+
+ target2 := NewNoOpOp[*snapshotMock](1, rene, unix)
+ target2.SetMetadata("key2", "value2")
+ snap.ops = append(snap.ops, target2)
+
+ op1 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{
+ "key": "override",
+ "key2": "value",
+ })
+
+ op1.Apply(snap)
+ snap.ops = append(snap.ops, op1)
+
+ target1Metadata := snap.AllOperations()[0].AllMetadata()
+ require.Len(t, target1Metadata, 2)
+ // original key is not overrided
+ require.Equal(t, target1Metadata["key"], "value")
+ // new key is set
+ require.Equal(t, target1Metadata["key2"], "value")
+
+ target2Metadata := snap.AllOperations()[1].AllMetadata()
+ require.Len(t, target2Metadata, 1)
+ require.Equal(t, target2Metadata["key2"], "value2")
+
+ op2 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target2.Id(), map[string]string{
+ "key2": "value",
+ "key3": "value3",
+ })
+
+ op2.Apply(snap)
+ snap.ops = append(snap.ops, op2)
+
+ target1Metadata = snap.AllOperations()[0].AllMetadata()
+ require.Len(t, target1Metadata, 2)
+ require.Equal(t, target1Metadata["key"], "value")
+ require.Equal(t, target1Metadata["key2"], "value")
+
+ target2Metadata = snap.AllOperations()[1].AllMetadata()
+ require.Len(t, target2Metadata, 2)
+ // original key is not overrided
+ require.Equal(t, target2Metadata["key2"], "value2")
+ // new key is set
+ require.Equal(t, target2Metadata["key3"], "value3")
+
+ op3 := NewSetMetadataOp[*snapshotMock](2, rene, unix, target1.Id(), map[string]string{
+ "key": "override",
+ "key2": "override",
+ })
+
+ op3.Apply(snap)
+ snap.ops = append(snap.ops, op3)
+
+ target1Metadata = snap.AllOperations()[0].AllMetadata()
+ require.Len(t, target1Metadata, 2)
+ // original key is not overrided
+ require.Equal(t, target1Metadata["key"], "value")
+ // previously set key is not overrided
+ require.Equal(t, target1Metadata["key2"], "value")
+
+ target2Metadata = snap.AllOperations()[1].AllMetadata()
+ require.Len(t, target2Metadata, 2)
+ require.Equal(t, target2Metadata["key2"], "value2")
+ require.Equal(t, target2Metadata["key3"], "value3")
+}
+
+func TestSetMetadataSerialize(t *testing.T) {
+ SerializeRoundTripTest(t, func(author identity.Interface, unixTime int64) *SetMetadataOperation[*snapshotMock] {
+ return NewSetMetadataOp[*snapshotMock](1, author, unixTime, "message", map[string]string{
+ "key1": "value1",
+ "key2": "value2",
+ })
+ })
+}
diff --git a/entity/dag/operation.go b/entity/dag/operation.go
index a320859f..0227b3e0 100644
--- a/entity/dag/operation.go
+++ b/entity/dag/operation.go
@@ -1,11 +1,21 @@
package dag
import (
+ "crypto/rand"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/pkg/errors"
+
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/identity"
"github.com/MichaelMure/git-bug/repository"
)
+// OperationType is an operation type identifier
+type OperationType int
+
// Operation is a piece of data defining a change to reflect on the state of an Entity.
// What this Operation or Entity's state looks like is not of the resort of this package as it only deals with the
// data structure and storage.
@@ -22,23 +32,39 @@ type Operation interface {
// a minimal amount of entropy and avoid collision.
//
// Author's note: I tried to find a clever way around that inelegance (stuffing random useless data into the stored
- // structure is not exactly elegant) but I failed to find a proper way. Essentially, anything that would reuse some
+ // structure is not exactly elegant), but I failed to find a proper way. Essentially, anything that would reuse some
// other data (parent operation's Id, lamport clock) or the graph structure (depth) impose that the Id would only
// make sense in the context of the graph and yield some deep coupling between Entity and Operation. This in turn
// make the whole thing even less elegant.
//
// A common way to derive an Id will be to use the entity.DeriveId() function on the serialized operation data.
Id() entity.Id
+ // Type return the type of the operation
+ Type() OperationType
// Validate check if the Operation data is valid
Validate() error
// Author returns the author of this operation
Author() identity.Interface
+ // Time return the time when the operation was added
+ Time() time.Time
+
+ // SetMetadata store arbitrary metadata about the operation
+ SetMetadata(key string, value string)
+ // GetMetadata retrieve arbitrary metadata about the operation
+ GetMetadata(key string) (string, bool)
+ // AllMetadata return all metadata for this operation
+ AllMetadata() map[string]string
+
+ // setId allow to set the Id, used when unmarshalling only
+ setId(id entity.Id)
+ // setAuthor allow to set the author, used when unmarshalling only
+ setAuthor(author identity.Interface)
+ // setExtraMetadataImmutable add a metadata not carried by the operation itself on the operation
+ setExtraMetadataImmutable(key string, value string)
}
-// OperationWithFiles is an extended Operation that has files dependency, stored in git.
+// OperationWithFiles is an optional extension for an Operation that has files dependency, stored in git.
type OperationWithFiles interface {
- Operation
-
// GetFiles return the files needed by this operation
// This implies that the Operation maintain and store internally the references to those files. This is how
// this information is read later, when loading from storage.
@@ -46,3 +72,201 @@ type OperationWithFiles interface {
// hash).
GetFiles() []repository.Hash
}
+
+// OperationDoesntChangeSnapshot is an interface signaling that the Operation implementing it doesn't change the
+// snapshot, for example a metadata operation that act on other operations.
+type OperationDoesntChangeSnapshot interface {
+ DoesntChangeSnapshot()
+}
+
+// Snapshot is the minimal interface that a snapshot need to implement
+type Snapshot interface {
+ // AllOperations returns all the operations that have been applied to that snapshot, in order
+ AllOperations() []Operation
+}
+
+// OpBase implement the common feature that every Operation should support.
+type OpBase struct {
+ // Not serialized. Store the op's id in memory.
+ id entity.Id
+ // Not serialized
+ author identity.Interface
+
+ OperationType OperationType `json:"type"`
+ UnixTime int64 `json:"timestamp"`
+
+ // mandatory random bytes to ensure a better randomness of the data used to later generate the ID
+ // len(Nonce) should be > 20 and < 64 bytes
+ // It has no functional purpose and should be ignored.
+ Nonce []byte `json:"nonce"`
+
+ Metadata map[string]string `json:"metadata,omitempty"`
+ // Not serialized. Store the extra metadata in memory,
+ // compiled from SetMetadataOperation.
+ extraMetadata map[string]string
+}
+
+func NewOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase {
+ return OpBase{
+ OperationType: opType,
+ author: author,
+ UnixTime: unixTime,
+ Nonce: makeNonce(20),
+ id: entity.UnsetId,
+ }
+}
+
+func makeNonce(len int) []byte {
+ result := make([]byte, len)
+ _, err := rand.Read(result)
+ if err != nil {
+ panic(err)
+ }
+ return result
+}
+
+func IdOperation(op Operation, base *OpBase) entity.Id {
+ if base.id == "" {
+ // something went really wrong
+ panic("op's id not set")
+ }
+ if base.id == entity.UnsetId {
+ // This means we are trying to get the op's Id *before* it has been stored, for instance when
+ // adding multiple ops in one go in an OperationPack.
+ // As the Id is computed based on the actual bytes written on the disk, we are going to predict
+ // those and then get the Id. This is safe as it will be the exact same code writing on disk later.
+
+ data, err := json.Marshal(op)
+ if err != nil {
+ panic(err)
+ }
+
+ base.id = entity.DeriveId(data)
+ }
+ return base.id
+}
+
+func (base *OpBase) Type() OperationType {
+ return base.OperationType
+}
+
+// Time return the time when the operation was added
+func (base *OpBase) Time() time.Time {
+ return time.Unix(base.UnixTime, 0)
+}
+
+// Validate check the OpBase for errors
+func (base *OpBase) Validate(op Operation, opType OperationType) error {
+ if base.OperationType == 0 {
+ return fmt.Errorf("operation type unset")
+ }
+ if base.OperationType != opType {
+ return fmt.Errorf("incorrect operation type (expected: %v, actual: %v)", opType, base.OperationType)
+ }
+
+ if op.Time().Unix() == 0 {
+ return fmt.Errorf("time not set")
+ }
+
+ if base.author == nil {
+ return fmt.Errorf("author not set")
+ }
+
+ if err := op.Author().Validate(); err != nil {
+ return errors.Wrap(err, "author")
+ }
+
+ if op, ok := op.(OperationWithFiles); ok {
+ for _, hash := range op.GetFiles() {
+ if !hash.IsValid() {
+ return fmt.Errorf("file with invalid hash %v", hash)
+ }
+ }
+ }
+
+ if len(base.Nonce) > 64 {
+ return fmt.Errorf("nonce is too big")
+ }
+ if len(base.Nonce) < 20 {
+ return fmt.Errorf("nonce is too small")
+ }
+
+ return nil
+}
+
+// IsAuthored is a sign post method for gqlgen
+func (base *OpBase) IsAuthored() {}
+
+// Author return author identity
+func (base *OpBase) Author() identity.Interface {
+ return base.author
+}
+
+// IdIsSet returns true if the id has been set already
+func (base *OpBase) IdIsSet() bool {
+ return base.id != "" && base.id != entity.UnsetId
+}
+
+// SetMetadata store arbitrary metadata about the operation
+func (base *OpBase) SetMetadata(key string, value string) {
+ if base.IdIsSet() {
+ panic("set metadata on an operation with already an Id")
+ }
+
+ if base.Metadata == nil {
+ base.Metadata = make(map[string]string)
+ }
+ base.Metadata[key] = value
+}
+
+// GetMetadata retrieve arbitrary metadata about the operation
+func (base *OpBase) GetMetadata(key string) (string, bool) {
+ val, ok := base.Metadata[key]
+
+ if ok {
+ return val, true
+ }
+
+ // extraMetadata can't replace the original operations value if any
+ val, ok = base.extraMetadata[key]
+
+ return val, ok
+}
+
+// AllMetadata return all metadata for this operation
+func (base *OpBase) AllMetadata() map[string]string {
+ result := make(map[string]string)
+
+ for key, val := range base.extraMetadata {
+ result[key] = val
+ }
+
+ // Original metadata take precedence
+ for key, val := range base.Metadata {
+ result[key] = val
+ }
+
+ return result
+}
+
+// setId allow to set the Id, used when unmarshalling only
+func (base *OpBase) setId(id entity.Id) {
+ if base.id != "" && base.id != entity.UnsetId {
+ panic("trying to set id again")
+ }
+ base.id = id
+}
+
+// setAuthor allow to set the author, used when unmarshalling only
+func (base *OpBase) setAuthor(author identity.Interface) {
+ base.author = author
+}
+
+func (base *OpBase) setExtraMetadataImmutable(key string, value string) {
+ if base.extraMetadata == nil {
+ base.extraMetadata = make(map[string]string)
+ }
+ if _, exist := base.extraMetadata[key]; !exist {
+ base.extraMetadata[key] = value
+ }
+}
diff --git a/entity/dag/operation_pack.go b/entity/dag/operation_pack.go
index 9b42f9bf..b2973343 100644
--- a/entity/dag/operation_pack.go
+++ b/entity/dag/operation_pack.go
@@ -314,10 +314,15 @@ func unmarshallPack(def Definition, resolver identity.Resolver, data []byte) ([]
for _, raw := range aux.Operations {
// delegate to specialized unmarshal function
- op, err := def.OperationUnmarshaler(author, raw, resolver)
+ op, err := def.OperationUnmarshaler(raw, resolver)
if err != nil {
return nil, nil, err
}
+ // Set the id from the serialized data
+ op.setId(entity.DeriveId(raw))
+ // Set the author, taken from the OperationPack
+ op.setAuthor(author)
+
ops = append(ops, op)
}
diff --git a/entity/dag/operation_pack_test.go b/entity/dag/operation_pack_test.go
index 73960800..e438e4c5 100644
--- a/entity/dag/operation_pack_test.go
+++ b/entity/dag/operation_pack_test.go
@@ -11,13 +11,13 @@ import (
)
func TestOperationPackReadWrite(t *testing.T) {
- repo, id1, _, resolver, def := makeTestContext()
+ repo, author, _, resolver, def := makeTestContext()
opp := &operationPack{
- Author: id1,
+ Author: author,
Operations: []Operation{
- newOp1(id1, "foo"),
- newOp2(id1, "bar"),
+ newOp1(author, "foo"),
+ newOp2(author, "bar"),
},
CreateTime: 123,
EditTime: 456,
@@ -32,34 +32,26 @@ func TestOperationPackReadWrite(t *testing.T) {
opp2, err := readOperationPack(def, repo, resolver, commit)
require.NoError(t, err)
- require.Equal(t, opp, opp2)
-
- // make sure we get the same Id with the same data
- opp3 := &operationPack{
- Author: id1,
- Operations: []Operation{
- newOp1(id1, "foo"),
- newOp2(id1, "bar"),
- },
- CreateTime: 123,
- EditTime: 456,
+ for _, op := range opp.Operations {
+ // force the creation of the id
+ op.Id()
}
- require.Equal(t, opp.Id(), opp3.Id())
+ require.Equal(t, opp, opp2)
}
func TestOperationPackSignedReadWrite(t *testing.T) {
- repo, id1, _, resolver, def := makeTestContext()
+ repo, author, _, resolver, def := makeTestContext()
- err := id1.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) {
+ err := author.(*identity.Identity).Mutate(repo, func(orig *identity.Mutator) {
orig.Keys = append(orig.Keys, identity.GenerateKey())
})
require.NoError(t, err)
opp := &operationPack{
- Author: id1,
+ Author: author,
Operations: []Operation{
- newOp1(id1, "foo"),
- newOp2(id1, "bar"),
+ newOp1(author, "foo"),
+ newOp2(author, "bar"),
},
CreateTime: 123,
EditTime: 456,
@@ -74,23 +66,15 @@ func TestOperationPackSignedReadWrite(t *testing.T) {
opp2, err := readOperationPack(def, repo, resolver, commit)
require.NoError(t, err)
- require.Equal(t, opp, opp2)
-
- // make sure we get the same Id with the same data
- opp3 := &operationPack{
- Author: id1,
- Operations: []Operation{
- newOp1(id1, "foo"),
- newOp2(id1, "bar"),
- },
- CreateTime: 123,
- EditTime: 456,
+ for _, op := range opp.Operations {
+ // force the creation of the id
+ op.Id()
}
- require.Equal(t, opp.Id(), opp3.Id())
+ require.Equal(t, opp, opp2)
}
func TestOperationPackFiles(t *testing.T) {
- repo, id1, _, resolver, def := makeTestContext()
+ repo, author, _, resolver, def := makeTestContext()
blobHash1, err := repo.StoreData(randomData())
require.NoError(t, err)
@@ -99,10 +83,10 @@ func TestOperationPackFiles(t *testing.T) {
require.NoError(t, err)
opp := &operationPack{
- Author: id1,
+ Author: author,
Operations: []Operation{
- newOp1(id1, "foo", blobHash1, blobHash2),
- newOp1(id1, "foo", blobHash2),
+ newOp1(author, "foo", blobHash1, blobHash2),
+ newOp1(author, "foo", blobHash2),
},
CreateTime: 123,
EditTime: 456,
@@ -117,6 +101,10 @@ func TestOperationPackFiles(t *testing.T) {
opp2, err := readOperationPack(def, repo, resolver, commit)
require.NoError(t, err)
+ for _, op := range opp.Operations {
+ // force the creation of the id
+ op.Id()
+ }
require.Equal(t, opp, opp2)
require.ElementsMatch(t, opp2.Operations[0].(OperationWithFiles).GetFiles(), []repository.Hash{
diff --git a/entity/dag/operation_testing.go b/entity/dag/operation_testing.go
new file mode 100644
index 00000000..29923eec
--- /dev/null
+++ b/entity/dag/operation_testing.go
@@ -0,0 +1,57 @@
+package dag
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/MichaelMure/git-bug/entity"
+ "github.com/MichaelMure/git-bug/identity"
+ "github.com/MichaelMure/git-bug/repository"
+)
+
+// SerializeRoundTripTest realize a marshall/unmarshall round-trip in the same condition as with OperationPack,
+// and check if the recovered operation is identical.
+func SerializeRoundTripTest[OpT Operation](t *testing.T, maker func(author identity.Interface, unixTime int64) OpT) {
+ repo := repository.NewMockRepo()
+
+ rene, err := identity.NewIdentity(repo, "René Descartes", "rene@descartes.fr")
+ require.NoError(t, err)
+
+ op := maker(rene, time.Now().Unix())
+ // enforce having an id
+ op.Id()
+
+ rdt := &roundTripper[OpT]{Before: op, author: rene}
+
+ data, err := json.Marshal(rdt)
+ require.NoError(t, err)
+
+ err = json.Unmarshal(data, &rdt)
+ require.NoError(t, err)
+
+ require.Equal(t, op, rdt.after)
+}
+
+type roundTripper[OpT Operation] struct {
+ Before OpT
+ author identity.Interface
+ after OpT
+}
+
+func (r *roundTripper[OpT]) MarshalJSON() ([]byte, error) {
+ return json.Marshal(r.Before)
+}
+
+func (r *roundTripper[OpT]) UnmarshalJSON(data []byte) error {
+ if err := json.Unmarshal(data, &r.after); err != nil {
+ return err
+ }
+ // Set the id from the serialized data
+ r.after.setId(entity.DeriveId(data))
+ // Set the author, as OperationPack would do
+ r.after.setAuthor(r.author)
+ return nil
+}