diff --git a/lib/gcpspanner/browser_availabilities.go b/lib/gcpspanner/browser_availabilities.go index fc760ba1..ce07e464 100644 --- a/lib/gcpspanner/browser_availabilities.go +++ b/lib/gcpspanner/browser_availabilities.go @@ -97,3 +97,28 @@ func (c *Client) InsertBrowserFeatureAvailability( return newEntityWriter[browserFeatureAvailabilityMapper](c).upsert(ctx, featureAvailability) } + +func (c *Client) fetchAllBrowserAvailabilitiesWithTransaction( + ctx context.Context, txn *spanner.ReadWriteTransaction) ([]spannerBrowserFeatureAvailability, error) { + var availabilities []spannerBrowserFeatureAvailability + iter := txn.Read(ctx, browserFeatureAvailabilitiesTable, spanner.AllKeys(), []string{ + "BrowserName", + "BrowserVersion", + "WebFeatureID", + }) + defer iter.Stop() + err := iter.Do(func(row *spanner.Row) error { + var entry spannerBrowserFeatureAvailability + if err := row.ToStruct(&entry); err != nil { + return err + } + availabilities = append(availabilities, entry) + + return nil + }) + if err != nil { + return nil, err + } + + return availabilities, nil +} diff --git a/lib/gcpspanner/browser_feature_support_event.go b/lib/gcpspanner/browser_feature_support_event.go new file mode 100644 index 00000000..372fd3fb --- /dev/null +++ b/lib/gcpspanner/browser_feature_support_event.go @@ -0,0 +1,141 @@ +// Copyright 2024 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" + "time" + + "cloud.google.com/go/spanner" +) + +const browserFeatureSupportEventsTable = "BrowserFeatureSupportEvents" + +type BrowserFeatureSupportStatus string + +const ( + UnsupportedFeatureSupport BrowserFeatureSupportStatus = "unsupported" + SupportedFeatureSupport BrowserFeatureSupportStatus = "supported" +) + +type BrowserFeatureSupportEvent struct { + TargetBrowserName string `spanner:"TargetBrowserName"` + EventBrowserName string `spanner:"EventBrowserName"` + EventReleaseDate time.Time `spanner:"EventReleaseDate"` + WebFeatureID string `spanner:"WebFeatureID"` + SupportStatus BrowserFeatureSupportStatus `spanner:"SupportStatus"` +} + +func buildAvailabilityMap( + releases []spannerBrowserRelease, + availabilities []spannerBrowserFeatureAvailability) map[string]map[string]time.Time { + // Create a map for efficient lookup of browser releases + releaseMap := make(map[string]map[string]time.Time) // map[browserName]map[browserVersion]releaseDate + for _, release := range releases { + if _, ok := releaseMap[release.BrowserName]; !ok { + releaseMap[release.BrowserName] = make(map[string]time.Time) + } + releaseMap[release.BrowserName][release.BrowserVersion] = release.ReleaseDate + } + + // Create a map for efficient lookup of feature availability with release dates + availabilityMap := make(map[string]map[string]time.Time) // map[browserName]map[featureID]time.Time + for _, availability := range availabilities { + if _, ok := availabilityMap[availability.BrowserName]; !ok { + availabilityMap[availability.BrowserName] = make(map[string]time.Time) + } + // Use releaseMap to get the release date for this availability + if releaseDate, ok := releaseMap[availability.BrowserName][availability.BrowserVersion]; ok { + availabilityMap[availability.BrowserName][availability.WebFeatureID] = releaseDate + } + } + + return availabilityMap +} + +func calculateBrowserSupportEvents( + availabilityMap map[string]map[string]time.Time, + releases []spannerBrowserRelease, + ids []string) []BrowserFeatureSupportEvent { + var supportEvents []BrowserFeatureSupportEvent + for _, targetBrowser := range releases { + for _, eventBrowser := range releases { + for _, id := range ids { + supportStatus := UnsupportedFeatureSupport // Default to unsupported + if _, ok := availabilityMap[targetBrowser.BrowserName]; ok { + availabilityTime, supported := availabilityMap[targetBrowser.BrowserName][id] + if supported && (availabilityTime.Equal(eventBrowser.ReleaseDate) || + eventBrowser.ReleaseDate.After(availabilityTime)) { + supportStatus = SupportedFeatureSupport + } + } + supportEvents = append(supportEvents, BrowserFeatureSupportEvent{ + TargetBrowserName: targetBrowser.BrowserName, + EventBrowserName: eventBrowser.BrowserName, + EventReleaseDate: eventBrowser.ReleaseDate, + WebFeatureID: id, + SupportStatus: supportStatus, + }) + } + } + } + + return supportEvents +} + +// PrecalculateBrowserFeatureSupportEvents populates the BrowserFeatureSupportEvents table with pre-calculated data. +func (c *Client) PrecalculateBrowserFeatureSupportEvents(ctx context.Context) error { + _, err := c.Client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error { + // 1. Fetch all BrowserFeatureAvailabilities + availabilities, err := c.fetchAllBrowserAvailabilitiesWithTransaction(ctx, txn) + if err != nil { + return err + } + + // 2. Fetch all BrowserReleases + releases, err := c.fetchAllBrowserReleasesWithTransaction(ctx, txn) + if err != nil { + return err + } + + // 3. Fetch all WebFeatures + ids, err := c.fetchAllWebFeatureIDsWithTransaction(ctx, txn) + if err != nil { + return err + } + + // 4. Create maps for quick look ups + availabilityMap := buildAvailabilityMap(releases, availabilities) + + // 4. Generate BrowserFeatureSupportEvents entries (including SupportStatus) + supportEvents := calculateBrowserSupportEvents(availabilityMap, releases, ids) + + // 5. Insert the new entries into BrowserFeatureSupportEvents + var mutations []*spanner.Mutation + for _, entry := range supportEvents { + m, err := spanner.InsertOrUpdateStruct(browserFeatureSupportEventsTable, entry) + if err != nil { + return errors.Join(err, ErrInternalQueryFailure) + } + mutations = append(mutations, m) + } + + return txn.BufferWrite(mutations) + + }) + + return err +} diff --git a/lib/gcpspanner/browser_feature_support_event_test.go b/lib/gcpspanner/browser_feature_support_event_test.go new file mode 100644 index 00000000..9fa71398 --- /dev/null +++ b/lib/gcpspanner/browser_feature_support_event_test.go @@ -0,0 +1,378 @@ +// Copyright 2024 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" + "reflect" + "slices" + "testing" + "time" + + "cloud.google.com/go/spanner" +) + +func TestPrecalculateBrowserFeatureSupportEvents(t *testing.T) { + restartDatabaseContainer(t) + ctx := context.Background() + featureKeyToID := map[string]string{} + + // 1. Insert sample data into WebFeatures + features := []WebFeature{ + {FeatureKey: "FeatureX", Name: "Cool API"}, + {FeatureKey: "FeatureY", Name: "Super API"}, + {FeatureKey: "FeatureZ", Name: "Ultra API"}, + } + for _, feature := range features { + id, err := spannerClient.UpsertWebFeature(ctx, feature) + if err != nil { + t.Fatalf("Failed to insert WebFeature: %v", err) + } + featureKeyToID[feature.FeatureKey] = *id + } + + // 2. Insert sample data into BrowserReleases + releases := []BrowserRelease{ + {BrowserName: "Chrome", BrowserVersion: "110", ReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC)}, + {BrowserName: "Chrome", BrowserVersion: "111", ReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}, + {BrowserName: "Firefox", BrowserVersion: "111", ReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}, + {BrowserName: "Firefox", BrowserVersion: "112", ReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)}, + } + for _, release := range releases { + err := spannerClient.InsertBrowserRelease(ctx, release) + if err != nil { + t.Fatalf("Failed to insert BrowserRelease: %v", err) + } + } + + // 3. Insert sample data into BrowserFeatureAvailabilities + availabilities := []struct { + WebFeatureKey string + BrowserFeatureAvailability + }{ + { + WebFeatureKey: features[0].FeatureKey, + BrowserFeatureAvailability: BrowserFeatureAvailability{BrowserName: "Chrome", BrowserVersion: "110"}, + }, + { + WebFeatureKey: features[2].FeatureKey, + BrowserFeatureAvailability: BrowserFeatureAvailability{BrowserName: "Chrome", BrowserVersion: "111"}, + }, + { + WebFeatureKey: features[1].FeatureKey, + BrowserFeatureAvailability: BrowserFeatureAvailability{BrowserName: "Firefox", BrowserVersion: "111"}, + }, + { + WebFeatureKey: features[2].FeatureKey, + BrowserFeatureAvailability: BrowserFeatureAvailability{BrowserName: "Firefox", BrowserVersion: "112"}, + }, + } + for _, availability := range availabilities { + err := spannerClient.InsertBrowserFeatureAvailability(ctx, availability.WebFeatureKey, + availability.BrowserFeatureAvailability) + if err != nil { + t.Fatalf("Failed to insert BrowserFeatureAvailability: %v", err) + } + } + + // 4. Call the function to pre-calculate the data + err := spannerClient.PrecalculateBrowserFeatureSupportEvents(ctx) + if err != nil { + t.Fatalf("PrecalculateBrowserFeatureSupportEvents failed: %v", err) + } + + // 5. Assert the expected data in BrowserFeatureSupportEvents + expectedEvents := []BrowserFeatureSupportEvent{ + /* + 2024-01-10 - Chrome release + */ + // Chrome supports features[0] during it's own release which has features[0] + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Chrome never supports features[1] + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Chrome does not support features[2] yet + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox never supports features[0] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox should not support features[1] during the release of Chrome because + // Firefox doesn't support features[1] until its release later. + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox does not support features[2] yet + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + /* + 2024-02-01 - Firefox and Chrome release + */ + // Firefox release + // Chrome already supports features[0]. + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Chrome never supports features[1] + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Chrome now supports features[2]. + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox never supports features[0] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox supports features[1] during it's own release which has features[1] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox does not support features[2] yet + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + + // Chrome release + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Chrome never supports features[1] + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Chrome now supports features[2]. + { + TargetBrowserName: "Chrome", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox never supports features[0] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox supports features[1] during it's own release which has features[1] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox does not support features[2] yet + { + TargetBrowserName: "Firefox", + EventBrowserName: "Chrome", + EventReleaseDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + + /* + 2024-03-01 - Firefox release + */ + // Chrome already supports features[0]. + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Chrome never supports features[1] + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Chrome now supports features[2]. + { + TargetBrowserName: "Chrome", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox never supports features[0] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[0].FeatureKey], + SupportStatus: UnsupportedFeatureSupport, + }, + // Firefox supports features[1] during it's own release which has features[1] + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[1].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + // Firefox supports features[2] now + { + TargetBrowserName: "Firefox", + EventBrowserName: "Firefox", + EventReleaseDate: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC), + WebFeatureID: featureKeyToID[features[2].FeatureKey], + SupportStatus: SupportedFeatureSupport, + }, + } + + actualEvents := spannerClient.readAllBrowserFeatureSupportEvents(ctx, t) + + // Assert that the actual events match the expected events + slices.SortFunc(expectedEvents, sortBrowserFeatureSupportEvents) + slices.SortFunc(actualEvents, sortBrowserFeatureSupportEvents) + if !reflect.DeepEqual(expectedEvents, actualEvents) { + t.Errorf("Unexpected data in BrowserFeatureSupportEvents\nExpected (size: %d):\n%+v\nActual (size: %d):\n%+v", + len(expectedEvents), expectedEvents, len(actualEvents), actualEvents) + } +} + +func (c *Client) readAllBrowserFeatureSupportEvents(ctx context.Context, t *testing.T) []BrowserFeatureSupportEvent { + // Fetch all rows from BrowserFeatureSupportEvents + stmt := spanner.Statement{ + SQL: `SELECT * + FROM BrowserFeatureSupportEvents`, + Params: nil, + } + var actualEvents []BrowserFeatureSupportEvent + iter := spannerClient.Single().Query(ctx, stmt) + defer iter.Stop() + err := iter.Do(func(row *spanner.Row) error { + var event BrowserFeatureSupportEvent + if err := row.ToStruct(&event); err != nil { + return err + } + actualEvents = append(actualEvents, event) + + return nil + }) + if err != nil { + t.Fatalf("Failed to fetch data from BrowserFeatureSupportEvents: %v", err) + } + + return actualEvents +} + +func sortBrowserFeatureSupportEvents(left, right BrowserFeatureSupportEvent) int { + // 1. Sort by EventReleaseDate + if !left.EventReleaseDate.Equal(right.EventReleaseDate) { + if left.EventReleaseDate.Before(right.EventReleaseDate) { + return -1 + } + + return 1 + } + + // 2. Sort by EventBrowserName + if left.EventBrowserName != right.EventBrowserName { + if left.EventBrowserName < right.EventBrowserName { + return -1 + } + + return 1 + } + + // 3. Sort by TargetBrowserName + if left.TargetBrowserName != right.TargetBrowserName { + if left.TargetBrowserName < right.TargetBrowserName { + return -1 + } + + return 1 + } + + // 4. Sort by WebFeatureID + if left.WebFeatureID < right.WebFeatureID { + return -1 + } else if left.WebFeatureID > right.WebFeatureID { + return 1 + } + + return 0 // Equal +} diff --git a/lib/gcpspanner/browser_releases.go b/lib/gcpspanner/browser_releases.go index 5b03f9df..e56012a9 100644 --- a/lib/gcpspanner/browser_releases.go +++ b/lib/gcpspanner/browser_releases.go @@ -82,3 +82,28 @@ func (m browserReleaseSpannerMapper) Table() string { func (c *Client) InsertBrowserRelease(ctx context.Context, release BrowserRelease) error { return newEntityWriter[browserReleaseSpannerMapper](c).upsert(ctx, release) } + +func (c *Client) fetchAllBrowserReleasesWithTransaction( + ctx context.Context, txn *spanner.ReadWriteTransaction) ([]spannerBrowserRelease, error) { + var releases []spannerBrowserRelease + iter := txn.Read(ctx, browserReleasesTable, spanner.AllKeys(), []string{ + "BrowserName", + "BrowserVersion", + "ReleaseDate", + }) + defer iter.Stop() + err := iter.Do(func(row *spanner.Row) error { + var entry spannerBrowserRelease + if err := row.ToStruct(&entry); err != nil { + return err + } + releases = append(releases, entry) + + return nil + }) + if err != nil { + return nil, err + } + + return releases, nil +} diff --git a/lib/gcpspanner/web_features.go b/lib/gcpspanner/web_features.go index 2d1f45d8..dc71c41e 100644 --- a/lib/gcpspanner/web_features.go +++ b/lib/gcpspanner/web_features.go @@ -99,3 +99,24 @@ func (c *Client) UpsertWebFeature(ctx context.Context, feature WebFeature) (*str func (c *Client) GetIDFromFeatureKey(ctx context.Context, filter *FeatureIDFilter) (*string, error) { return newEntityWriterWithIDRetrieval[webFeatureSpannerMapper, string](c).getIDByKey(ctx, filter.featureKey) } + +func (c *Client) fetchAllWebFeatureIDsWithTransaction( + ctx context.Context, txn *spanner.ReadWriteTransaction) ([]string, error) { + var ids []string + iter := txn.Read(ctx, webFeaturesTable, spanner.AllKeys(), []string{"ID"}) + defer iter.Stop() + err := iter.Do(func(row *spanner.Row) error { + var id string + if err := row.Column(0, &id); err != nil { + return err + } + ids = append(ids, id) + + return nil + }) + if err != nil { + return nil, err + } + + return ids, nil +}