diff --git a/components/gitpod-db/src/typeorm/migration/1662040283793-AddUsageTable.ts b/components/gitpod-db/src/typeorm/migration/1662040283793-AddUsageTable.ts new file mode 100644 index 00000000000000..ec71012d929e2c --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1662040283793-AddUsageTable.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUsageTable1662040283793 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`d_b_usage\` ( + \`id\` char(36) NOT NULL, + \`attributionId\` varchar(255) NOT NULL, + \`description\` varchar(255) NOT NULL, + \`creditCents\` bigint NOT NULL, + \`effectiveTime\` varchar(255) NOT NULL, + \`kind\` varchar(255) NOT NULL, + \`workspaceInstanceId\` char(36) NULL, + \`draft\` BOOLEAN NOT NULL, + \`metadata\` text NULL, + \`_created\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`_lastModified\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + INDEX \`IDX_usage__attribution_id\` (\`attributionId\`), + INDEX \`IDX_usage__effectiveTime\` (\`effectiveTime\`), + INDEX \`IDX_usage__workspaceInstanceId\` (\`workspaceInstanceId\`), + INDEX \`IDX_usage___lastModified\` (\`_lastModified\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX \`IDX_usage__attribution_id\` ON \`d_b_usage\``); + await queryRunner.query(`DROP INDEX \`IDX_usage__effectiveTime\` ON \`d_b_usage\``); + await queryRunner.query(`DROP INDEX \`IDX_usage__workspaceInstanceId\` ON \`d_b_usage\``); + await queryRunner.query(`DROP INDEX \`IDX_usage___lastModified\` ON \`d_b_usage\``); + await queryRunner.query(`DROP TABLE \`d_b_usage\``); + } +} diff --git a/components/usage/pkg/db/dbtest/usage.go b/components/usage/pkg/db/dbtest/usage.go new file mode 100644 index 00000000000000..2e122af35cf165 --- /dev/null +++ b/components/usage/pkg/db/dbtest/usage.go @@ -0,0 +1,79 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package dbtest + +import ( + "testing" + + "github.com/gitpod-io/gitpod/usage/pkg/db" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func NewUsage(t *testing.T, record db.Usage) db.Usage { + t.Helper() + + result := db.Usage{ + ID: uuid.New(), + AttributionID: db.NewUserAttributionID(uuid.New().String()), + Description: "some description", + CreditCents: 42, + EffectiveTime: db.VarcharTime{}, + Kind: "workspaceinstance", + WorkspaceInstanceID: uuid.New(), + } + + if record.ID.ID() != 0 { + result.ID = record.ID + } + if record.EffectiveTime.IsSet() { + result.EffectiveTime = record.EffectiveTime + } + if record.AttributionID != "" { + result.AttributionID = record.AttributionID + } + if record.Description != "" { + result.Description = record.Description + } + if record.CreditCents != 0 { + result.CreditCents = record.CreditCents + } + if record.WorkspaceInstanceID.ID() != 0 { + result.WorkspaceInstanceID = record.WorkspaceInstanceID + } + if record.Kind != "" { + result.Kind = record.Kind + } + if record.Draft { + result.Draft = true + } + if record.Metadata != nil { + result.Metadata = record.Metadata + } + return result +} + +func CreateUsageRecords(t *testing.T, conn *gorm.DB, entries ...db.Usage) []db.Usage { + t.Helper() + + var records []db.Usage + var ids []string + for _, usageEntry := range entries { + record := NewUsage(t, usageEntry) + records = append(records, record) + ids = append(ids, record.ID.String()) + } + + require.NoError(t, conn.CreateInBatches(&records, 1000).Error) + + t.Cleanup(func() { + require.NoError(t, conn.Where(ids).Delete(&db.Usage{}).Error) + }) + + t.Logf("stored %d", len(entries)) + + return records +} diff --git a/components/usage/pkg/db/usage.go b/components/usage/pkg/db/usage.go new file mode 100644 index 00000000000000..4e72835a609a43 --- /dev/null +++ b/components/usage/pkg/db/usage.go @@ -0,0 +1,53 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package db + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type Usage struct { + ID uuid.UUID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"` + AttributionID AttributionID `gorm:"column:attributionId;type:varchar;size:255;" json:"attributionId"` + Description string `gorm:"column:description;type:varchar;size:255;" json:"description"` + CreditCents int64 `gorm:"column:creditCents;type:bigint;" json:"creditCents"` + EffectiveTime VarcharTime `gorm:"column:effectiveTime;type:varchar;size:255;" json:"effectiveTime"` + Kind string `gorm:"column:kind;type:char;size:10;" json:"kind"` + WorkspaceInstanceID uuid.UUID `gorm:"column:workspaceInstanceId;type:char;size:36;" json:"workspaceInstanceId"` + Draft bool `gorm:"column:draft;type:boolean;" json:"draft"` + Metadata datatypes.JSON `gorm:"column:metadata;type:text;size:65535" json:"metadata"` +} + +type FindUsageResult struct { + UsageEntries []Usage +} + +// TableName sets the insert table name for this struct type +func (u *Usage) TableName() string { + return "d_b_usage" +} + +func FindUsage(ctx context.Context, conn *gorm.DB, attributionId AttributionID, from, to VarcharTime, offset int64, limit int64) ([]Usage, error) { + db := conn.WithContext(ctx) + + var usageRecords []Usage + result := db. + WithContext(ctx). + Where("attributionId = ?", attributionId). + Where("? <= effectiveTime AND effectiveTime < ?", from.String(), to.String()). + Order("effectiveTime DESC"). + Offset(int(offset)). + Limit(int(limit)). + Find(&usageRecords) + if result.Error != nil { + return nil, fmt.Errorf("failed to get usage records: %s", result.Error) + } + return usageRecords, nil +} diff --git a/components/usage/pkg/db/usage_test.go b/components/usage/pkg/db/usage_test.go new file mode 100644 index 00000000000000..270f576a041db6 --- /dev/null +++ b/components/usage/pkg/db/usage_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package db_test + +import ( + "context" + "testing" + "time" + + "github.com/gitpod-io/gitpod/usage/pkg/db" + "github.com/gitpod-io/gitpod/usage/pkg/db/dbtest" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestFindUsageInRange(t *testing.T) { + conn := dbtest.ConnectForTests(t) + + start := time.Date(2022, 7, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2022, 8, 1, 0, 0, 0, 0, time.UTC) + + attributionID := db.NewTeamAttributionID(uuid.New().String()) + + entryBefore := dbtest.NewUsage(t, db.Usage{ + AttributionID: attributionID, + EffectiveTime: db.NewVarcharTime(start.Add(-1 * 23 * time.Hour)), + Draft: true, + }) + + entryInside := dbtest.NewUsage(t, db.Usage{ + AttributionID: attributionID, + EffectiveTime: db.NewVarcharTime(start.Add(2 * time.Minute)), + }) + + entryAfter := dbtest.NewUsage(t, db.Usage{ + AttributionID: attributionID, + EffectiveTime: db.NewVarcharTime(end.Add(2 * time.Hour)), + }) + + usageEntries := []db.Usage{entryBefore, entryInside, entryAfter} + dbtest.CreateUsageRecords(t, conn, usageEntries...) + listResult, err := db.FindUsage(context.Background(), conn, attributionID, db.NewVarcharTime(start), db.NewVarcharTime(end), 0, 10) + require.NoError(t, err) + + require.Equal(t, 1, len(listResult)) + require.Equal(t, []db.Usage{entryInside}, listResult) +}