diff --git a/antlr/FeatureSearch.g4 b/antlr/FeatureSearch.g4 index 643c5be8f..ba97f9fa1 100644 --- a/antlr/FeatureSearch.g4 +++ b/antlr/FeatureSearch.g4 @@ -29,6 +29,7 @@ available_date_term: 'available_date' COLON BROWSER_NAME COLON (date_range_query); // In the future support other operators by doing something like (date_operator_query | date_range_query) baseline_date_term: 'baseline_date' COLON (date_range_query); +bcd_term: 'bcd' COLON ANY_VALUE; name_term: 'name' COLON ANY_VALUE; group_term: 'group' COLON ANY_VALUE; snapshot_term: 'snapshot' COLON ANY_VALUE; @@ -38,6 +39,7 @@ term: | available_on_term | baseline_status_term | baseline_date_term + | bcd_term | group_term | id_term | snapshot_term diff --git a/antlr/FeatureSearch.md b/antlr/FeatureSearch.md index ebc1fdd30..4a1836334 100644 --- a/antlr/FeatureSearch.md +++ b/antlr/FeatureSearch.md @@ -60,6 +60,7 @@ This query language enables you to construct flexible searches to find features - `name:"Dark Mode"` - Find features named "Dark Mode" (including spaces). - `baseline_date:2023-01-01..2023-12-31` - Searches for all features that reached baseline in 2023. - `group:css` - Searches for features that belong to the `css` group and any groups that are descendants of that group. +- `bcd:ToggleEvent` - Searches for features associated with the Browser Compatibility Data key `ToggleEvent` - `snapshot:ecmascript-5` - Searches for features that belong to the `ecmascript-5` snapshot. - `id:css` - Searches for a feature whose feature identifier (featurekey) is `css`. diff --git a/frontend/src/static/js/utils/constants.ts b/frontend/src/static/js/utils/constants.ts index b1b5f0167..dcb767668 100644 --- a/frontend/src/static/js/utils/constants.ts +++ b/frontend/src/static/js/utils/constants.ts @@ -159,6 +159,10 @@ export const VOCABULARY = [ name: 'baseline_status:widely', doc: 'Features in baseline and widely available', }, + { + name: 'bcd:', + doc: 'Features linked to MDN’s Browser Compatibility Data keys. E.g., bcd:ToggleEvent', + }, { name: 'group:', doc: 'Features in a group or its descendants. E.g., group:css', diff --git a/infra/storage/spanner/migrations/000016.sql b/infra/storage/spanner/migrations/000016.sql new file mode 100644 index 000000000..eea4444da --- /dev/null +++ b/infra/storage/spanner/migrations/000016.sql @@ -0,0 +1,25 @@ +-- Copyright 2025 Google LLC +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +-- WebFeatureBrowserCompatFeatures stores the compat_features list (e.g. "html.elements.address") +-- for each WebFeature. Multiple compat features may exist per feature. +CREATE TABLE IF NOT EXISTS WebFeatureBrowserCompatFeatures ( + ID STRING(36) NOT NULL, -- same name and type as parent + CompatFeature STRING(255) NOT NULL, + FOREIGN KEY (ID) REFERENCES WebFeatures(ID) +) PRIMARY KEY (ID, CompatFeature) +, INTERLEAVE IN PARENT WebFeatures ON DELETE CASCADE; + +-- Index to accelerate searches by CompatFeature +CREATE INDEX IDX_CompatFeature ON WebFeatureBrowserCompatFeatures(CompatFeature); diff --git a/lib/gcpspanner/feature_browser_compat_features.go b/lib/gcpspanner/feature_browser_compat_features.go new file mode 100644 index 000000000..43faf1082 --- /dev/null +++ b/lib/gcpspanner/feature_browser_compat_features.go @@ -0,0 +1,38 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpspanner + +import ( + "context" + + "cloud.google.com/go/spanner" +) + +func (c *Client) UpsertBrowserCompatFeatures(ctx context.Context, featureID string, compatFeatures []string) error { + // Create a delete mutation for the specified KeyRange + del := spanner.Delete("WebFeatureBrowserCompatFeatures", spanner.Key{featureID}.AsPrefix()) + + // Then, insert new ones + muts := make([]*spanner.Mutation, 0, len(compatFeatures)+1) + muts = append(muts, del) + for _, compat := range compatFeatures { + muts = append(muts, spanner.InsertOrUpdate("WebFeatureBrowserCompatFeatures", []string{ + "ID", "CompatFeature", + }, []interface{}{featureID, compat})) + } + _, err := c.Apply(ctx, muts) + + return err +} diff --git a/lib/gcpspanner/feature_browser_compat_features_test.go b/lib/gcpspanner/feature_browser_compat_features_test.go new file mode 100644 index 000000000..f5a56df1c --- /dev/null +++ b/lib/gcpspanner/feature_browser_compat_features_test.go @@ -0,0 +1,93 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gcpspanner + +import ( + "context" + "errors" + "slices" + "testing" + + "cloud.google.com/go/spanner" + "google.golang.org/api/iterator" +) + +func TestUpsertBrowserCompatFeatures(t *testing.T) { + restartDatabaseContainer(t) + ctx := context.Background() + + feature := getSampleFeatures()[0] + featureID, err := spannerClient.UpsertWebFeature(ctx, feature) + if err != nil { + t.Fatalf("failed to insert feature: %v", err) + } + + initial := []string{"html.elements.address", "html.elements.section"} + err = spannerClient.UpsertBrowserCompatFeatures(ctx, *featureID, initial) + if err != nil { + t.Fatalf("UpsertBrowserCompatFeatures initial insert failed: %v", err) + } + + expected := slices.Clone(initial) + details := readAllBrowserCompatFeatures(ctx, t, *featureID) + slices.Sort(details) + slices.Sort(expected) + if !slices.Equal(details, expected) { + t.Errorf("initial compat features mismatch.\nexpected %+v\nreceived %+v", expected, details) + } + + updated := []string{"html.elements.article"} + err = spannerClient.UpsertBrowserCompatFeatures(ctx, *featureID, updated) + if err != nil { + t.Fatalf("UpsertBrowserCompatFeatures update failed: %v", err) + } + + expected = slices.Clone(updated) + details = readAllBrowserCompatFeatures(ctx, t, *featureID) + slices.Sort(details) + slices.Sort(expected) + if !slices.Equal(details, expected) { + t.Errorf("updated compat features mismatch.\nexpected %+v\nreceived %+v", expected, details) + } +} + +func readAllBrowserCompatFeatures(ctx context.Context, t *testing.T, featureID string) []string { + stmt := spanner.NewStatement(` + SELECT CompatFeature + FROM WebFeatureBrowserCompatFeatures + WHERE ID = @id`) + stmt.Params["id"] = featureID + + iter := spannerClient.Single().Query(ctx, stmt) + defer iter.Stop() + + var features []string + for { + row, err := iter.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + t.Fatalf("query failed: %v", err) + } + var compat string + if err := row.Columns(&compat); err != nil { + t.Fatalf("column parse failed: %v", err) + } + features = append(features, compat) + } + + return features +} diff --git a/lib/gcpspanner/feature_search_query.go b/lib/gcpspanner/feature_search_query.go index a77be5a9d..999fc71a0 100644 --- a/lib/gcpspanner/feature_search_query.go +++ b/lib/gcpspanner/feature_search_query.go @@ -147,6 +147,8 @@ func (b *FeatureSearchFilterBuilder) traverseAndGenerateFilters(node *searchtype filter = b.baselineDateFilter(node.Term.Value, node.Term.Operator) case searchtypes.IdentifierAvailableBrowserDate: filter = b.handleIdentifierAvailableBrowserDateTerm(node) + case searchtypes.IdentifierBrowserCompatData: + filter = b.browserCompatDataFilter(node.Term.Value, node.Term.Operator) } if filter != "" { filters = append(filters, filter) @@ -393,6 +395,13 @@ func (b *FeatureSearchFilterBuilder) baselineDateFilter(rawDate string, op searc return fmt.Sprintf(`LowDate %s @%s`, searchOperatorToSpannerBinaryOperator(op), paramName) } +func (b *FeatureSearchFilterBuilder) browserCompatDataFilter(bcdKey string, op searchtypes.SearchOperator) string { + paramName := b.addParamGetName(bcdKey) + + return fmt.Sprintf(`wf.ID IN (SELECT ID FROM WebFeatureBrowserCompatFeatures WHERE CompatFeature %s @%s)`, + searchOperatorToSpannerBinaryOperator(op), paramName) +} + // Exclude all that do not have an entry in ExcludedFeatureKeys. const removeExcludedKeyFilter = "efk.FeatureKey IS NULL" const removeExcludedKeyFilterAND = "AND " + removeExcludedKeyFilter diff --git a/lib/gcpspanner/feature_search_query_test.go b/lib/gcpspanner/feature_search_query_test.go index 78f9ae3a5..10641b9d0 100644 --- a/lib/gcpspanner/feature_search_query_test.go +++ b/lib/gcpspanner/feature_search_query_test.go @@ -87,6 +87,24 @@ var ( }, } + simpleBCDQuery = TestTree{ + Query: `bcd:"html.elements.address"`, + InputTree: &searchtypes.SearchNode{ + Keyword: searchtypes.KeywordRoot, + Term: nil, + Children: []*searchtypes.SearchNode{ + { + Keyword: searchtypes.KeywordNone, + Term: &searchtypes.SearchTerm{ + Identifier: "bcd", + Value: "html.elements.address", + Operator: searchtypes.OperatorEq, + }, + }, + }, + }, + } + availableOnBaselineStatus = TestTree{ Query: "available_on:chrome AND baseline_status:widely", InputTree: &searchtypes.SearchNode{ @@ -460,6 +478,13 @@ WHERE BrowserName = @param0)`}, "param0": "%" + "grid" + "%", }, }, + { + inputTestTree: simpleBCDQuery, + expectedClauses: []string{`wf.ID IN (SELECT ID FROM WebFeatureBrowserCompatFeatures WHERE CompatFeature = @param0)`}, + expectedParams: map[string]interface{}{ + "param0": "html.elements.address", + }, + }, { inputTestTree: availableOnBaselineStatus, expectedClauses: []string{`wf.ID IN (SELECT WebFeatureID FROM BrowserFeatureAvailabilities diff --git a/lib/gcpspanner/searchtypes/features_search_visitor.go b/lib/gcpspanner/searchtypes/features_search_visitor.go index ded33c1b4..50e3424ac 100644 --- a/lib/gcpspanner/searchtypes/features_search_visitor.go +++ b/lib/gcpspanner/searchtypes/features_search_visitor.go @@ -160,6 +160,10 @@ func (v *FeaturesSearchVisitor) createNameNode(nameNode antlr.TerminalNode) *Sea return v.createSimpleNode(nameNode, IdentifierName) } +func (v *FeaturesSearchVisitor) createBrowserCompatDataNode(bcdNode antlr.TerminalNode) *SearchNode { + return v.createSimpleNode(bcdNode, IdentifierBrowserCompatData) +} + func (v *FeaturesSearchVisitor) createSimpleNode( node antlr.TerminalNode, identifier SearchIdentifier) *SearchNode { @@ -512,6 +516,11 @@ func (v *FeaturesSearchVisitor) VisitName_term(ctx *parser.Name_termContext) int return v.createNameNode(ctx.ANY_VALUE()) } +// nolint: revive // Method signature is generated. +func (v *FeaturesSearchVisitor) VisitBcd_term(ctx *parser.Bcd_termContext) interface{} { + return v.createBrowserCompatDataNode(ctx.ANY_VALUE()) +} + func (v *FeaturesSearchVisitor) VisitTerm(ctx *parser.TermContext) interface{} { return v.VisitChildren(ctx) } diff --git a/lib/gcpspanner/searchtypes/searchtypes.go b/lib/gcpspanner/searchtypes/searchtypes.go index feadf6b4f..0b2985015 100644 --- a/lib/gcpspanner/searchtypes/searchtypes.go +++ b/lib/gcpspanner/searchtypes/searchtypes.go @@ -92,6 +92,7 @@ const ( IdentifierAvailableOn SearchIdentifier = "available_on" IdentifierBaselineDate SearchIdentifier = "baseline_date" IdentifierBaselineStatus SearchIdentifier = "baseline_status" + IdentifierBrowserCompatData SearchIdentifier = "bcd" IdentifierName SearchIdentifier = "name" IdentifierGroup SearchIdentifier = "group" IdentifierSnapshot SearchIdentifier = "snapshot" diff --git a/lib/gcpspanner/spanneradapters/web_features_consumer.go b/lib/gcpspanner/spanneradapters/web_features_consumer.go index 139116e42..f11648911 100644 --- a/lib/gcpspanner/spanneradapters/web_features_consumer.go +++ b/lib/gcpspanner/spanneradapters/web_features_consumer.go @@ -35,6 +35,7 @@ type WebFeatureSpannerClient interface { UpsertFeatureDiscouragedDetails(ctx context.Context, featureID string, in gcpspanner.FeatureDiscouragedDetails) error PrecalculateBrowserFeatureSupportEvents(ctx context.Context, startAt, endAt time.Time) error + UpsertBrowserCompatFeatures(ctx context.Context, featureID string, compatFeatures []string) error } // NewWebFeaturesConsumer constructs an adapter for the web features consumer service. @@ -99,6 +100,15 @@ func (c *WebFeaturesConsumer) InsertWebFeatures( return nil, err } + if len(featureData.CompatFeatures) > 0 { + err = c.client.UpsertBrowserCompatFeatures(ctx, *id, featureData.CompatFeatures) + if err != nil { + slog.ErrorContext(ctx, "unable to insert compat features", "featureID", *id, "error", err) + + return nil, err + } + } + if featureData.Discouraged != nil { err = c.client.UpsertFeatureDiscouragedDetails(ctx, featureID, gcpspanner.FeatureDiscouragedDetails{ AccordingTo: featureData.Discouraged.AccordingTo, diff --git a/lib/gcpspanner/spanneradapters/web_features_consumer_test.go b/lib/gcpspanner/spanneradapters/web_features_consumer_test.go index a97d4edcf..d9aa6624b 100644 --- a/lib/gcpspanner/spanneradapters/web_features_consumer_test.go +++ b/lib/gcpspanner/spanneradapters/web_features_consumer_test.go @@ -229,6 +229,12 @@ type mockUpsertFeatureDiscouragedDetailsConfig struct { expectedCount int } +type mockUpsertBrowserCompatFeaturesConfig struct { + expectedInputs map[string][]string + outputs map[string]error + expectedCount int +} + type mockWebFeatureSpannerClient struct { t *testing.T upsertWebFeatureCount int @@ -243,6 +249,8 @@ type mockWebFeatureSpannerClient struct { precalculateBrowserFeatureSupportEventsCount int mockUpsertFeatureDiscouragedDetailsCfg mockUpsertFeatureDiscouragedDetailsConfig upsertFeatureDiscouragedDetailsCount int + mockUpsertBrowserCompatFeaturesCfg mockUpsertBrowserCompatFeaturesConfig + upsertBrowserCompatFeaturesCount int } func (c *mockWebFeatureSpannerClient) UpsertWebFeature( @@ -367,6 +375,23 @@ func (c *mockWebFeatureSpannerClient) UpsertFeatureDiscouragedDetails( return c.mockUpsertFeatureDiscouragedDetailsCfg.outputs[featureID] } +func (c *mockWebFeatureSpannerClient) UpsertBrowserCompatFeatures( + _ context.Context, featureID string, compatFeatures []string) error { + if len(c.mockUpsertBrowserCompatFeaturesCfg.expectedInputs) <= c.upsertBrowserCompatFeaturesCount { + c.t.Fatal("no more expected input for UpsertBrowserCompatFeatures") + } + expectedInput, found := c.mockUpsertBrowserCompatFeaturesCfg.expectedInputs[featureID] + if !found { + c.t.Errorf("unexpected input for featureID %v", featureID) + } + if !reflect.DeepEqual(expectedInput, compatFeatures) { + c.t.Errorf("unexpected input expected %v received %v", expectedInput, compatFeatures) + } + c.upsertBrowserCompatFeaturesCount++ + + return c.mockUpsertBrowserCompatFeaturesCfg.outputs[featureID] +} + func newMockmockWebFeatureSpannerClient( t *testing.T, mockUpsertWebFeatureCfg mockUpsertWebFeatureConfig, @@ -375,6 +400,7 @@ func newMockmockWebFeatureSpannerClient( mockUpsertFeatureSpecCfg mockUpsertFeatureSpecConfig, mocmockPrecalculateBrowserFeatureSupportEventsCfg mockPrecalculateBrowserFeatureSupportEventsConfig, mockUpsertFeatureDiscouragedDetailsCfg mockUpsertFeatureDiscouragedDetailsConfig, + mockUpsertBrowserCompatFeaturesCfg mockUpsertBrowserCompatFeaturesConfig, ) *mockWebFeatureSpannerClient { return &mockWebFeatureSpannerClient{ t: t, @@ -390,6 +416,8 @@ func newMockmockWebFeatureSpannerClient( precalculateBrowserFeatureSupportEventsCount: 0, mockUpsertFeatureDiscouragedDetailsCfg: mockUpsertFeatureDiscouragedDetailsCfg, upsertFeatureDiscouragedDetailsCount: 0, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesCfg, + upsertBrowserCompatFeaturesCount: 0, } } @@ -416,6 +444,7 @@ func TestInsertWebFeatures(t *testing.T) { mockUpsertFeatureSpecCfg mockUpsertFeatureSpecConfig mockPrecalculateBrowserFeatureSupportEventsCfg mockPrecalculateBrowserFeatureSupportEventsConfig mockUpsertFeatureDiscouragedDetailsCfg mockUpsertFeatureDiscouragedDetailsConfig + mockUpsertBrowserCompatFeaturesCfg mockUpsertBrowserCompatFeaturesConfig input map[string]web_platform_dx__web_features.FeatureValue expectedError error // Expected error from InsertWebFeatures }{ @@ -533,7 +562,7 @@ func TestInsertWebFeatures(t *testing.T) { "feature1": { Name: "Feature 1", Caniuse: nil, - CompatFeatures: nil, + CompatFeatures: []string{"html.elements.address", "html.elements.section"}, Discouraged: &web_platform_dx__web_features.Discouraged{ AccordingTo: []string{"according-to-1", "according-to-2"}, Alternatives: []string{"alternative-1", "alternative-2"}, @@ -612,6 +641,15 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{"feature1": nil}, expectedCount: 1, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{ + "id-1": {"html.elements.address", "html.elements.section"}, + }, + outputs: map[string]error{ + "id-1": nil, + }, + expectedCount: 1, + }, expectedError: nil, }, { @@ -655,6 +693,11 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{}, expectedCount: 0, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{}, + outputs: map[string]error{}, + expectedCount: 0, + }, input: map[string]web_platform_dx__web_features.FeatureValue{ "feature1": { Name: "Feature 1", @@ -728,6 +771,11 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{}, expectedCount: 0, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{}, + outputs: map[string]error{}, + expectedCount: 0, + }, input: map[string]web_platform_dx__web_features.FeatureValue{ "feature1": { Name: "Feature 1", @@ -861,6 +909,11 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{}, expectedCount: 0, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{}, + outputs: map[string]error{}, + expectedCount: 0, + }, expectedError: ErrBrowserFeatureAvailabilityTest, }, { @@ -990,6 +1043,11 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{}, expectedCount: 0, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{}, + outputs: map[string]error{}, + expectedCount: 0, + }, expectedError: ErrFeatureSpecTest, }, { @@ -1177,6 +1235,11 @@ func TestInsertWebFeatures(t *testing.T) { outputs: map[string]error{}, expectedCount: 0, }, + mockUpsertBrowserCompatFeaturesCfg: mockUpsertBrowserCompatFeaturesConfig{ + expectedInputs: map[string][]string{}, + outputs: map[string]error{}, + expectedCount: 0, + }, expectedError: ErrPrecalculateBrowserFeatureSupportEventsTest, }, } @@ -1191,6 +1254,7 @@ func TestInsertWebFeatures(t *testing.T) { tc.mockUpsertFeatureSpecCfg, tc.mockPrecalculateBrowserFeatureSupportEventsCfg, tc.mockUpsertFeatureDiscouragedDetailsCfg, + tc.mockUpsertBrowserCompatFeaturesCfg, ) consumer := NewWebFeaturesConsumer(mockClient) @@ -1241,6 +1305,13 @@ func TestInsertWebFeatures(t *testing.T) { mockClient.mockUpsertFeatureDiscouragedDetailsCfg.expectedCount, mockClient.upsertFeatureDiscouragedDetailsCount) } + + if mockClient.upsertBrowserCompatFeaturesCount != + tc.mockUpsertBrowserCompatFeaturesCfg.expectedCount { + t.Errorf("expected %d calls to UpsertBrowserCompatFeatures, got %d", + tc.mockUpsertBrowserCompatFeaturesCfg.expectedCount, + mockClient.upsertBrowserCompatFeaturesCount) + } }) } }