Skip to content

Commit ef3b1e1

Browse files
committed
Add bcd insert, retreival, tests
1 parent 3dac5da commit ef3b1e1

12 files changed

+289
-1
lines changed

antlr/FeatureSearch.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ available_date_term:
2929
'available_date' COLON BROWSER_NAME COLON (date_range_query);
3030
// In the future support other operators by doing something like (date_operator_query | date_range_query)
3131
baseline_date_term: 'baseline_date' COLON (date_range_query);
32+
bcd_term: 'bcd' COLON ANY_VALUE;
3233
name_term: 'name' COLON ANY_VALUE;
3334
group_term: 'group' COLON ANY_VALUE;
3435
snapshot_term: 'snapshot' COLON ANY_VALUE;
@@ -38,6 +39,7 @@ term:
3839
| available_on_term
3940
| baseline_status_term
4041
| baseline_date_term
42+
| bcd_term
4143
| group_term
4244
| id_term
4345
| snapshot_term

antlr/FeatureSearch.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ This query language enables you to construct flexible searches to find features
6060
- `name:"Dark Mode"` - Find features named "Dark Mode" (including spaces).
6161
- `baseline_date:2023-01-01..2023-12-31` - Searches for all features that reached baseline in 2023.
6262
- `group:css` - Searches for features that belong to the `css` group and any groups that are descendants of that group.
63+
- `bcd:ToggleEvent` - Searches for features associated with the Browser Compatibility Data key `ToggleEvent`
6364
- `snapshot:ecmascript-5` - Searches for features that belong to the `ecmascript-5` snapshot.
6465
- `id:css` - Searches for a feature whose feature identifier (featurekey) is `css`.
6566

frontend/src/static/js/utils/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ export const VOCABULARY = [
159159
name: 'baseline_status:widely',
160160
doc: 'Features in baseline and widely available',
161161
},
162+
{
163+
name: 'bcd:',
164+
doc: 'Features linked to MDN’s Browser Compatibility Data keys. E.g., bcd:ToggleEvent',
165+
},
162166
{
163167
name: 'group:',
164168
doc: 'Features in a group or its descendants. E.g., group:css',
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Copyright 2025 Google LLC
2+
--
3+
-- Licensed under the Apache License, Version 2.0 (the "License");
4+
-- you may not use this file except in compliance with the License.
5+
-- You may obtain a copy of the License at
6+
--
7+
-- http://www.apache.org/licenses/LICENSE-2.0
8+
--
9+
-- Unless required by applicable law or agreed to in writing, software
10+
-- distributed under the License is distributed on an "AS IS" BASIS,
11+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
-- See the License for the specific language governing permissions and
13+
-- limitations under the License.
14+
15+
-- WebFeatureBrowserCompatFeatures stores the compat_features list (e.g. "html.elements.address")
16+
-- for each WebFeature. Multiple compat features may exist per feature.
17+
CREATE TABLE IF NOT EXISTS WebFeatureBrowserCompatFeatures (
18+
ID STRING(36) NOT NULL, -- same name and type as parent
19+
CompatFeature STRING(255) NOT NULL,
20+
FOREIGN KEY (ID) REFERENCES WebFeatures(ID)
21+
) PRIMARY KEY (ID, CompatFeature)
22+
, INTERLEAVE IN PARENT WebFeatures ON DELETE CASCADE;
23+
24+
-- Index to accelerate searches by CompatFeature
25+
CREATE INDEX IDX_CompatFeature ON WebFeatureBrowserCompatFeatures(CompatFeature);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcpspanner
16+
17+
import (
18+
"context"
19+
20+
"cloud.google.com/go/spanner"
21+
)
22+
23+
func (c *Client) UpsertBrowserCompatFeatures(ctx context.Context, featureID string, compatFeatures []string) error {
24+
// Create a delete mutation for the specified KeyRange
25+
del := spanner.Delete("WebFeatureBrowserCompatFeatures", spanner.Key{featureID}.AsPrefix())
26+
27+
// Then, insert new ones
28+
muts := make([]*spanner.Mutation, 0, len(compatFeatures)+1)
29+
muts = append(muts, del)
30+
for _, compat := range compatFeatures {
31+
muts = append(muts, spanner.InsertOrUpdate("WebFeatureBrowserCompatFeatures", []string{
32+
"ID", "CompatFeature",
33+
}, []interface{}{featureID, compat}))
34+
}
35+
_, err := c.Apply(ctx, muts)
36+
37+
return err
38+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gcpspanner
16+
17+
import (
18+
"context"
19+
"errors"
20+
"slices"
21+
"testing"
22+
23+
"cloud.google.com/go/spanner"
24+
"google.golang.org/api/iterator"
25+
)
26+
27+
func TestUpsertBrowserCompatFeatures(t *testing.T) {
28+
restartDatabaseContainer(t)
29+
ctx := context.Background()
30+
31+
feature := getSampleFeatures()[0]
32+
featureID, err := spannerClient.UpsertWebFeature(ctx, feature)
33+
if err != nil {
34+
t.Fatalf("failed to insert feature: %v", err)
35+
}
36+
37+
initial := []string{"html.elements.address", "html.elements.section"}
38+
err = spannerClient.UpsertBrowserCompatFeatures(ctx, *featureID, initial)
39+
if err != nil {
40+
t.Fatalf("UpsertBrowserCompatFeatures initial insert failed: %v", err)
41+
}
42+
43+
expected := slices.Clone(initial)
44+
details := readAllBrowserCompatFeatures(ctx, t, *featureID)
45+
slices.Sort(details)
46+
slices.Sort(expected)
47+
if !slices.Equal(details, expected) {
48+
t.Errorf("initial compat features mismatch.\nexpected %+v\nreceived %+v", expected, details)
49+
}
50+
51+
updated := []string{"html.elements.article"}
52+
err = spannerClient.UpsertBrowserCompatFeatures(ctx, *featureID, updated)
53+
if err != nil {
54+
t.Fatalf("UpsertBrowserCompatFeatures update failed: %v", err)
55+
}
56+
57+
expected = slices.Clone(updated)
58+
details = readAllBrowserCompatFeatures(ctx, t, *featureID)
59+
slices.Sort(details)
60+
slices.Sort(expected)
61+
if !slices.Equal(details, expected) {
62+
t.Errorf("updated compat features mismatch.\nexpected %+v\nreceived %+v", expected, details)
63+
}
64+
}
65+
66+
func readAllBrowserCompatFeatures(ctx context.Context, t *testing.T, featureID string) []string {
67+
stmt := spanner.NewStatement(`
68+
SELECT CompatFeature
69+
FROM WebFeatureBrowserCompatFeatures
70+
WHERE ID = @id`)
71+
stmt.Params["id"] = featureID
72+
73+
iter := spannerClient.Single().Query(ctx, stmt)
74+
defer iter.Stop()
75+
76+
var features []string
77+
for {
78+
row, err := iter.Next()
79+
if errors.Is(err, iterator.Done) {
80+
break
81+
}
82+
if err != nil {
83+
t.Fatalf("query failed: %v", err)
84+
}
85+
var compat string
86+
if err := row.Columns(&compat); err != nil {
87+
t.Fatalf("column parse failed: %v", err)
88+
}
89+
features = append(features, compat)
90+
}
91+
92+
return features
93+
}

lib/gcpspanner/feature_search_query.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ func (b *FeatureSearchFilterBuilder) traverseAndGenerateFilters(node *searchtype
147147
filter = b.baselineDateFilter(node.Term.Value, node.Term.Operator)
148148
case searchtypes.IdentifierAvailableBrowserDate:
149149
filter = b.handleIdentifierAvailableBrowserDateTerm(node)
150+
case searchtypes.IdentifierBrowserCompatData:
151+
filter = b.browserCompatDataFilter(node.Term.Value, node.Term.Operator)
150152
}
151153
if filter != "" {
152154
filters = append(filters, filter)
@@ -393,6 +395,13 @@ func (b *FeatureSearchFilterBuilder) baselineDateFilter(rawDate string, op searc
393395
return fmt.Sprintf(`LowDate %s @%s`, searchOperatorToSpannerBinaryOperator(op), paramName)
394396
}
395397

398+
func (b *FeatureSearchFilterBuilder) browserCompatDataFilter(bcdKey string, op searchtypes.SearchOperator) string {
399+
paramName := b.addParamGetName(bcdKey)
400+
401+
return fmt.Sprintf(`wf.ID IN (SELECT ID FROM WebFeatureBrowserCompatFeatures WHERE CompatFeature %s @%s)`,
402+
searchOperatorToSpannerBinaryOperator(op), paramName)
403+
}
404+
396405
// Exclude all that do not have an entry in ExcludedFeatureKeys.
397406
const removeExcludedKeyFilter = "efk.FeatureKey IS NULL"
398407
const removeExcludedKeyFilterAND = "AND " + removeExcludedKeyFilter

lib/gcpspanner/feature_search_query_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ var (
8787
},
8888
}
8989

90+
simpleBCDQuery = TestTree{
91+
Query: `bcd:"html.elements.address"`,
92+
InputTree: &searchtypes.SearchNode{
93+
Keyword: searchtypes.KeywordRoot,
94+
Term: nil,
95+
Children: []*searchtypes.SearchNode{
96+
{
97+
Keyword: searchtypes.KeywordNone,
98+
Term: &searchtypes.SearchTerm{
99+
Identifier: "bcd",
100+
Value: "html.elements.address",
101+
Operator: searchtypes.OperatorEq,
102+
},
103+
},
104+
},
105+
},
106+
}
107+
90108
availableOnBaselineStatus = TestTree{
91109
Query: "available_on:chrome AND baseline_status:widely",
92110
InputTree: &searchtypes.SearchNode{
@@ -460,6 +478,13 @@ WHERE BrowserName = @param0)`},
460478
"param0": "%" + "grid" + "%",
461479
},
462480
},
481+
{
482+
inputTestTree: simpleBCDQuery,
483+
expectedClauses: []string{`wf.ID IN (SELECT ID FROM WebFeatureBrowserCompatFeatures WHERE CompatFeature = @param0)`},
484+
expectedParams: map[string]interface{}{
485+
"param0": "html.elements.address",
486+
},
487+
},
463488
{
464489
inputTestTree: availableOnBaselineStatus,
465490
expectedClauses: []string{`wf.ID IN (SELECT WebFeatureID FROM BrowserFeatureAvailabilities

lib/gcpspanner/searchtypes/features_search_visitor.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ func (v *FeaturesSearchVisitor) createNameNode(nameNode antlr.TerminalNode) *Sea
160160
return v.createSimpleNode(nameNode, IdentifierName)
161161
}
162162

163+
func (v *FeaturesSearchVisitor) createBrowserCompatDataNode(bcdNode antlr.TerminalNode) *SearchNode {
164+
return v.createSimpleNode(bcdNode, IdentifierBrowserCompatData)
165+
}
166+
163167
func (v *FeaturesSearchVisitor) createSimpleNode(
164168
node antlr.TerminalNode,
165169
identifier SearchIdentifier) *SearchNode {
@@ -512,6 +516,11 @@ func (v *FeaturesSearchVisitor) VisitName_term(ctx *parser.Name_termContext) int
512516
return v.createNameNode(ctx.ANY_VALUE())
513517
}
514518

519+
// nolint: revive // Method signature is generated.
520+
func (v *FeaturesSearchVisitor) VisitBcd_term(ctx *parser.Bcd_termContext) interface{} {
521+
return v.createBrowserCompatDataNode(ctx.ANY_VALUE())
522+
}
523+
515524
func (v *FeaturesSearchVisitor) VisitTerm(ctx *parser.TermContext) interface{} {
516525
return v.VisitChildren(ctx)
517526
}

lib/gcpspanner/searchtypes/searchtypes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const (
9292
IdentifierAvailableOn SearchIdentifier = "available_on"
9393
IdentifierBaselineDate SearchIdentifier = "baseline_date"
9494
IdentifierBaselineStatus SearchIdentifier = "baseline_status"
95+
IdentifierBrowserCompatData SearchIdentifier = "bcd"
9596
IdentifierName SearchIdentifier = "name"
9697
IdentifierGroup SearchIdentifier = "group"
9798
IdentifierSnapshot SearchIdentifier = "snapshot"

lib/gcpspanner/spanneradapters/web_features_consumer.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type WebFeatureSpannerClient interface {
3535
UpsertFeatureDiscouragedDetails(ctx context.Context, featureID string,
3636
in gcpspanner.FeatureDiscouragedDetails) error
3737
PrecalculateBrowserFeatureSupportEvents(ctx context.Context, startAt, endAt time.Time) error
38+
UpsertBrowserCompatFeatures(ctx context.Context, featureID string, compatFeatures []string) error
3839
}
3940

4041
// NewWebFeaturesConsumer constructs an adapter for the web features consumer service.
@@ -99,6 +100,15 @@ func (c *WebFeaturesConsumer) InsertWebFeatures(
99100
return nil, err
100101
}
101102

103+
if len(featureData.CompatFeatures) > 0 {
104+
err = c.client.UpsertBrowserCompatFeatures(ctx, *id, featureData.CompatFeatures)
105+
if err != nil {
106+
slog.ErrorContext(ctx, "unable to insert compat features", "featureID", *id, "error", err)
107+
108+
return nil, err
109+
}
110+
}
111+
102112
if featureData.Discouraged != nil {
103113
err = c.client.UpsertFeatureDiscouragedDetails(ctx, featureID, gcpspanner.FeatureDiscouragedDetails{
104114
AccordingTo: featureData.Discouraged.AccordingTo,

0 commit comments

Comments
 (0)