mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-19 06:02:52 +00:00
Improves compatibility of eventstore and related database components with the new relational table package. ## Which problems are solved 1. **Incompatible Database Interfaces**: The existing eventstore was tightly coupled to the database package, which is incompatible with the new, more abstract relational table package in v3. This prevented the new command-side logic from pushing events to the legacy eventstore. 2. **Missing Health Checks**: The database interfaces in the new package lacked a Ping method, making it impossible to perform health checks on database connections. 3. **Event Publishing Logic**: The command handling logic in domain needed a way to collect and push events to the legacy eventstore after a command was successfully executed. ## How the problems are solved 1. **`LegacyEventstore` Interface**: * A new `LegacyEventstore` interface is introduced in the new `database/eventstore` . This interface exposes a `PushWithNewClient` method that accepts the new `database.QueryExecutor` interface, decoupling the v3 domain from the legacy implementation. * The `internal/eventstore.Eventstore` now implements this interface. A wrapper, PushWithClient, is added to convert the old database client types (`*sql.DB`, `*sql.Tx`) into the new `QueryExecutor` types before calling `PushWithNewClient`. 2. **Database Interface Updates**: * The `database.Pool` and `database.Client` interfaces in `storage/eventstore` have been updated to include a Ping method, allowing for consistent health checks across different database dialects. * The `postgres` and `sql` dialect implementations have been updated to support this new method. 3. **Command and Invoker Refactoring**: * The `Commander` interface in domain now includes an `Events() []legacy_es.Command` method. This allows commands to declare which events they will generate. * The `eventCollector` in the invoker logic has been redesigned. It now ensures a database transaction is started before executing a command. After successful execution, it calls the `Events()` method on the command to collect the generated events and appends them to a list. * The `eventStoreInvoker` then pushes all collected events to the legacy eventstore using the new `LegacyEventstore` interface, ensuring that events are only pushed if the entire command (and any sub-commands) executes successfully within the transaction. 4. **Testing**: * New unit tests have been added for the invoker to verify that events are correctly collected from single commands, batched commands, and nested commands. These changes create a clean bridge between the new v3 command-side logic and the existing v1 eventstore, allowing for incremental adoption of the new architecture while maintaining full functionality. ## Additional Information closes https://github.com/zitadel/zitadel/issues/10442
364 lines
9.9 KiB
Go
364 lines
9.9 KiB
Go
package eventstore
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"github.com/zitadel/zitadel/internal/eventstore"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
type fieldValue struct {
|
|
value []byte
|
|
}
|
|
|
|
func (value *fieldValue) Unmarshal(ptr any) error {
|
|
return json.Unmarshal(value.value, ptr)
|
|
}
|
|
|
|
func (es *Eventstore) FillFields(ctx context.Context, events ...eventstore.FillFieldsEvent) (err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer span.End()
|
|
|
|
tx, err := es.client.Begin(ctx, &database.TransactionOptions{IsolationLevel: database.IsolationLevelReadCommitted})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
err = tx.End(ctx, err)
|
|
}()
|
|
|
|
return handleFieldFillEvents(ctx, tx, events)
|
|
}
|
|
|
|
// Search implements the [eventstore.Search] method
|
|
func (es *Eventstore) Search(ctx context.Context, conditions ...map[eventstore.FieldType]any) (result []*eventstore.SearchResult, err error) {
|
|
ctx, span := tracing.NewSpan(ctx)
|
|
defer func() { span.EndWithError(err) }()
|
|
|
|
var builder strings.Builder
|
|
args := buildSearchStatement(ctx, &builder, conditions...)
|
|
|
|
rows, err := es.client.Query(
|
|
ctx,
|
|
builder.String(),
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
res eventstore.SearchResult
|
|
value fieldValue
|
|
)
|
|
err = rows.Scan(
|
|
&res.Aggregate.InstanceID,
|
|
&res.Aggregate.ResourceOwner,
|
|
&res.Aggregate.Type,
|
|
&res.Aggregate.ID,
|
|
&res.Object.Type,
|
|
&res.Object.ID,
|
|
&res.Object.Revision,
|
|
&res.FieldName,
|
|
&value.value,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Value = &value
|
|
|
|
result = append(result, &res)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
const searchQueryPrefix = `SELECT instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value FROM eventstore.fields WHERE instance_id = $1`
|
|
|
|
func buildSearchStatement(ctx context.Context, builder *strings.Builder, conditions ...map[eventstore.FieldType]any) []any {
|
|
args := make([]any, 0, len(conditions)*4+1)
|
|
args = append(args, authz.GetInstance(ctx).InstanceID())
|
|
|
|
builder.WriteString(searchQueryPrefix)
|
|
|
|
builder.WriteString(" AND ")
|
|
if len(conditions) > 1 {
|
|
builder.WriteRune('(')
|
|
}
|
|
for i, condition := range conditions {
|
|
if i > 0 {
|
|
builder.WriteString(" OR ")
|
|
}
|
|
if len(condition) > 1 {
|
|
builder.WriteRune('(')
|
|
}
|
|
args = append(args, buildSearchCondition(builder, len(args)+1, condition)...)
|
|
if len(condition) > 1 {
|
|
builder.WriteRune(')')
|
|
}
|
|
}
|
|
if len(conditions) > 1 {
|
|
builder.WriteRune(')')
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
func buildSearchCondition(builder *strings.Builder, index int, conditions map[eventstore.FieldType]any) []any {
|
|
args := make([]any, 0, len(conditions))
|
|
|
|
orderedCondition := make([]eventstore.FieldType, 0, len(conditions))
|
|
for field := range conditions {
|
|
orderedCondition = append(orderedCondition, field)
|
|
}
|
|
slices.Sort(orderedCondition)
|
|
|
|
for _, field := range orderedCondition {
|
|
if len(args) > 0 {
|
|
builder.WriteString(" AND ")
|
|
}
|
|
builder.WriteString(fieldNameByType(field, conditions[field]))
|
|
builder.WriteString(" = $")
|
|
builder.WriteString(strconv.Itoa(index + len(args)))
|
|
args = append(args, conditions[field])
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
func (es *Eventstore) handleFieldCommands(ctx context.Context, tx database.Transaction, commands []eventstore.Command) error {
|
|
for _, command := range commands {
|
|
if len(command.Fields()) > 0 {
|
|
if err := handleFieldOperations(ctx, tx, command.Fields()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleFieldFillEvents(ctx context.Context, tx database.Transaction, events []eventstore.FillFieldsEvent) error {
|
|
for _, event := range events {
|
|
if len(event.Fields()) == 0 {
|
|
continue
|
|
}
|
|
if err := handleFieldOperations(ctx, tx, event.Fields()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleFieldOperations(ctx context.Context, tx database.Transaction, operations []*eventstore.FieldOperation) error {
|
|
for _, operation := range operations {
|
|
if operation.Set != nil {
|
|
if err := handleFieldSet(ctx, tx, operation.Set); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if operation.Remove != nil {
|
|
if err := handleSearchDelete(ctx, tx, operation.Remove); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func handleFieldSet(ctx context.Context, tx database.Transaction, field *eventstore.Field) error {
|
|
if len(field.UpsertConflictFields) == 0 {
|
|
return handleSearchInsert(ctx, tx, field)
|
|
}
|
|
return handleSearchUpsert(ctx, tx, field)
|
|
}
|
|
|
|
const (
|
|
insertField = `INSERT INTO eventstore.fields (instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value, value_must_be_unique, should_index) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`
|
|
)
|
|
|
|
func handleSearchInsert(ctx context.Context, tx database.Transaction, field *eventstore.Field) error {
|
|
value, err := json.Marshal(field.Value.Value)
|
|
if err != nil {
|
|
return zerrors.ThrowInvalidArgument(err, "V3-fcrW1", "unable to marshal field value")
|
|
}
|
|
_, err = tx.Exec(
|
|
ctx,
|
|
insertField,
|
|
|
|
field.Aggregate.InstanceID,
|
|
field.Aggregate.ResourceOwner,
|
|
field.Aggregate.Type,
|
|
field.Aggregate.ID,
|
|
field.Object.Type,
|
|
field.Object.ID,
|
|
field.Object.Revision,
|
|
field.FieldName,
|
|
value,
|
|
field.Value.MustBeUnique,
|
|
field.Value.ShouldIndex,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const (
|
|
fieldsUpsertPrefix = `WITH upsert AS (UPDATE eventstore.fields SET (instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value, value_must_be_unique, should_index) = ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) WHERE `
|
|
fieldsUpsertSuffix = ` RETURNING * ) INSERT INTO eventstore.fields (instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, object_revision, field_name, value, value_must_be_unique, should_index) SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 WHERE NOT EXISTS (SELECT 1 FROM upsert)`
|
|
)
|
|
|
|
func handleSearchUpsert(ctx context.Context, tx database.Transaction, field *eventstore.Field) error {
|
|
value, err := json.Marshal(field.Value.Value)
|
|
if err != nil {
|
|
return zerrors.ThrowInvalidArgument(err, "V3-fcrW1", "unable to marshal field value")
|
|
}
|
|
|
|
_, err = tx.Exec(
|
|
ctx,
|
|
writeUpsertField(field.UpsertConflictFields),
|
|
|
|
field.Aggregate.InstanceID,
|
|
field.Aggregate.ResourceOwner,
|
|
field.Aggregate.Type,
|
|
field.Aggregate.ID,
|
|
field.Object.Type,
|
|
field.Object.ID,
|
|
field.Object.Revision,
|
|
field.FieldName,
|
|
value,
|
|
field.Value.MustBeUnique,
|
|
field.Value.ShouldIndex,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func writeUpsertField(fields []eventstore.FieldType) string {
|
|
var builder strings.Builder
|
|
|
|
builder.WriteString(fieldsUpsertPrefix)
|
|
for i, fieldName := range fields {
|
|
if i > 0 {
|
|
builder.WriteString(" AND ")
|
|
}
|
|
name, index := searchFieldNameAndIndexByTypeForPush(fieldName)
|
|
|
|
builder.WriteString(name)
|
|
builder.WriteString(" = ")
|
|
builder.WriteString(index)
|
|
}
|
|
builder.WriteString(fieldsUpsertSuffix)
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
const removeSearch = `DELETE FROM eventstore.fields WHERE `
|
|
|
|
func handleSearchDelete(ctx context.Context, tx database.Transaction, clauses map[eventstore.FieldType]any) error {
|
|
if len(clauses) == 0 {
|
|
return zerrors.ThrowInvalidArgument(nil, "V3-oqlBZ", "no conditions")
|
|
}
|
|
stmt, args := writeDeleteField(clauses)
|
|
_, err := tx.Exec(ctx, stmt, args...)
|
|
return err
|
|
}
|
|
|
|
func writeDeleteField(clauses map[eventstore.FieldType]any) (string, []any) {
|
|
var (
|
|
builder strings.Builder
|
|
args = make([]any, 0, len(clauses))
|
|
)
|
|
builder.WriteString(removeSearch)
|
|
|
|
orderedCondition := make([]eventstore.FieldType, 0, len(clauses))
|
|
for field := range clauses {
|
|
orderedCondition = append(orderedCondition, field)
|
|
}
|
|
slices.Sort(orderedCondition)
|
|
|
|
for _, fieldName := range orderedCondition {
|
|
if len(args) > 0 {
|
|
builder.WriteString(" AND ")
|
|
}
|
|
builder.WriteString(fieldNameByType(fieldName, clauses[fieldName]))
|
|
|
|
builder.WriteString(" = $")
|
|
builder.WriteString(strconv.Itoa(len(args) + 1))
|
|
|
|
args = append(args, clauses[fieldName])
|
|
}
|
|
|
|
return builder.String(), args
|
|
}
|
|
|
|
func fieldNameByType(typ eventstore.FieldType, value any) string {
|
|
switch typ {
|
|
case eventstore.FieldTypeAggregateID:
|
|
return "aggregate_id"
|
|
case eventstore.FieldTypeAggregateType:
|
|
return "aggregate_type"
|
|
case eventstore.FieldTypeInstanceID:
|
|
return "instance_id"
|
|
case eventstore.FieldTypeResourceOwner:
|
|
return "resource_owner"
|
|
case eventstore.FieldTypeFieldName:
|
|
return "field_name"
|
|
case eventstore.FieldTypeObjectType:
|
|
return "object_type"
|
|
case eventstore.FieldTypeObjectID:
|
|
return "object_id"
|
|
case eventstore.FieldTypeObjectRevision:
|
|
return "object_revision"
|
|
case eventstore.FieldTypeValue:
|
|
return valueColumn(value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func searchFieldNameAndIndexByTypeForPush(typ eventstore.FieldType) (string, string) {
|
|
switch typ {
|
|
case eventstore.FieldTypeInstanceID:
|
|
return "instance_id", "$1"
|
|
case eventstore.FieldTypeResourceOwner:
|
|
return "resource_owner", "$2"
|
|
case eventstore.FieldTypeAggregateType:
|
|
return "aggregate_type", "$3"
|
|
case eventstore.FieldTypeAggregateID:
|
|
return "aggregate_id", "$4"
|
|
case eventstore.FieldTypeObjectType:
|
|
return "object_type", "$5"
|
|
case eventstore.FieldTypeObjectID:
|
|
return "object_id", "$6"
|
|
case eventstore.FieldTypeObjectRevision:
|
|
return "object_revision", "$7"
|
|
case eventstore.FieldTypeFieldName:
|
|
return "field_name", "$8"
|
|
case eventstore.FieldTypeValue:
|
|
return "value", "$9"
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func valueColumn(value any) string {
|
|
//nolint: exhaustive
|
|
switch reflect.TypeOf(value).Kind() {
|
|
case reflect.Bool:
|
|
return "bool_value"
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
|
|
return "number_value"
|
|
case reflect.String:
|
|
return "text_value"
|
|
}
|
|
return ""
|
|
}
|