diff --git a/store/interfaces.gen.go b/store/interfaces.gen.go index 9b2fdbbd7..2180977bf 100644 --- a/store/interfaces.gen.go +++ b/store/interfaces.gen.go @@ -16,6 +16,7 @@ package store // - store/compose_record_values.yaml // - store/compose_records.yaml // - store/credentials.yaml +// - store/labels.yaml // - store/messaging_attachments.yaml // - store/messaging_channel_members.yaml // - store/messaging_channels.yaml @@ -50,6 +51,7 @@ type ( ComposeRecordValues ComposeRecords Credentials + Labels MessagingAttachments MessagingChannelMembers MessagingChannels diff --git a/store/labels.gen.go b/store/labels.gen.go new file mode 100644 index 000000000..863f010fa --- /dev/null +++ b/store/labels.gen.go @@ -0,0 +1,84 @@ +package store + +// This file is auto-generated. +// +// Template: pkg/codegen/assets/store_base.gen.go.tpl +// Definitions: store/labels.yaml +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/label/types" +) + +type ( + Labels interface { + SearchLabels(ctx context.Context, f types.LabelFilter) (types.LabelSet, types.LabelFilter, error) + LookupLabelByKindResourceIDName(ctx context.Context, kind string, resource_id uint64, name string) (*types.Label, error) + + CreateLabel(ctx context.Context, rr ...*types.Label) error + + UpdateLabel(ctx context.Context, rr ...*types.Label) error + + UpsertLabel(ctx context.Context, rr ...*types.Label) error + + DeleteLabel(ctx context.Context, rr ...*types.Label) error + DeleteLabelByKindResourceIDName(ctx context.Context, kind string, resourceID uint64, name string) error + + TruncateLabels(ctx context.Context) error + + // Additional custom functions + + // DeleteExtraLabels (custom function) + DeleteExtraLabels(ctx context.Context, _kind string, _resourceID uint64, _names ...string) error + } +) + +var _ *types.Label +var _ context.Context + +// SearchLabels returns all matching Labels from store +func SearchLabels(ctx context.Context, s Labels, f types.LabelFilter) (types.LabelSet, types.LabelFilter, error) { + return s.SearchLabels(ctx, f) +} + +// LookupLabelByKindResourceIDName Label lookup by kind, resource, name +func LookupLabelByKindResourceIDName(ctx context.Context, s Labels, kind string, resource_id uint64, name string) (*types.Label, error) { + return s.LookupLabelByKindResourceIDName(ctx, kind, resource_id, name) +} + +// CreateLabel creates one or more Labels in store +func CreateLabel(ctx context.Context, s Labels, rr ...*types.Label) error { + return s.CreateLabel(ctx, rr...) +} + +// UpdateLabel updates one or more (existing) Labels in store +func UpdateLabel(ctx context.Context, s Labels, rr ...*types.Label) error { + return s.UpdateLabel(ctx, rr...) +} + +// UpsertLabel creates new or updates existing one or more Labels in store +func UpsertLabel(ctx context.Context, s Labels, rr ...*types.Label) error { + return s.UpsertLabel(ctx, rr...) +} + +// DeleteLabel Deletes one or more Labels from store +func DeleteLabel(ctx context.Context, s Labels, rr ...*types.Label) error { + return s.DeleteLabel(ctx, rr...) +} + +// DeleteLabelByKindResourceIDName Deletes Label from store +func DeleteLabelByKindResourceIDName(ctx context.Context, s Labels, kind string, resourceID uint64, name string) error { + return s.DeleteLabelByKindResourceIDName(ctx, kind, resourceID, name) +} + +// TruncateLabels Deletes all Labels from store +func TruncateLabels(ctx context.Context, s Labels) error { + return s.TruncateLabels(ctx) +} + +func DeleteExtraLabels(ctx context.Context, s Labels, _kind string, _resourceID uint64, _names ...string) error { + return s.DeleteExtraLabels(ctx, _kind, _resourceID, _names...) +} diff --git a/store/labels.yaml b/store/labels.yaml new file mode 100644 index 000000000..3bc398ac9 --- /dev/null +++ b/store/labels.yaml @@ -0,0 +1,39 @@ +import: + - github.com/cortezaproject/corteza-server/pkg/label/types + +types: + type: types.Label + +fields: + - { field: Kind, isPrimaryKey: true } + - { field: ResourceID, isPrimaryKey: true } + - { field: Name, isPrimaryKey: true, lookupFilterPreprocessor: lower } + - { field: Value } + +lookups: + - fields: [ Kind, ResourceID, Name ] + uniqueConstraintCheck: true + description: |- + Label lookup by kind, resource, name + +functions: + - name: DeleteExtraLabels + arguments: + - { name: kind, type: string } + - { name: resourceID, type: uint64 } + - { name: names, type: ...string } + return: [ error ] + + +search: + enablePaging: false + enableSorting: false + enableFilterCheckFunction: false + +upsert: + enable: true + +rdbms: + alias: lbl + table: labels + customFilterConverter: true diff --git a/store/rdbms/labels.gen.go b/store/rdbms/labels.gen.go new file mode 100644 index 000000000..87dd33173 --- /dev/null +++ b/store/rdbms/labels.gen.go @@ -0,0 +1,344 @@ +package rdbms + +// This file is an auto-generated file +// +// Template: pkg/codegen/assets/store_rdbms.gen.go.tpl +// Definitions: store/labels.yaml +// +// Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated. + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/label/types" + "github.com/cortezaproject/corteza-server/store" +) + +var _ = errors.Is + +// SearchLabels returns all matching rows +// +// This function calls convertLabelFilter with the given +// types.LabelFilter and expects to receive a working squirrel.SelectBuilder +func (s Store) SearchLabels(ctx context.Context, f types.LabelFilter) (types.LabelSet, types.LabelFilter, error) { + var ( + err error + set []*types.Label + q squirrel.SelectBuilder + ) + q, err = s.convertLabelFilter(f) + if err != nil { + return nil, f, err + } + + return set, f, s.config.ErrorHandler(func() error { + set, _, _, err = s.QueryLabels(ctx, q, nil) + return err + + }()) +} + +// QueryLabels queries the database, converts and checks each row and +// returns collected set +// +// Fn also returns total number of fetched items and last fetched item so that the caller can construct cursor +// for next page of results +func (s Store) QueryLabels( + ctx context.Context, + q squirrel.Sqlizer, + check func(*types.Label) (bool, error), +) ([]*types.Label, uint, *types.Label, error) { + var ( + set = make([]*types.Label, 0, DefaultSliceCapacity) + res *types.Label + + // Query rows with + rows, err = s.Query(ctx, q) + + fetched uint + ) + + if err != nil { + return nil, 0, nil, err + } + + defer rows.Close() + for rows.Next() { + fetched++ + if err = rows.Err(); err == nil { + res, err = s.internalLabelRowScanner(rows) + } + + if err != nil { + return nil, 0, nil, err + } + + set = append(set, res) + } + + return set, fetched, res, rows.Err() +} + +// LookupLabelByKindResourceIDName Label lookup by kind, resource, name +func (s Store) LookupLabelByKindResourceIDName(ctx context.Context, kind string, resource_id uint64, name string) (*types.Label, error) { + return s.execLookupLabel(ctx, squirrel.Eq{ + s.preprocessColumn("lbl.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("lbl.rel_resource", ""): store.PreprocessValue(resource_id, ""), + s.preprocessColumn("lbl.name", "lower"): store.PreprocessValue(name, "lower"), + }) +} + +// CreateLabel creates one or more rows in labels table +func (s Store) CreateLabel(ctx context.Context, rr ...*types.Label) (err error) { + for _, res := range rr { + err = s.checkLabelConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execCreateLabels(ctx, s.internalLabelEncoder(res)) + if err != nil { + return err + } + } + + return +} + +// UpdateLabel updates one or more existing rows in labels +func (s Store) UpdateLabel(ctx context.Context, rr ...*types.Label) error { + return s.config.ErrorHandler(s.partialLabelUpdate(ctx, nil, rr...)) +} + +// partialLabelUpdate updates one or more existing rows in labels +func (s Store) partialLabelUpdate(ctx context.Context, onlyColumns []string, rr ...*types.Label) (err error) { + for _, res := range rr { + err = s.checkLabelConstraints(ctx, res) + if err != nil { + return err + } + + err = s.execUpdateLabels( + ctx, + squirrel.Eq{ + s.preprocessColumn("lbl.kind", ""): store.PreprocessValue(res.Kind, ""), s.preprocessColumn("lbl.rel_resource", ""): store.PreprocessValue(res.ResourceID, ""), s.preprocessColumn("lbl.name", "lower"): store.PreprocessValue(res.Name, "lower"), + }, + s.internalLabelEncoder(res).Skip("kind", "rel_resource", "name").Only(onlyColumns...)) + if err != nil { + return s.config.ErrorHandler(err) + } + } + + return +} + +// UpsertLabel updates one or more existing rows in labels +func (s Store) UpsertLabel(ctx context.Context, rr ...*types.Label) (err error) { + for _, res := range rr { + err = s.checkLabelConstraints(ctx, res) + if err != nil { + return err + } + + err = s.config.ErrorHandler(s.execUpsertLabels(ctx, s.internalLabelEncoder(res))) + if err != nil { + return err + } + } + + return nil +} + +// DeleteLabel Deletes one or more rows from labels table +func (s Store) DeleteLabel(ctx context.Context, rr ...*types.Label) (err error) { + for _, res := range rr { + + err = s.execDeleteLabels(ctx, squirrel.Eq{ + s.preprocessColumn("lbl.kind", ""): store.PreprocessValue(res.Kind, ""), s.preprocessColumn("lbl.rel_resource", ""): store.PreprocessValue(res.ResourceID, ""), s.preprocessColumn("lbl.name", "lower"): store.PreprocessValue(res.Name, "lower"), + }) + if err != nil { + return s.config.ErrorHandler(err) + } + } + + return nil +} + +// DeleteLabelByKindResourceIDName Deletes row from the labels table +func (s Store) DeleteLabelByKindResourceIDName(ctx context.Context, kind string, resourceID uint64, name string) error { + return s.execDeleteLabels(ctx, squirrel.Eq{ + s.preprocessColumn("lbl.kind", ""): store.PreprocessValue(kind, ""), + s.preprocessColumn("lbl.rel_resource", ""): store.PreprocessValue(resourceID, ""), + s.preprocessColumn("lbl.name", "lower"): store.PreprocessValue(name, "lower"), + }) +} + +// TruncateLabels Deletes all rows from the labels table +func (s Store) TruncateLabels(ctx context.Context) error { + return s.config.ErrorHandler(s.Truncate(ctx, s.labelTable())) +} + +// execLookupLabel prepares Label query and executes it, +// returning types.Label (or error) +func (s Store) execLookupLabel(ctx context.Context, cnd squirrel.Sqlizer) (res *types.Label, err error) { + var ( + row rowScanner + ) + + row, err = s.QueryRow(ctx, s.labelsSelectBuilder().Where(cnd)) + if err != nil { + return + } + + res, err = s.internalLabelRowScanner(row) + if err != nil { + return + } + + return res, nil +} + +// execCreateLabels updates all matched (by cnd) rows in labels with given data +func (s Store) execCreateLabels(ctx context.Context, payload store.Payload) error { + return s.config.ErrorHandler(s.Exec(ctx, s.InsertBuilder(s.labelTable()).SetMap(payload))) +} + +// execUpdateLabels updates all matched (by cnd) rows in labels with given data +func (s Store) execUpdateLabels(ctx context.Context, cnd squirrel.Sqlizer, set store.Payload) error { + return s.config.ErrorHandler(s.Exec(ctx, s.UpdateBuilder(s.labelTable("lbl")).Where(cnd).SetMap(set))) +} + +// execUpsertLabels inserts new or updates matching (by-primary-key) rows in labels with given data +func (s Store) execUpsertLabels(ctx context.Context, set store.Payload) error { + upsert, err := s.config.UpsertBuilder( + s.config, + s.labelTable(), + set, + "kind", + "rel_resource", + "name", + ) + + if err != nil { + return err + } + + return s.config.ErrorHandler(s.Exec(ctx, upsert)) +} + +// execDeleteLabels Deletes all matched (by cnd) rows in labels with given data +func (s Store) execDeleteLabels(ctx context.Context, cnd squirrel.Sqlizer) error { + return s.config.ErrorHandler(s.Exec(ctx, s.DeleteBuilder(s.labelTable("lbl")).Where(cnd))) +} + +func (s Store) internalLabelRowScanner(row rowScanner) (res *types.Label, err error) { + res = &types.Label{} + + if _, has := s.config.RowScanners["label"]; has { + scanner := s.config.RowScanners["label"].(func(_ rowScanner, _ *types.Label) error) + err = scanner(row, res) + } else { + err = row.Scan( + &res.Kind, + &res.ResourceID, + &res.Name, + &res.Value, + ) + } + + if err == sql.ErrNoRows { + return nil, store.ErrNotFound + } + + if err != nil { + return nil, fmt.Errorf("could not scan db row for Label: %w", err) + } else { + return res, nil + } +} + +// QueryLabels returns squirrel.SelectBuilder with set table and all columns +func (s Store) labelsSelectBuilder() squirrel.SelectBuilder { + return s.SelectBuilder(s.labelTable("lbl"), s.labelColumns("lbl")...) +} + +// labelTable name of the db table +func (Store) labelTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "labels" + alias +} + +// LabelColumns returns all defined table columns +// +// With optional string arg, all columns are returned aliased +func (Store) labelColumns(aa ...string) []string { + var alias string + if len(aa) > 0 { + alias = aa[0] + "." + } + + return []string{ + alias + "kind", + alias + "rel_resource", + alias + "name", + alias + "value", + } +} + +// {true true false false false} + +// internalLabelEncoder encodes fields from types.Label to store.Payload (map) +// +// Encoding is done by using generic approach or by calling encodeLabel +// func when rdbms.customEncoder=true +func (s Store) internalLabelEncoder(res *types.Label) store.Payload { + return store.Payload{ + "kind": res.Kind, + "rel_resource": res.ResourceID, + "name": res.Name, + "value": res.Value, + } +} + +// checkLabelConstraints performs lookups (on valid) resource to check if any of the values on unique fields +// already exists in the store +// +// Using built-in constraint checking would be more performant but unfortunately we can not rely +// on the full support (MySQL does not support conditional indexes) +func (s *Store) checkLabelConstraints(ctx context.Context, res *types.Label) error { + // Consider resource valid when all fields in unique constraint check lookups + // have valid (non-empty) value + // + // Only string and uint64 are supported for now + // feel free to add additional types if needed + var valid = true + + valid = valid && len(res.Kind) > 0 + + valid = valid && res.ResourceID > 0 + + valid = valid && len(res.Name) > 0 + + if !valid { + return nil + } + + { + ex, err := s.LookupLabelByKindResourceIDName(ctx, res.Kind, res.ResourceID, res.Name) + if err == nil && ex != nil && ex.Kind != res.Kind && ex.ResourceID != res.ResourceID && ex.Name != res.Name { + return store.ErrNotUnique + } else if !errors.Is(err, store.ErrNotFound) { + return err + } + } + + return nil +} diff --git a/store/rdbms/labels.go b/store/rdbms/labels.go new file mode 100644 index 000000000..c26a5eba1 --- /dev/null +++ b/store/rdbms/labels.go @@ -0,0 +1,41 @@ +package rdbms + +import ( + "context" + "github.com/Masterminds/squirrel" + "github.com/cortezaproject/corteza-server/pkg/label/types" +) + +func (s Store) convertLabelFilter(f types.LabelFilter) (query squirrel.SelectBuilder, err error) { + query = s.labelsSelectBuilder() + + query = query.Where(squirrel.Eq{"lbl.kind": f.Kind}) + + if len(f.ResourceID) > 0 { + query = query.Where(squirrel.Eq{"lbl.rel_resource": f.ResourceID}) + } + + if len(f.Filter) > 0 { + kvOr := squirrel.Or{} + for k, v := range f.Filter { + kvOr = append(kvOr, squirrel.Eq{"name": k, "value": v}) + } + query = query.Where(kvOr) + + } + + return +} + +// DeleteExtraLabels removes all unlisted labels +func (s Store) DeleteExtraLabels(ctx context.Context, kind string, resourceID uint64, names ...string) (err error) { + return s.execDeleteLabels(ctx, squirrel.And{ + squirrel.Eq{ + "kind": kind, + "rel_resource": resourceID, + }, + squirrel.NotEq{ + "name": names, + }, + }) +} diff --git a/store/rdbms/rdbms_schema.go b/store/rdbms/rdbms_schema.go index 7cf0a8556..51f8c0cee 100644 --- a/store/rdbms/rdbms_schema.go +++ b/store/rdbms/rdbms_schema.go @@ -56,6 +56,7 @@ func (s Schema) Tables() []*Table { s.ActionLog(), s.RbacRules(), s.Settings(), + s.Labels(), s.ComposeAttachment(), s.ComposeChart(), s.ComposeModule(), @@ -220,6 +221,17 @@ func (Schema) Settings() *Table { ) } +func (Schema) Labels() *Table { + return TableDef("labels", + ColumnDef("kind", ColumnTypeVarchar, ColumnTypeLength(handleLength)), + ColumnDef("rel_resource", ColumnTypeIdentifier), + ColumnDef("name", ColumnTypeVarchar, ColumnTypeLength(resourceLength)), + ColumnDef("value", ColumnTypeText), + + PrimaryKey(IColumn("kind", "rel_resource", "name")), + ) +} + func (Schema) ComposeAttachment() *Table { // @todo merge with general attachment table diff --git a/store/tests/gen_test.go b/store/tests/gen_test.go index 0bce68101..5c353654e 100644 --- a/store/tests/gen_test.go +++ b/store/tests/gen_test.go @@ -14,6 +14,7 @@ package tests // - store/compose_namespaces.yaml // - store/compose_pages.yaml // - store/credentials.yaml +// - store/labels.yaml // - store/messaging_attachments.yaml // - store/messaging_channel_members.yaml // - store/messaging_channels.yaml @@ -100,6 +101,11 @@ func testAllGenerated(t *testing.T, s store.Storer) { testCredentials(t, s) }) + // Run generated tests for Labels + t.Run("Labels", func(t *testing.T) { + testLabels(t, s) + }) + // Run generated tests for MessagingAttachments t.Run("MessagingAttachments", func(t *testing.T) { testMessagingAttachments(t, s) diff --git a/store/tests/labels_test.go b/store/tests/labels_test.go new file mode 100644 index 000000000..f05a7f832 --- /dev/null +++ b/store/tests/labels_test.go @@ -0,0 +1,49 @@ +package tests + +import ( + "context" + "github.com/cortezaproject/corteza-server/pkg/label/types" + "github.com/cortezaproject/corteza-server/store" + _ "github.com/joho/godotenv/autoload" + "github.com/stretchr/testify/require" + "testing" +) + +func testLabels(t *testing.T, s store.Labels) { + var ( + ctx = context.Background() + ) + + t.Run("create", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateLabels(ctx)) + req.NoError(s.CreateLabel(ctx, &types.Label{ + Kind: "kind", + ResourceID: 1, + Name: "lname", + Value: "lvalue", + })) + }) + + t.Run("update", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateLabels(ctx)) + req.NoError(s.UpdateLabel(ctx, &types.Label{ + Kind: "kind", + ResourceID: 1, + Name: "lname", + Value: "lvalue", + })) + }) + + t.Run("upsert", func(t *testing.T) { + req := require.New(t) + req.NoError(s.TruncateLabels(ctx)) + req.NoError(s.UpsertLabel(ctx, &types.Label{ + Kind: "kind", + ResourceID: 1, + Name: "lname", + Value: "lvalue", + })) + }) +}