Skip to content

Commit 23d9f02

Browse files
authored
feat: Add ListBaselineStatusCounts to retrieve cumulative baseline feature counts in spanner (#1132)
* feat: Add ListBaselineStatusCount to retrieve cumulative baseline feature counts This PR introduces the `ListBaselineStatusCount` function and its associated helper functions to retrieve a cumulative count of baseline features over time. The `ListBaselineStatusCount` function works similarly to `ListBrowserFeatureCountMetric` by: 1. Calculating an initial cumulative count of baseline features up to the specified `startAt` date. 2. Retrieving subsequent baseline feature counts within the specified date range (`startAt` to `endAt`). 3. Accumulating these counts to provide a cumulative view of baseline feature adoption over time. Currently, the function only supports retrieving counts based on the `LowDate` field in the `FeatureBaselineStatus` table. However, support for `HighDate` can be easily added in the future by expanding the `BaselineDateType` enum and updating the query construction logic. This functionality is useful for tracking the overall adoption of baseline features and understanding how the baseline status of features changes over time. * rename
1 parent 0a81a0e commit 23d9f02

File tree

2 files changed

+432
-0
lines changed

2 files changed

+432
-0
lines changed
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
"fmt"
21+
"time"
22+
23+
"cloud.google.com/go/spanner"
24+
"google.golang.org/api/iterator"
25+
)
26+
27+
// BaselineDateType is an enum representing the type of date to use for baseline counts.
28+
type BaselineDateType string
29+
30+
const (
31+
// BaselineDateTypeLow uses the LowDate from FeatureBaselineStatus.
32+
BaselineDateTypeLow BaselineDateType = "low"
33+
)
34+
35+
// BaselineStatusCountMetric represents a single data point in the baseline status count time series.
36+
type BaselineStatusCountMetric struct {
37+
Date time.Time `spanner:"Date"`
38+
StatusCount int64 `spanner:"StatusCount"`
39+
}
40+
41+
// BaselineStatusCountResultPage is a page of results for the baseline status count query.
42+
type BaselineStatusCountResultPage struct {
43+
NextPageToken *string
44+
Metrics []BaselineStatusCountMetric
45+
}
46+
47+
// baselineStatusCountCursor is used for pagination.
48+
type baselineStatusCountCursor struct {
49+
LastDate time.Time `json:"last_date"`
50+
LastStatusCount int64 `json:"last_status_count"`
51+
}
52+
53+
// decodeBaselineStatusCountCursor decodes a cursor string into a baselineStatusCountCursor.
54+
func decodeBaselineStatusCountCursor(cursor string) (*baselineStatusCountCursor, error) {
55+
return decodeCursor[baselineStatusCountCursor](cursor)
56+
}
57+
58+
// encodeBaselineStatusCountCursor encodes a baselineStatusCountCursor into a cursor string.
59+
func encodeBaselineStatusCountCursor(lastDate time.Time, lastStatusCount int64) string {
60+
return encodeCursor(baselineStatusCountCursor{
61+
LastDate: lastDate,
62+
LastStatusCount: lastStatusCount,
63+
})
64+
}
65+
66+
type fbsColumn string
67+
68+
const fbsColumnLowDate fbsColumn = "fbs.LowDate"
69+
70+
// ListBaselineStatusCounts retrieves a cumulative count of baseline features over time.
71+
func (c *Client) ListBaselineStatusCounts(
72+
ctx context.Context,
73+
dateType BaselineDateType,
74+
startAt time.Time,
75+
endAt time.Time,
76+
pageSize int,
77+
pageToken *string,
78+
) (*BaselineStatusCountResultPage, error) {
79+
var parsedToken *baselineStatusCountCursor
80+
var err error
81+
if pageToken != nil {
82+
parsedToken, err = decodeBaselineStatusCountCursor(*pageToken)
83+
if err != nil {
84+
return nil, errors.Join(ErrInternalQueryFailure, err)
85+
}
86+
}
87+
88+
txn := c.ReadOnlyTransaction()
89+
defer txn.Close()
90+
91+
// 1. Validate dateType
92+
switch dateType {
93+
case BaselineDateTypeLow:
94+
break
95+
default:
96+
return nil, errors.Join(ErrInternalQueryFailure, fmt.Errorf("invalid BaselineDateType: %s", dateType))
97+
}
98+
99+
// 2. Get excluded feature IDs
100+
excludedFeatureIDs, err := c.getFeatureIDsForEachExcludedFeatureKey(ctx, txn)
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
// 3. Calculate initial cumulative count
106+
cumulativeCount, err := c.getInitialBaselineStatusCount(
107+
ctx, txn, parsedToken, startAt, excludedFeatureIDs, dateType)
108+
if err != nil {
109+
return nil, errors.Join(ErrInternalQueryFailure, err)
110+
}
111+
112+
// 4. Process results and update cumulative count
113+
stmt := createListBaselineStatusCountsStatement(dateType, startAt, endAt, pageSize, parsedToken, excludedFeatureIDs)
114+
115+
iter := txn.Query(ctx, stmt)
116+
defer iter.Stop()
117+
118+
var metrics []BaselineStatusCountMetric
119+
for {
120+
row, err := iter.Next()
121+
if errors.Is(err, iterator.Done) {
122+
break
123+
}
124+
if err != nil {
125+
return nil, errors.Join(ErrInternalQueryFailure, err)
126+
}
127+
128+
var metric BaselineStatusCountMetric
129+
if err := row.ToStruct(&metric); err != nil {
130+
return nil, err
131+
}
132+
133+
cumulativeCount += metric.StatusCount
134+
metric.StatusCount = cumulativeCount
135+
metrics = append(metrics, metric)
136+
}
137+
138+
var newCursor *string
139+
if len(metrics) == pageSize {
140+
lastMetric := metrics[len(metrics)-1]
141+
generatedCursor := encodeBaselineStatusCountCursor(lastMetric.Date, lastMetric.StatusCount)
142+
newCursor = &generatedCursor
143+
}
144+
145+
return &BaselineStatusCountResultPage{
146+
NextPageToken: newCursor,
147+
Metrics: metrics,
148+
}, nil
149+
}
150+
151+
// getInitialBaselineStatusCount calculates the initial cumulative count for the first page.
152+
func (c *Client) getInitialBaselineStatusCount(
153+
ctx context.Context,
154+
txn *spanner.ReadOnlyTransaction,
155+
parsedToken *baselineStatusCountCursor,
156+
startAt time.Time,
157+
excludedFeatureIDs []string,
158+
dateType BaselineDateType,
159+
) (int64, error) {
160+
if parsedToken != nil {
161+
return parsedToken.LastStatusCount, nil
162+
}
163+
164+
params := map[string]interface{}{
165+
"startAt": startAt,
166+
}
167+
168+
var excludedFeatureFilter string
169+
if len(excludedFeatureIDs) > 0 {
170+
excludedFeatureFilter = `
171+
AND fbs.WebFeatureID NOT IN UNNEST(@excludedFeatureIDs)`
172+
params["excludedFeatureIDs"] = excludedFeatureIDs
173+
}
174+
175+
// Construct the query based on dateType
176+
var dateField string
177+
switch dateType {
178+
case BaselineDateTypeLow:
179+
dateField = string(fbsColumnLowDate)
180+
}
181+
182+
var initialCount int64
183+
stmt := spanner.Statement{
184+
SQL: fmt.Sprintf(`
185+
SELECT COALESCE(SUM(daily_status_count), 0)
186+
FROM (
187+
SELECT COUNT(fbs.WebFeatureID) AS daily_status_count
188+
FROM FeatureBaselineStatus fbs
189+
WHERE %s < @startAt %s
190+
GROUP BY %s
191+
)`, dateField, excludedFeatureFilter, dateField),
192+
Params: params,
193+
}
194+
195+
err := txn.Query(ctx, stmt).Do(func(r *spanner.Row) error {
196+
return r.Column(0, &initialCount)
197+
})
198+
199+
return initialCount, err
200+
}
201+
202+
// createListBaselineStatusCountsStatement creates the Spanner statement for the main query.
203+
func createListBaselineStatusCountsStatement(
204+
dateType BaselineDateType,
205+
startAt time.Time,
206+
endAt time.Time,
207+
pageSize int,
208+
pageToken *baselineStatusCountCursor,
209+
excludedFeatureIDs []string,
210+
) spanner.Statement {
211+
params := map[string]interface{}{
212+
"startAt": startAt,
213+
"endAt": endAt,
214+
"pageSize": pageSize,
215+
}
216+
217+
var pageFilter string
218+
if pageToken != nil {
219+
var dateField string
220+
switch dateType {
221+
case BaselineDateTypeLow:
222+
dateField = string(fbsColumnLowDate)
223+
}
224+
pageFilter = fmt.Sprintf(`AND %s > @lastDate`, dateField)
225+
params["lastDate"] = pageToken.LastDate
226+
}
227+
228+
var excludedFeatureFilter string
229+
if len(excludedFeatureIDs) > 0 {
230+
excludedFeatureFilter = `AND fbs.WebFeatureID NOT IN UNNEST(@excludedFeatureIDs)`
231+
params["excludedFeatureIDs"] = excludedFeatureIDs
232+
}
233+
234+
// Construct the query based on dateType
235+
var dateField string
236+
switch dateType {
237+
case BaselineDateTypeLow:
238+
dateField = string(fbsColumnLowDate)
239+
}
240+
241+
stmt := spanner.Statement{
242+
SQL: fmt.Sprintf(`
243+
SELECT %s AS Date, COUNT(fbs.WebFeatureID) AS StatusCount
244+
FROM FeatureBaselineStatus fbs
245+
WHERE %s >= @startAt AND %s < @endAt %s %s
246+
GROUP BY %s
247+
ORDER BY %s
248+
LIMIT @pageSize`,
249+
dateField, dateField, dateField, pageFilter, excludedFeatureFilter, dateField, dateField),
250+
Params: params,
251+
}
252+
253+
return stmt
254+
}

0 commit comments

Comments
 (0)