diff --git a/alpha/declcfg/filter/filter.go b/alpha/declcfg/filter/filter.go new file mode 100644 index 000000000..f4cc77efb --- /dev/null +++ b/alpha/declcfg/filter/filter.go @@ -0,0 +1,248 @@ +// Package filter provides functionality for filtering File-Based Catalog metadata +// based on search metadata properties. It supports filtering by string, list, and map +// metadata types with flexible matching criteria and combination logic. +package filter + +import ( + "encoding/json" + "fmt" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" +) + +// Result represents the result of evaluating a single filter criterion +type Result struct { + Name string // The name of the filter criterion + Matched bool // Whether the criterion matched +} + +// MatchFunc defines how multiple filter criteria should be combined +type MatchFunc func(results []Result) bool + +// All returns true only if all criteria match (AND logic) +func All(results []Result) bool { + for _, result := range results { + if !result.Matched { + return false + } + } + return true +} + +// Any returns true if any criteria matches (OR logic) +func Any(results []Result) bool { + for _, result := range results { + if result.Matched { + return true + } + } + return false +} + +// ValueMatchFunc defines how values within a single criterion should be matched +type ValueMatchFunc func(metadataValues, filterValues []string) bool + +// anyValue returns true if metadata contains any of the filter values. +// This is an internal value matching function used by HasAny criteria. +func anyValue(metadataValues, filterValues []string) bool { + metadataSet := make(map[string]bool) + for _, v := range metadataValues { + metadataSet[v] = true + } + + for _, filterValue := range filterValues { + if metadataSet[filterValue] { + return true + } + } + return false +} + +// allValues returns true if metadata contains all of the filter values. +// This is an internal value matching function used by HasAll criteria. +func allValues(metadataValues, filterValues []string) bool { + metadataSet := make(map[string]bool) + for _, v := range metadataValues { + metadataSet[v] = true + } + + for _, filterValue := range filterValues { + if !metadataSet[filterValue] { + return false + } + } + return true +} + +// Filter holds the configuration for filtering metadata based on filter criteria. +// It can be used to evaluate whether metadata objects match specified conditions. +type Filter struct { + // criteria are the individual filter criteria + criteria []criterion + // matchFunc determines how multiple filter criteria should be combined + matchFunc MatchFunc +} + +// criterion represents a single filter criterion +type criterion struct { + name string + values []string + matchFunc ValueMatchFunc +} + +// New creates a new Filter with the specified match function. +// The match function determines how multiple filter criteria are combined (e.g., All, Any). +func New(matchFunc MatchFunc) *Filter { + return &Filter{ + matchFunc: matchFunc, + } +} + +// HasAny adds a filter criterion that matches if the metadata contains any of the specified values. +// For string metadata, it checks if the value matches any of the provided values. +// For list metadata, it checks if any list element matches any of the provided values. +// For map metadata, it checks if any key with a true value matches any of the provided values. +func (f *Filter) HasAny(name string, values ...string) *Filter { + f.criteria = append(f.criteria, criterion{ + name: name, + values: values, + matchFunc: anyValue, + }) + return f +} + +// HasAll adds a filter criterion that matches if the metadata contains all of the specified values. +// For string metadata, it checks if the value matches all of the provided values (typically used with a single value). +// For list metadata, it checks if the list contains all of the provided values. +// For map metadata, it checks if all of the provided values exist as keys with true values. +func (f *Filter) HasAll(name string, values ...string) *Filter { + f.criteria = append(f.criteria, criterion{ + name: name, + values: values, + matchFunc: allValues, + }) + return f +} + +// matchSearchMetadata evaluates filter criteria against a single SearchMetadata instance. +// This is an internal helper method used by matchProperties. +func (f *Filter) matchSearchMetadata(searchMetadata property.SearchMetadata) (bool, error) { + // Create a map of search metadata for quick lookup + metadataMap := make(map[string]property.SearchMetadataItem) + for _, item := range searchMetadata { + metadataMap[item.Name] = item + } + + // Evaluate each filter criterion + results := make([]Result, 0, len(f.criteria)) + for _, filter := range f.criteria { + metadata, exists := metadataMap[filter.name] + + // If the filter criterion is not defined in the search metadata, it doesn't match + if !exists { + results = append(results, Result{ + Name: filter.name, + Matched: false, + }) + continue + } + + criterionMatch, err := applyCriterion(metadata, filter) + if err != nil { + return false, err + } + + results = append(results, Result{ + Name: filter.name, + Matched: criterionMatch, + }) + } + + // Apply the match function to combine all criteria results + return f.matchFunc(results), nil +} + +// matchProperties evaluates whether the given properties match the filter criteria. +// This is an internal method used by MatchMeta. +func (f *Filter) matchProperties(properties []property.Property) (bool, error) { + // If no filter criteria, everything matches + if len(f.criteria) == 0 { + return true, nil + } + + var searchMetadatas []property.SearchMetadata + for _, prop := range properties { + if prop.Type == property.TypeSearchMetadata { + sm, err := property.ParseOne[property.SearchMetadata](prop) + if err != nil { + return false, fmt.Errorf("failed to parse search metadata: %v", err) + } + searchMetadatas = append(searchMetadatas, sm) + } + } + + // If no search metadata, it doesn't match any filter + if len(searchMetadatas) == 0 { + return false, nil + } + + if len(searchMetadatas) > 1 { + return false, fmt.Errorf("multiple search metadata properties cannot be defined") + } + + return f.matchSearchMetadata(searchMetadatas[0]) +} + +// MatchMeta evaluates whether the given Meta object matches the filter criteria. +// It extracts the properties from the Meta's blob and applies the configured filter criteria. +// Returns true if the metadata matches according to the configured match function, false otherwise. +func (f *Filter) MatchMeta(m declcfg.Meta) (bool, error) { + // metaBlob represents the structure of a Meta blob for extracting properties + type propertiesBlob struct { + Properties []property.Property `json:"properties,omitempty"` + } + + // Parse the blob to extract properties + var blob propertiesBlob + if err := json.Unmarshal(m.Blob, &blob); err != nil { + return false, fmt.Errorf("failed to unmarshal meta blob: %v", err) + } + + return f.matchProperties(blob.Properties) +} + +// applyCriterion applies the filter criterion to the metadata based on the metadata's type. +// This is an internal helper function that handles the type-specific logic for matching. +func applyCriterion(metadata property.SearchMetadataItem, filter criterion) (bool, error) { + metadataValue, err := metadata.ExtractValue() + if err != nil { + return false, err + } + values, err := metadataValueAsSlice(metadataValue) + if err != nil { + return false, err + } + return filter.matchFunc(values, filter.values), nil +} + +// metadataValueAsSlice converts metadata values to a string slice for uniform processing. +// This is an internal helper function that normalizes different metadata types. +func metadataValueAsSlice(metadataValue any) ([]string, error) { + switch v := metadataValue.(type) { + case string: + return []string{v}, nil + case []string: + return v, nil + case map[string]bool: + var keys []string + for key, value := range v { + if value { + keys = append(keys, key) + } + } + return keys, nil + default: + return nil, fmt.Errorf("unsupported metadata value type: %T", metadataValue) + } +} diff --git a/alpha/declcfg/filter/filter_example_test.go b/alpha/declcfg/filter/filter_example_test.go new file mode 100644 index 000000000..8eea3bf0b --- /dev/null +++ b/alpha/declcfg/filter/filter_example_test.go @@ -0,0 +1,172 @@ +package filter_test + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/declcfg/filter" + "github.com/operator-framework/operator-registry/alpha/property" +) + +// Helper function to create a Meta with search metadata +func createMetaWithSearchMetadata(name string, searchMetadata []property.SearchMetadataItem) declcfg.Meta { + props := []property.Property{ + property.MustBuildPackage("test-package", "1.0.0"), + property.MustBuildSearchMetadata(searchMetadata), + } + + type metaBlob struct { + Properties []property.Property `json:"properties,omitempty"` + } + + blob := metaBlob{Properties: props} + blobBytes, _ := json.Marshal(blob) + + return declcfg.Meta{ + Name: name, + Blob: blobBytes, + } +} + +func ExampleFilter_MatchMeta() { + // Create some sample metas with search metadata + meta1 := createMetaWithSearchMetadata("database-operator.v1.0.0", []property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"database", "storage", "backup"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": true, "monitoring": true}}, + }) + + meta2 := createMetaWithSearchMetadata("web-server.v2.0.0", []property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "alpha"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"web", "http", "server"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"ssl": true, "compression": false}}, + }) + + // Example 1: Filter by maturity level + stableFilter := filter.New(filter.All).HasAny("maturity", "stable") + + matches1, err := stableFilter.MatchMeta(meta1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta1 matches stable filter: %t\n", matches1) + + matches2, err := stableFilter.MatchMeta(meta2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta2 matches stable filter: %t\n", matches2) + + // Example 2: Filter by keywords + databaseFilter := filter.New(filter.All).HasAny("keywords", "database") + + dbMatches1, err := databaseFilter.MatchMeta(meta1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta1 matches database filter: %t\n", dbMatches1) + + dbMatches2, err := databaseFilter.MatchMeta(meta2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta2 matches database filter: %t\n", dbMatches2) + + // Example 3: Filter by features + backupFeatureFilter := filter.New(filter.All).HasAny("features", "backup") + + backupMatches1, err := backupFeatureFilter.MatchMeta(meta1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta1 matches backup feature filter: %t\n", backupMatches1) + + backupMatches2, err := backupFeatureFilter.MatchMeta(meta2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta2 matches backup feature filter: %t\n", backupMatches2) + + // Example 4: Combined filters (All - must satisfy all conditions) + combinedFilter := filter.New(filter.All). + HasAny("maturity", "stable"). + HasAny("keywords", "database") + + combinedMatches1, err := combinedFilter.MatchMeta(meta1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta1 matches combined filter: %t\n", combinedMatches1) + + combinedMatches2, err := combinedFilter.MatchMeta(meta2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta2 matches combined filter: %t\n", combinedMatches2) + + // Example 5: Alternative filters (Any - satisfy any condition) + anyFilter := filter.New(filter.Any). + HasAny("maturity", "stable"). + HasAny("keywords", "web") + + anyMatches1, err := anyFilter.MatchMeta(meta1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta1 matches any filter: %t\n", anyMatches1) + + anyMatches2, err := anyFilter.MatchMeta(meta2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Meta2 matches any filter: %t\n", anyMatches2) + + // Output: + // Meta1 matches stable filter: true + // Meta2 matches stable filter: false + // Meta1 matches database filter: true + // Meta2 matches database filter: false + // Meta1 matches backup feature filter: true + // Meta2 matches backup feature filter: false + // Meta1 matches combined filter: true + // Meta2 matches combined filter: false + // Meta1 matches any filter: true + // Meta2 matches any filter: true +} + +func ExampleFilter_MatchMeta_customMatchFunc() { + // Create a meta with multiple search metadata + meta := createMetaWithSearchMetadata("complex-operator.v1.0.0", []property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"database", "storage"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": false, "monitoring": true}}, + }) + + // Custom match function that requires maturity to be stable but features are optional + customMatchFunc := func(results []filter.Result) bool { + maturityMatched := false + for _, result := range results { + if result.Name == "maturity" { + maturityMatched = result.Matched + } + } + return maturityMatched + } + + // Create filter with custom logic + f := filter.New(customMatchFunc). + HasAny("maturity", "stable"). + HasAny("features", "nonexistent") // This won't match, but that's OK with our custom logic + + matches, err := f.MatchMeta(meta) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Custom filter matches: %t\n", matches) + + // Output: + // Custom filter matches: true +} diff --git a/alpha/declcfg/filter/filter_test.go b/alpha/declcfg/filter/filter_test.go new file mode 100644 index 000000000..9a7ba6a9d --- /dev/null +++ b/alpha/declcfg/filter/filter_test.go @@ -0,0 +1,440 @@ +package filter + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/property" +) + +func TestNew(t *testing.T) { + filter := New(All) + assert.NotNil(t, filter) + assert.NotNil(t, filter.matchFunc) + assert.Empty(t, filter.criteria) +} + +func TestFilter_HasAny(t *testing.T) { + filter := New(All).HasAny("test", "value1", "value2") + require.Len(t, filter.criteria, 1) + assert.Equal(t, "test", filter.criteria[0].name) + assert.Equal(t, []string{"value1", "value2"}, filter.criteria[0].values) +} + +func TestFilter_HasAll(t *testing.T) { + filter := New(All).HasAll("test", "value1", "value2") + require.Len(t, filter.criteria, 1) + assert.Equal(t, "test", filter.criteria[0].name) + assert.Equal(t, []string{"value1", "value2"}, filter.criteria[0].values) +} + +// Helper function to create a Meta with search metadata +func createMetaWithSearchMetadata(searchMetadata []property.SearchMetadataItem) declcfg.Meta { + props := []property.Property{ + property.MustBuildPackage("test-package", "1.0.0"), + property.MustBuildSearchMetadata(searchMetadata), + } + + type metaBlob struct { + Properties []property.Property `json:"properties,omitempty"` + } + + blob := metaBlob{Properties: props} + blobBytes, _ := json.Marshal(blob) + + return declcfg.Meta{ + Blob: blobBytes, + } +} + +func TestAll(t *testing.T) { + tests := []struct { + name string + results []Result + expected bool + }{ + { + name: "all match", + results: []Result{ + {Name: "test1", Matched: true}, + {Name: "test2", Matched: true}, + }, + expected: true, + }, + { + name: "some don't match", + results: []Result{ + {Name: "test1", Matched: true}, + {Name: "test2", Matched: false}, + }, + expected: false, + }, + { + name: "none match", + results: []Result{ + {Name: "test1", Matched: false}, + {Name: "test2", Matched: false}, + }, + expected: false, + }, + { + name: "empty results", + results: []Result{}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := All(tt.results) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAny(t *testing.T) { + tests := []struct { + name string + results []Result + expected bool + }{ + { + name: "all match", + results: []Result{ + {Name: "test1", Matched: true}, + {Name: "test2", Matched: true}, + }, + expected: true, + }, + { + name: "some match", + results: []Result{ + {Name: "test1", Matched: true}, + {Name: "test2", Matched: false}, + }, + expected: true, + }, + { + name: "none match", + results: []Result{ + {Name: "test1", Matched: false}, + {Name: "test2", Matched: false}, + }, + expected: false, + }, + { + name: "empty results", + results: []Result{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Any(tt.results) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMatchMeta(t *testing.T) { + // Create test metas with different search metadata + meta1 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"database", "storage", "backup"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": true, "monitoring": false}}, + }) + + meta2 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "alpha"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"web", "http"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"ssl": true, "compression": false}}, + }) + + meta3 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"database", "cache"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": false, "monitoring": true}}, + }) + + tests := []struct { + name string + filter *Filter + meta declcfg.Meta + expected bool + }{ + { + name: "filter by stable maturity - meta1 matches", + filter: New(All).HasAny("maturity", "stable"), + meta: meta1, + expected: true, + }, + { + name: "filter by stable maturity - meta2 doesn't match", + filter: New(All).HasAny("maturity", "stable"), + meta: meta2, + expected: false, + }, + { + name: "filter by stable maturity - meta3 matches", + filter: New(All).HasAny("maturity", "stable"), + meta: meta3, + expected: true, + }, + { + name: "filter by keywords containing database - meta1 matches", + filter: New(All).HasAny("keywords", "database"), + meta: meta1, + expected: true, + }, + { + name: "filter by keywords containing database - meta2 doesn't match", + filter: New(All).HasAny("keywords", "database"), + meta: meta2, + expected: false, + }, + { + name: "filter by features having backup key - meta1 matches", + filter: New(All).HasAny("features", "backup"), + meta: meta1, + expected: true, + }, + { + name: "filter by features having backup key - meta3 doesn't match", + filter: New(All).HasAny("features", "backup"), + meta: meta3, + expected: false, + }, + { + name: "filter by keywords having both database and storage - meta1 matches", + filter: New(All).HasAll("keywords", "database", "storage"), + meta: meta1, + expected: true, + }, + { + name: "filter by keywords having both database and storage - meta3 doesn't match", + filter: New(All).HasAll("keywords", "database", "storage"), + meta: meta3, + expected: false, + }, + { + name: "multiple filters with All - stable AND database - meta1 matches", + filter: New(All). + HasAny("maturity", "stable"). + HasAny("keywords", "database"), + meta: meta1, + expected: true, + }, + { + name: "multiple filters with Any - alpha OR monitoring key - meta2 matches", + filter: New(Any). + HasAny("maturity", "alpha"). + HasAny("features", "monitoring"), + meta: meta2, + expected: true, + }, + { + name: "multiple filters with Any - alpha OR monitoring key - meta3 matches", + filter: New(Any). + HasAny("maturity", "alpha"). + HasAny("features", "monitoring"), + meta: meta3, + expected: true, + }, + { + name: "no matching criteria", + filter: New(All).HasAny("maturity", "beta"), + meta: meta1, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.filter.MatchMeta(tt.meta) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMatchMeta_NoSearchMetadata(t *testing.T) { + // Create meta without search metadata + props := []property.Property{ + property.MustBuildPackage("test-package", "1.0.0"), + } + + type metaBlob struct { + Properties []property.Property `json:"properties,omitempty"` + } + + blob := metaBlob{Properties: props} + blobBytes, _ := json.Marshal(blob) + + meta := declcfg.Meta{ + Blob: blobBytes, + } + + filter := New(All).HasAny("maturity", "stable") + + result, err := filter.MatchMeta(meta) + require.NoError(t, err) + + // Should return false since meta has no search metadata + assert.False(t, result) +} + +func TestMatchMeta_EmptyFilter(t *testing.T) { + meta := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + }) + + // Test with empty filter (no criteria) + filter := New(All) + result, err := filter.MatchMeta(meta) + require.NoError(t, err) + assert.True(t, result) // Empty filter should match all metas +} + +func TestChainedFilters(t *testing.T) { + meta := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "keywords", Type: property.SearchMetadataTypeListString, Value: []string{"database", "storage", "backup"}}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": true, "monitoring": false, "scaling": true}}, + }) + + // Test method chaining + filter := New(All). + HasAny("maturity", "stable", "alpha"). + HasAll("keywords", "database", "storage"). + HasAny("features", "backup", "monitoring") + + result, err := filter.MatchMeta(meta) + require.NoError(t, err) + assert.True(t, result) +} + +func TestCrossTypeFiltering(t *testing.T) { + // Test that the same filter name can work with different metadata types + stringMeta := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "category", Type: property.SearchMetadataTypeString, Value: "database"}, + }) + + listMeta := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "category", Type: property.SearchMetadataTypeListString, Value: []string{"database", "storage"}}, + }) + + mapMeta := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "category", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"database": true}}, + }) + + // Filter should work across all types + filter := New(All).HasAny("category", "database") + + stringResult, err := filter.MatchMeta(stringMeta) + require.NoError(t, err) + assert.True(t, stringResult) + + listResult, err := filter.MatchMeta(listMeta) + require.NoError(t, err) + assert.True(t, listResult) + + mapResult, err := filter.MatchMeta(mapMeta) + require.NoError(t, err) + assert.True(t, mapResult) +} + +func TestCustomMatchFunction(t *testing.T) { + // Test a custom match function that considers filter names + customMatchFunc := func(results []Result) bool { + // Custom logic: require maturity to match, but features are optional + maturityMatched := false + + for _, result := range results { + switch result.Name { + case "maturity": + maturityMatched = result.Matched + case "features": + // Features are optional, we don't need to track this + } + } + + // Must have maturity match, features are a bonus but not required + return maturityMatched + } + + meta1 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": true}}, + }) + + meta2 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "alpha"}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"backup": true}}, + }) + + meta3 := createMetaWithSearchMetadata([]property.SearchMetadataItem{ + {Name: "maturity", Type: property.SearchMetadataTypeString, Value: "stable"}, + {Name: "features", Type: property.SearchMetadataTypeMapStringBoolean, Value: map[string]bool{"monitoring": true}}, + }) + + // Create filter with custom match function + filter := New(customMatchFunc). + HasAny("maturity", "stable"). + HasAny("features", "nonexistent") // This won't match, but that's OK with our custom logic + + result1, err := filter.MatchMeta(meta1) + require.NoError(t, err) + assert.True(t, result1) // Should match (stable maturity) + + result2, err := filter.MatchMeta(meta2) + require.NoError(t, err) + assert.False(t, result2) // Should not match (alpha maturity) + + result3, err := filter.MatchMeta(meta3) + require.NoError(t, err) + assert.True(t, result3) // Should match (stable maturity) +} + +func TestFilter_MatchMeta_NoMatchFunc(t *testing.T) { + // Create a filter without a match function - this will cause a panic when matchFunc is called + filter := &Filter{ + criteria: []criterion{ + {name: "test", values: []string{"value"}, matchFunc: anyValue}, + }, + matchFunc: nil, // Explicitly set to nil + } + + // Create a meta with search metadata that will match + searchMetadata := []property.SearchMetadataItem{ + {Name: "test", Type: property.SearchMetadataTypeString, Value: "value"}, + } + props := []property.Property{ + property.MustBuildPackage("test-package", "1.0.0"), + property.MustBuildSearchMetadata(searchMetadata), + } + blobData := map[string]interface{}{ + "schema": "olm.bundle", + "name": "test-bundle", + "package": "test-package", + "properties": props, + } + blob, err := json.Marshal(blobData) + require.NoError(t, err) + + meta := declcfg.Meta{ + Schema: "olm.bundle", + Name: "test-bundle", + Package: "test-package", + Blob: blob, + } + + // This should panic because matchFunc is nil + assert.Panics(t, func() { + _, _ = filter.MatchMeta(meta) + }) +} diff --git a/alpha/declcfg/filter/tryit/main.go b/alpha/declcfg/filter/tryit/main.go new file mode 100644 index 000000000..8c4c0bf34 --- /dev/null +++ b/alpha/declcfg/filter/tryit/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/operator-framework/operator-registry/alpha/declcfg" + "github.com/operator-framework/operator-registry/alpha/declcfg/filter" + "github.com/operator-framework/operator-registry/alpha/property" +) + +func main() { + // Create filter metadata property + searchMetadata := property.SearchMetadata{ + {Name: "Maturity", Type: "String", Value: "stable"}, + {Name: "Keywords", Type: "ListString", Value: []string{"database", "storage", "sql"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"backup": true, "monitoring": false}}, + } + + // Create a Meta object with properties + properties := []property.Property{ + property.MustBuildSearchMetadata(searchMetadata), + // ... other properties like packages, GVKs, etc. + } + + // Create the Meta blob + metaBlob := struct { + Properties []property.Property `json:"properties,omitempty"` + }{ + Properties: properties, + } + + blobBytes, _ := json.Marshal(metaBlob) + + meta := declcfg.Meta{ + Schema: "olm.bundle", + Name: "my-operator.v1.0.0", + Blob: json.RawMessage(blobBytes), + } + + // Create and use filter + f := filter.New(filter.All). // All individual filter criteria must match + HasAny("Maturity", "stable", "alpha"). // The "Maturity" filter must match "stable" or "alpha" + HasAll("Keywords", "database", "sql") // The "Keywords" filter must match "database" and "sql" + + matches, err := f.MatchMeta(meta) + if err != nil { + panic(err) + } + + fmt.Printf("Meta matches filter: %t\n", matches) // Output: Meta matches filter: true +} diff --git a/alpha/property/property.go b/alpha/property/property.go index 6fb792dda..e339b03c1 100644 --- a/alpha/property/property.go +++ b/alpha/property/property.go @@ -35,6 +35,111 @@ func (p Property) String() string { return fmt.Sprintf("type: %q, value: %q", p.Type, p.Value) } +// ExtractValue extracts and validates the value from a SearchMetadataItem. +// It returns the properly typed value (string, []string, or map[string]bool) as an interface{}. +// The returned value is guaranteed to be valid according to the item's Type field. +func (item SearchMetadataItem) ExtractValue() (any, error) { + switch item.Type { + case SearchMetadataTypeString: + return item.extractStringValue() + case SearchMetadataTypeListString: + return item.extractListStringValue() + case SearchMetadataTypeMapStringBoolean: + return item.extractMapStringBooleanValue() + default: + return nil, fmt.Errorf("unsupported type: %s", item.Type) + } +} + +// extractStringValue extracts and validates a string value from a SearchMetadataItem. +// This is an internal method used by ExtractValue. +func (item SearchMetadataItem) extractStringValue() (string, error) { + str, ok := item.Value.(string) + if !ok { + return "", fmt.Errorf("type is 'String' but value is not a string: %T", item.Value) + } + if len(str) == 0 { + return "", errors.New("string value must have length >= 1") + } + return str, nil +} + +// extractListStringValue extracts and validates a []string value from a SearchMetadataItem. +// This is an internal method used by ExtractValue. +func (item SearchMetadataItem) extractListStringValue() ([]string, error) { + switch v := item.Value.(type) { + case []string: + for i, str := range v { + if len(str) == 0 { + return nil, fmt.Errorf("ListString item[%d] must have length >= 1", i) + } + } + return v, nil + case []interface{}: + result := make([]string, len(v)) + for i, val := range v { + if str, ok := val.(string); !ok { + return nil, fmt.Errorf("ListString item[%d] is not a string: %T", i, val) + } else if len(str) == 0 { + return nil, fmt.Errorf("ListString item[%d] must have length >= 1", i) + } else { + result[i] = str + } + } + return result, nil + default: + return nil, fmt.Errorf("type is 'ListString' but value is not a string list: %T", item.Value) + } +} + +// extractMapStringBooleanValue extracts and validates a map[string]bool value from a SearchMetadataItem. +// This is an internal method used by ExtractValue. +func (item SearchMetadataItem) extractMapStringBooleanValue() (map[string]bool, error) { + switch v := item.Value.(type) { + case map[string]bool: + for key := range v { + if len(key) == 0 { + return nil, errors.New("MapStringBoolean keys must have length >= 1") + } + } + return v, nil + case map[string]interface{}: + result := make(map[string]bool) + for key, val := range v { + if len(key) == 0 { + return nil, errors.New("MapStringBoolean keys must have length >= 1") + } + if boolVal, ok := val.(bool); !ok { + return nil, fmt.Errorf("MapStringBoolean value for key '%s' is not a boolean: %T", key, val) + } else { + result[key] = boolVal + } + } + return result, nil + default: + return nil, fmt.Errorf("type is 'MapStringBoolean' but value is not a string-to-boolean map: %T", item.Value) + } +} + +// validateSearchMetadataItem validates a single SearchMetadataItem. +// This is an internal helper function used during JSON unmarshaling. +func validateSearchMetadataItem(item SearchMetadataItem) error { + if item.Name == "" { + return errors.New("name must be set") + } + if item.Type == "" { + return errors.New("type must be set") + } + if item.Value == nil { + return errors.New("value must be set") + } + + if _, err := item.ExtractValue(); err != nil { + return err + } + return nil +} + type Package struct { PackageName string `json:"packageName"` Version string `json:"version"` @@ -88,6 +193,46 @@ type CSVMetadata struct { Provider v1alpha1.AppLink `json:"provider,omitempty"` } +// SearchMetadataItem represents a single search metadata item with a name, type, and value. +// Supported types are defined by the SearchMetadataType* constants. +type SearchMetadataItem struct { + Name string `json:"name"` // The name/key of the search metadata + Type string `json:"type"` // The type of the value (String, ListString, MapStringBoolean) + Value interface{} `json:"value"` // The actual value, validated according to Type +} + +// SearchMetadata represents a collection of search metadata items. +// It validates that all items are valid and that there are no duplicate names. +type SearchMetadata []SearchMetadataItem + +// UnmarshalJSON implements custom JSON unmarshaling for SearchMetadata. +// It validates each item and ensures there are no duplicate names. +func (sm *SearchMetadata) UnmarshalJSON(data []byte) error { + // First unmarshal into a slice of SearchMetadataItem + var items []SearchMetadataItem + if err := json.Unmarshal(data, &items); err != nil { + return err + } + + // Validate each item and check for duplicate names + namesSeen := make(map[string]bool) + for i, item := range items { + if err := validateSearchMetadataItem(item); err != nil { + return fmt.Errorf("item[%d]: %v", i, err) + } + + // Check for duplicate names + if namesSeen[item.Name] { + return fmt.Errorf("item[%d]: duplicate name '%s'", i, item.Name) + } + namesSeen[item.Name] = true + } + + // Set the validated items + *sm = SearchMetadata(items) + return nil +} + type Properties struct { Packages []Package `hash:"set"` PackagesRequired []PackageRequired `hash:"set"` @@ -96,6 +241,7 @@ type Properties struct { BundleObjects []BundleObject `hash:"set"` Channels []Channel `hash:"set"` CSVMetadatas []CSVMetadata `hash:"set"` + SearchMetadatas []SearchMetadata `hash:"set"` Others []Property `hash:"set"` } @@ -107,60 +253,52 @@ const ( TypeGVKRequired = "olm.gvk.required" TypeBundleObject = "olm.bundle.object" TypeCSVMetadata = "olm.csv.metadata" + TypeSearchMetadata = "olm.search.metadata" TypeConstraint = "olm.constraint" TypeChannel = "olm.channel" ) +// Search metadata item type constants define the supported types for SearchMetadataItem values. +const ( + SearchMetadataTypeString = "String" + SearchMetadataTypeListString = "ListString" + SearchMetadataTypeMapStringBoolean = "MapStringBoolean" +) + +// appendParsed is a generic helper function that parses a property and appends it to a slice. +// This is an internal helper used by the Parse function to reduce code duplication. +func appendParsed[T any](slice *[]T, prop Property) error { + parsed, err := ParseOne[T](prop) + if err != nil { + return err + } + *slice = append(*slice, parsed) + return nil +} + func Parse(in []Property) (*Properties, error) { var out Properties + + // Map of property types to their parsing functions that directly append to output slices + parsers := map[string]func(Property) error{ + TypePackage: func(p Property) error { return appendParsed(&out.Packages, p) }, + TypePackageRequired: func(p Property) error { return appendParsed(&out.PackagesRequired, p) }, + TypeGVK: func(p Property) error { return appendParsed(&out.GVKs, p) }, + TypeGVKRequired: func(p Property) error { return appendParsed(&out.GVKsRequired, p) }, + TypeBundleObject: func(p Property) error { return appendParsed(&out.BundleObjects, p) }, + TypeCSVMetadata: func(p Property) error { return appendParsed(&out.CSVMetadatas, p) }, + TypeSearchMetadata: func(p Property) error { return appendParsed(&out.SearchMetadatas, p) }, + TypeChannel: func(p Property) error { return appendParsed(&out.Channels, p) }, + } + + // Parse each property using the appropriate parser for i, prop := range in { - switch prop.Type { - case TypePackage: - var p Package - if err := json.Unmarshal(prop.Value, &p); err != nil { + if parser, exists := parsers[prop.Type]; exists { + if err := parser(prop); err != nil { return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} } - out.Packages = append(out.Packages, p) - case TypePackageRequired: - var p PackageRequired - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.PackagesRequired = append(out.PackagesRequired, p) - case TypeGVK: - var p GVK - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.GVKs = append(out.GVKs, p) - case TypeGVKRequired: - var p GVKRequired - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.GVKsRequired = append(out.GVKsRequired, p) - case TypeBundleObject: - var p BundleObject - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.BundleObjects = append(out.BundleObjects, p) - case TypeCSVMetadata: - var p CSVMetadata - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.CSVMetadatas = append(out.CSVMetadatas, p) - // NOTICE: The Channel properties are for internal use only. - // DO NOT use it for any public-facing functionalities. - // This API is in alpha stage and it is subject to change. - case TypeChannel: - var p Channel - if err := json.Unmarshal(prop.Value, &p); err != nil { - return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} - } - out.Channels = append(out.Channels, p) - default: + } else { + // For unknown types, use direct unmarshaling to preserve existing behavior var p json.RawMessage if err := json.Unmarshal(prop.Value, &p); err != nil { return nil, ParseError{Idx: i, Typ: prop.Type, Err: err} @@ -168,9 +306,45 @@ func Parse(in []Property) (*Properties, error) { out.Others = append(out.Others, prop) } } + return &out, nil } +// ParseOne parses a single property into the specified type T. +// It validates that the property's Type field matches what the scheme expects for type T, +// ensuring type safety between the property metadata and the generic type parameter. +func ParseOne[T any](p Property) (T, error) { + var zero T + + // Get the type of T + targetType := reflect.TypeOf((*T)(nil)).Elem() + + // Check if T is a pointer type, if so get the element type + if targetType.Kind() == reflect.Ptr { + targetType = targetType.Elem() + } + + // Look up the expected property type for this Go type + expectedPropertyType, ok := scheme[reflect.PointerTo(targetType)] + if !ok { + return zero, fmt.Errorf("type %s is not registered in the scheme", targetType) + } + + // Verify the property type matches what we expect + if p.Type != expectedPropertyType { + return zero, fmt.Errorf("property type %q does not match expected type %q for %s", p.Type, expectedPropertyType, targetType) + } + + // Unmarshal the property value into the target type + // Any validation will happen automatically via custom UnmarshalJSON methods + var result T + if err := json.Unmarshal(p.Value, &result); err != nil { + return zero, fmt.Errorf("failed to unmarshal property value: %v", err) + } + + return result, nil +} + func Deduplicate(in []Property) []Property { type key struct { typ string @@ -279,6 +453,12 @@ func MustBuildCSVMetadata(csv v1alpha1.ClusterServiceVersion) Property { }) } +// MustBuildSearchMetadata creates a search metadata property from a SearchMetadata. +// It panics if the items are invalid or if there are duplicate names. +func MustBuildSearchMetadata(searchMetadata SearchMetadata) Property { + return MustBuild(&searchMetadata) +} + // NOTICE: The Channel properties are for internal use only. // // DO NOT use it for any public-facing functionalities. diff --git a/alpha/property/property_test.go b/alpha/property/property_test.go index 171cec7a0..c61efb59c 100644 --- a/alpha/property/property_test.go +++ b/alpha/property/property_test.go @@ -2,6 +2,8 @@ package property import ( "encoding/json" + "reflect" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -108,6 +110,13 @@ func TestParse(t *testing.T) { }, assertion: assert.Error, }, + { + name: "Error/InvalidSearchMetadata", + input: []Property{ + {Type: TypeSearchMetadata, Value: json.RawMessage(`{`)}, + }, + assertion: assert.Error, + }, { name: "Error/InvalidOther", input: []Property{ @@ -127,6 +136,11 @@ func TestParse(t *testing.T) { MustBuildGVKRequired("other", "v2", "Kind3"), MustBuildGVKRequired("other", "v2", "Kind4"), MustBuildBundleObject([]byte("testdata2")), + MustBuildSearchMetadata(SearchMetadata{ + {Name: "Maturity", Type: "String", Value: "Stable"}, + {Name: "Keywords", Type: "ListString", Value: []string{"database", "nosql"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"Feature1": true, "Feature2": false}}, + }), {Type: "otherType1", Value: json.RawMessage(`{"v":"otherValue1"}`)}, {Type: "otherType2", Value: json.RawMessage(`["otherValue2"]`)}, }, @@ -150,6 +164,13 @@ func TestParse(t *testing.T) { BundleObjects: []BundleObject{ {Data: []byte("testdata2")}, }, + SearchMetadatas: []SearchMetadata{ + { + {Name: "Maturity", Type: "String", Value: "Stable"}, + {Name: "Keywords", Type: "ListString", Value: []interface{}{"database", "nosql"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]interface{}{"Feature1": true, "Feature2": false}}, + }, + }, Others: []Property{ {Type: "otherType1", Value: json.RawMessage(`{"v":"otherValue1"}`)}, {Type: "otherType2", Value: json.RawMessage(`["otherValue2"]`)}, @@ -234,6 +255,20 @@ func TestBuild(t *testing.T) { assertion: require.NoError, expectedProperty: propPtr(MustBuildBundleObject([]byte("test"))), }, + { + name: "Success/SearchMetadata", + input: &SearchMetadata{ + {Name: "Maturity", Type: "String", Value: "Stable"}, + {Name: "Keywords", Type: "ListString", Value: []string{"database", "nosql"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"Feature1": true, "Feature2": false}}, + }, + assertion: require.NoError, + expectedProperty: propPtr(MustBuildSearchMetadata(SearchMetadata{ + {Name: "Maturity", Type: "String", Value: "Stable"}, + {Name: "Keywords", Type: "ListString", Value: []string{"database", "nosql"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"Feature1": true, "Feature2": false}}, + })), + }, { name: "Success/Property", input: &Property{Type: "foo", Value: json.RawMessage(`"bar"`)}, @@ -273,3 +308,545 @@ func TestMustBuild(t *testing.T) { func propPtr(in Property) *Property { return &in } + +func TestSearchMetadata(t *testing.T) { + t.Run("Success/AllSupportedTypes", func(t *testing.T) { + items := []SearchMetadataItem{ + {Name: "Maturity", Type: "String", Value: "Stable"}, + {Name: "Keywords", Type: "ListString", Value: []string{"database", "nosql", "operator"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"Feature1": true, "Feature2": false, "Feature3": true}}, + } + + prop := MustBuildSearchMetadata(SearchMetadata(items)) + require.Equal(t, TypeSearchMetadata, prop.Type) + + // Parse back and verify + props, err := Parse([]Property{prop}) + require.NoError(t, err) + require.Len(t, props.SearchMetadatas, 1) + + searchMetadata := props.SearchMetadatas[0] + require.Len(t, searchMetadata, 3) + + // Verify each item exists (order might differ due to JSON marshaling) + itemMap := make(map[string]SearchMetadataItem) + for _, item := range searchMetadata { + itemMap[item.Name] = item + } + + // String type + maturity, ok := itemMap["Maturity"] + require.True(t, ok) + assert.Equal(t, "String", maturity.Type) + assert.Equal(t, "Stable", maturity.Value) + + // ListString type (JSON unmarshaling converts []string to []interface{}) + keywords, ok := itemMap["Keywords"] + require.True(t, ok) + assert.Equal(t, "ListString", keywords.Type) + keywordsList, ok := keywords.Value.([]interface{}) + require.True(t, ok) + assert.Len(t, keywordsList, 3) + assert.Contains(t, keywordsList, "database") + assert.Contains(t, keywordsList, "nosql") + assert.Contains(t, keywordsList, "operator") + + // MapStringBoolean type (JSON unmarshaling converts map[string]bool to map[string]interface{}) + features, ok := itemMap["Features"] + require.True(t, ok) + assert.Equal(t, "MapStringBoolean", features.Type) + featuresMap, ok := features.Value.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, featuresMap["Feature1"]) + assert.Equal(t, false, featuresMap["Feature2"]) + assert.Equal(t, true, featuresMap["Feature3"]) + }) + + t.Run("Success/Build", func(t *testing.T) { + searchMetadata := SearchMetadata{ + {Name: "TestField", Type: "String", Value: "TestValue"}, + } + + prop, err := Build(&searchMetadata) + require.NoError(t, err) + assert.Equal(t, TypeSearchMetadata, prop.Type) + + // Verify it can be parsed back + props, err := Parse([]Property{*prop}) + require.NoError(t, err) + require.Len(t, props.SearchMetadatas, 1) + assert.Equal(t, "TestField", props.SearchMetadatas[0][0].Name) + assert.Equal(t, "String", props.SearchMetadatas[0][0].Type) + assert.Equal(t, "TestValue", props.SearchMetadatas[0][0].Value) + }) +} + +func TestSearchMetadataValidation(t *testing.T) { + createSearchMetadataProperty := func(items []SearchMetadataItem) Property { + return MustBuildSearchMetadata(SearchMetadata(items)) + } + + t.Run("Success/ValidItems", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "ValidString", Type: "String", Value: "valid"}, + {Name: "ValidListString", Type: "ListString", Value: []string{"item1", "item2"}}, + {Name: "ValidMapStringBoolean", Type: "MapStringBoolean", Value: map[string]bool{"key1": true, "key2": false}}, + }) + + _, err := Parse([]Property{prop}) + require.NoError(t, err) + }) + + t.Run("Error/EmptyName", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "", Type: "String", Value: "valid"}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name must be set") + }) + + t.Run("Error/EmptyType", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "ValidName", Type: "", Value: "valid"}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type must be set") + }) + + t.Run("Error/NilStringValue", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "ValidName", Type: "String", Value: nil}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "value must be set") + }) + + t.Run("Error/EmptyString", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "EmptyString", Type: "String", Value: ""}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "string value must have length >= 1") + }) + + t.Run("Error/WrongTypeForString", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "WrongType", Type: "String", Value: 123}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type is 'String' but value is not a string") + }) + + t.Run("Error/EmptyStringInList", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "EmptyInList", Type: "ListString", Value: []string{"valid", "", "alsovalid"}}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "ListString item[1] must have length >= 1") + }) + + t.Run("Error/NonStringInList", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "NonStringInList", Type: "ListString", Value: []interface{}{"valid", 123, "alsovalid"}}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "ListString item[1] is not a string") + }) + + t.Run("Error/EmptyKeyInMap", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "EmptyKey", Type: "MapStringBoolean", Value: map[string]bool{"validkey": true, "": false}}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "MapStringBoolean keys must have length >= 1") + }) + + t.Run("Error/NonBooleanInMap", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "NonBooleanValue", Type: "MapStringBoolean", Value: map[string]interface{}{"key1": true, "key2": "false"}}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "MapStringBoolean value for key 'key2' is not a boolean") + }) + + t.Run("Error/UnsupportedType", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "UnsupportedType", Type: "UnknownType", Value: "value"}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported type: UnknownType") + }) + + t.Run("Error/DuplicateNames", func(t *testing.T) { + prop := createSearchMetadataProperty([]SearchMetadataItem{ + {Name: "DuplicateName", Type: "String", Value: "value1"}, + {Name: "DuplicateName", Type: "ListString", Value: []string{"value2"}}, + }) + + _, err := Parse([]Property{prop}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate name 'DuplicateName'") + }) +} + +func TestParseOne(t *testing.T) { + t.Run("Success/Package", func(t *testing.T) { + prop := MustBuildPackage("test-package", "1.0.0") + + result, err := ParseOne[Package](prop) + require.NoError(t, err) + assert.Equal(t, "test-package", result.PackageName) + assert.Equal(t, "1.0.0", result.Version) + }) + + t.Run("Success/PackageRequired", func(t *testing.T) { + prop := MustBuildPackageRequired("test-package", ">=1.0.0") + + result, err := ParseOne[PackageRequired](prop) + require.NoError(t, err) + assert.Equal(t, "test-package", result.PackageName) + assert.Equal(t, ">=1.0.0", result.VersionRange) + }) + + t.Run("Success/GVK", func(t *testing.T) { + prop := MustBuildGVK("test.io", "v1", "TestKind") + + result, err := ParseOne[GVK](prop) + require.NoError(t, err) + assert.Equal(t, "test.io", result.Group) + assert.Equal(t, "v1", result.Version) + assert.Equal(t, "TestKind", result.Kind) + }) + + t.Run("Success/GVKRequired", func(t *testing.T) { + prop := MustBuildGVKRequired("test.io", "v1", "TestKind") + + result, err := ParseOne[GVKRequired](prop) + require.NoError(t, err) + assert.Equal(t, "test.io", result.Group) + assert.Equal(t, "v1", result.Version) + assert.Equal(t, "TestKind", result.Kind) + }) + + t.Run("Success/BundleObject", func(t *testing.T) { + testData := []byte("test bundle data") + prop := MustBuildBundleObject(testData) + + result, err := ParseOne[BundleObject](prop) + require.NoError(t, err) + assert.Equal(t, testData, result.Data) + }) + + t.Run("Success/SearchMetadata", func(t *testing.T) { + items := []SearchMetadataItem{ + {Name: "Category", Type: "String", Value: "Database"}, + {Name: "Keywords", Type: "ListString", Value: []string{"sql", "database"}}, + {Name: "Features", Type: "MapStringBoolean", Value: map[string]bool{"high-availability": true, "backup": false}}, + } + prop := MustBuildSearchMetadata(SearchMetadata(items)) + + result, err := ParseOne[SearchMetadata](prop) + require.NoError(t, err) + require.Len(t, result, 3) + + // Create a map for easier assertion + itemMap := make(map[string]SearchMetadataItem) + for _, item := range result { + itemMap[item.Name] = item + } + + category, ok := itemMap["Category"] + require.True(t, ok) + assert.Equal(t, "String", category.Type) + assert.Equal(t, "Database", category.Value) + }) + + t.Run("Error/UnregisteredType", func(t *testing.T) { + // Create a property with a custom unregistered type + prop := Property{Type: "custom.unregistered", Value: json.RawMessage(`{}`)} + + type UnregisteredType struct { + Field string `json:"field"` + } + + _, err := ParseOne[UnregisteredType](prop) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not registered in the scheme") + }) + + t.Run("Error/TypeMismatch", func(t *testing.T) { + // Try to parse a Package property as a GVK + prop := MustBuildPackage("test-package", "1.0.0") + + _, err := ParseOne[GVK](prop) + require.Error(t, err) + assert.Contains(t, err.Error(), "property type \"olm.package\" does not match expected type \"olm.gvk\"") + }) + + t.Run("Error/InvalidJSON", func(t *testing.T) { + // Create a property with invalid JSON + prop := Property{Type: TypePackage, Value: json.RawMessage(`{invalid json`)} + + _, err := ParseOne[Package](prop) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to unmarshal property value") + }) + + t.Run("Error/InvalidSearchMetadata", func(t *testing.T) { + // Create a SearchMetadata property with invalid item + invalidItems := []SearchMetadataItem{ + {Name: "", Type: "String", Value: "invalid"}, // Empty name should fail validation + } + + // Build the property manually to bypass MustBuildSearchMetadata validation + jsonBytes, err := json.Marshal(invalidItems) + require.NoError(t, err) + + prop := Property{Type: TypeSearchMetadata, Value: json.RawMessage(jsonBytes)} + + _, err = ParseOne[SearchMetadata](prop) + require.Error(t, err) + assert.Contains(t, err.Error(), "name must be set") + }) +} + +func TestSearchMetadataItem_ExtractValue_String(t *testing.T) { + tests := []struct { + name string + item SearchMetadataItem + want string + wantErr bool + }{ + { + name: "valid string", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeString, + Value: "stable", + }, + want: "stable", + wantErr: false, + }, + { + name: "not a string", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeString, + Value: 123, + }, + want: "", + wantErr: true, + }, + { + name: "empty string", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeString, + Value: "", + }, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.item.ExtractValue() + if (err != nil) != tt.wantErr { + t.Errorf("ExtractValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if str, ok := got.(string); !ok || str != tt.want { + t.Errorf("ExtractValue() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSearchMetadataItem_ExtractValue_ListString(t *testing.T) { + tests := []struct { + name string + item SearchMetadataItem + want []string + wantErr bool + }{ + { + name: "valid string slice", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeListString, + Value: []string{"a", "b", "c"}, + }, + want: []string{"a", "b", "c"}, + wantErr: false, + }, + { + name: "valid interface slice", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeListString, + Value: []interface{}{"a", "b", "c"}, + }, + want: []string{"a", "b", "c"}, + wantErr: false, + }, + { + name: "empty string in slice", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeListString, + Value: []string{"a", "", "c"}, + }, + want: nil, + wantErr: true, + }, + { + name: "non-string in interface slice", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeListString, + Value: []interface{}{"a", 123, "c"}, + }, + want: nil, + wantErr: true, + }, + { + name: "not a slice", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeListString, + Value: "not a slice", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.item.ExtractValue() + if (err != nil) != tt.wantErr { + t.Errorf("ExtractValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if slice, ok := got.([]string); !ok || !reflect.DeepEqual(slice, tt.want) { + t.Errorf("ExtractValue() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSearchMetadataItem_ExtractValue_MapStringBoolean(t *testing.T) { + tests := []struct { + name string + item SearchMetadataItem + want map[string]bool + wantErr bool + }{ + { + name: "valid map[string]bool", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeMapStringBoolean, + Value: map[string]bool{"a": true, "b": false}, + }, + want: map[string]bool{"a": true, "b": false}, + wantErr: false, + }, + { + name: "valid map[string]interface{}", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeMapStringBoolean, + Value: map[string]interface{}{"a": true, "b": false}, + }, + want: map[string]bool{"a": true, "b": false}, + wantErr: false, + }, + { + name: "empty key", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeMapStringBoolean, + Value: map[string]bool{"": true, "b": false}, + }, + want: nil, + wantErr: true, + }, + { + name: "non-boolean value in interface map", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeMapStringBoolean, + Value: map[string]interface{}{"a": true, "b": "not a bool"}, + }, + want: nil, + wantErr: true, + }, + { + name: "not a map", + item: SearchMetadataItem{ + Name: "test", + Type: SearchMetadataTypeMapStringBoolean, + Value: "not a map", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.item.ExtractValue() + if (err != nil) != tt.wantErr { + t.Errorf("ExtractValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if m, ok := got.(map[string]bool); !ok || !reflect.DeepEqual(m, tt.want) { + t.Errorf("ExtractValue() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestSearchMetadataItem_ExtractValue_UnsupportedType(t *testing.T) { + item := SearchMetadataItem{ + Name: "test", + Type: "unsupported", + Value: "value", + } + + _, err := item.ExtractValue() + if err == nil { + t.Error("ExtractValue() expected error for unsupported type, got nil") + } + if !strings.Contains(err.Error(), "unsupported type") { + t.Errorf("ExtractValue() error = %v, want error containing 'unsupported type'", err) + } +} diff --git a/alpha/property/scheme.go b/alpha/property/scheme.go index ab176856f..64cec6e97 100644 --- a/alpha/property/scheme.go +++ b/alpha/property/scheme.go @@ -16,7 +16,8 @@ func init() { // NOTICE: The Channel properties are for internal use only. // DO NOT use it for any public-facing functionalities. // This API is in alpha stage and it is subject to change. - reflect.TypeOf(&Channel{}): TypeChannel, + reflect.TypeOf(&Channel{}): TypeChannel, + reflect.TypeOf(&SearchMetadata{}): TypeSearchMetadata, } }