Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
514 lines
16 KiB
Go
514 lines
16 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package sql_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cockroachdb/cockroach-go/v2/testserver"
|
|
"github.com/pkg/errors"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
continuity "github.com/ory/kratos/continuity/test"
|
|
"github.com/ory/kratos/corpx"
|
|
courier "github.com/ory/kratos/courier/test"
|
|
"github.com/ory/kratos/driver/config"
|
|
ri "github.com/ory/kratos/identity"
|
|
identity "github.com/ory/kratos/identity/test"
|
|
"github.com/ory/kratos/persistence/sql"
|
|
"github.com/ory/kratos/persistence/sql/batch"
|
|
sqltesthelpers "github.com/ory/kratos/persistence/sql/testhelpers"
|
|
"github.com/ory/kratos/pkg"
|
|
"github.com/ory/kratos/pkg/testhelpers"
|
|
"github.com/ory/kratos/schema"
|
|
errorx "github.com/ory/kratos/selfservice/errorx/test"
|
|
lf "github.com/ory/kratos/selfservice/flow/login"
|
|
login "github.com/ory/kratos/selfservice/flow/login/test"
|
|
recovery "github.com/ory/kratos/selfservice/flow/recovery/test"
|
|
registration "github.com/ory/kratos/selfservice/flow/registration/test"
|
|
settings "github.com/ory/kratos/selfservice/flow/settings/test"
|
|
verification "github.com/ory/kratos/selfservice/flow/verification/test"
|
|
sessiontokenexchange "github.com/ory/kratos/selfservice/sessiontokenexchange/test"
|
|
code "github.com/ory/kratos/selfservice/strategy/code/test"
|
|
link "github.com/ory/kratos/selfservice/strategy/link/test"
|
|
session "github.com/ory/kratos/session/test"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/pop/v6"
|
|
"github.com/ory/pop/v6/logging"
|
|
"github.com/ory/x/configx"
|
|
"github.com/ory/x/dbal"
|
|
"github.com/ory/x/popx"
|
|
"github.com/ory/x/sqlcon"
|
|
"github.com/ory/x/sqlcon/dockertest"
|
|
"github.com/ory/x/sqlxx"
|
|
"github.com/ory/x/urlx"
|
|
)
|
|
|
|
func init() {
|
|
corpx.RegisterFakes()
|
|
pop.SetNowFunc(func() time.Time {
|
|
return time.Now().UTC().Round(time.Second)
|
|
})
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func pl(t testing.TB) func(lvl logging.Level, s string, args ...interface{}) {
|
|
return func(lvl logging.Level, s string, args ...interface{}) {
|
|
if pop.Debug == false {
|
|
return
|
|
}
|
|
|
|
if lvl == logging.SQL {
|
|
if len(args) > 0 {
|
|
xargs := make([]string, len(args))
|
|
for i, a := range args {
|
|
switch a.(type) {
|
|
case string:
|
|
xargs[i] = fmt.Sprintf("%q", a)
|
|
default:
|
|
xargs[i] = fmt.Sprintf("%v", a)
|
|
}
|
|
}
|
|
s = fmt.Sprintf("%s - %s | %s", lvl, s, xargs)
|
|
} else {
|
|
s = fmt.Sprintf("%s - %s", lvl, s)
|
|
}
|
|
} else {
|
|
s = fmt.Sprintf(s, args...)
|
|
s = fmt.Sprintf("%s - %s", lvl, s)
|
|
}
|
|
t.Log(s)
|
|
}
|
|
}
|
|
|
|
var dbNames = []string{"sqlite", "postgres", "mysql", "cockroach"}
|
|
|
|
func setupDatabase(t testing.TB, name string) string {
|
|
var dsn string
|
|
switch name {
|
|
case "sqlite":
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
case "postgres":
|
|
dsn = dockertest.RunTestPostgreSQLWithVersion(t, "16")
|
|
case "mysql":
|
|
dsn = dockertest.RunTestMySQLWithVersion(t, "8.4")
|
|
case "cockroach":
|
|
dsn = newLocalTestCRDBServer(t)
|
|
default:
|
|
t.Fatalf("unknown database: %s", name)
|
|
}
|
|
|
|
if name != "sqlite" {
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
c, err := pop.NewConnection(&pop.ConnectionDetails{URL: dsn})
|
|
require.NoError(t, err)
|
|
require.NoError(t, c.Open())
|
|
dbName := "testdb" + strings.ReplaceAll(x.NewUUID().String(), "-", "")
|
|
require.NoError(t, c.RawQuery("CREATE DATABASE "+dbName).Exec())
|
|
dsn = regexp.MustCompile(`/[a-z0-9]+\?`).ReplaceAllString(dsn, "/"+dbName+"?")
|
|
}, 60*time.Second, 200*time.Millisecond)
|
|
}
|
|
|
|
t.Logf("Connecting to %s: %s", name, dsn)
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn)
|
|
p := reg.Persister().(*sql.Persister)
|
|
|
|
t.Logf("Applying %s migrations", name)
|
|
pop.SetLogger(pl(t))
|
|
require.NoError(t, p.MigrateUp(t.Context()))
|
|
t.Logf("%s migrations applied", name)
|
|
status, err := p.MigrationStatus(t.Context())
|
|
require.NoError(t, err)
|
|
require.False(t, status.HasPending())
|
|
|
|
t.Logf("Database %s initialized successfully", name)
|
|
return dsn
|
|
}
|
|
|
|
func TestPersister(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := t.Context()
|
|
// Bake the default identity schema into each registry's base config below
|
|
// instead of layering it onto ctx. A context overlay makes
|
|
// TestConfigProvider.Config rebuild the whole koanf tree on every config read
|
|
// (the same reason identity.TestPool bakes PoolConfigValues), which the
|
|
// contracts here would otherwise pay on every read.
|
|
defaultSchema := configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json"))
|
|
|
|
for _, name := range dbNames {
|
|
t.Run(fmt.Sprintf("database=%s", name), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if name != "sqlite" && testing.Short() {
|
|
t.Skip("skipping non-sqlite under -short")
|
|
}
|
|
|
|
dsn := setupDatabase(t, name)
|
|
t.Logf("DSN: %s", dsn)
|
|
|
|
t.Run("racy identity creation", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
// Pre-run migrations so concurrent goroutine startups below
|
|
// don't race to apply the same migrations on a fresh DB.
|
|
pkg.NewRegistryDefaultWithDSN(t, dsn)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for i := range 10 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, ps := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
id := ri.NewIdentity("")
|
|
id.SetCredentials(ri.CredentialsTypePassword, ri.Credentials{
|
|
Type: ri.CredentialsTypePassword,
|
|
Identifiers: []string{fmt.Sprintf("racy identity %d", i)},
|
|
Config: sqlxx.JSONRawMessage(`{"foo":"bar"}`),
|
|
})
|
|
id.Traits = ri.Traits("{}")
|
|
|
|
require.NoError(t, ps.CreateIdentity(ctx, id))
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
})
|
|
|
|
t.Run("case=credential types exist", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
for _, ct := range []ri.CredentialsType{ri.CredentialsTypeOIDC, ri.CredentialsTypePassword} {
|
|
require.NoError(t, p.(*sql.Persister).Connection(t.Context()).Where("name = ?", ct).First(&ri.CredentialsTypeTable{}))
|
|
}
|
|
})
|
|
|
|
t.Run("contract=identity.TestPool", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
// Unlike the sibling contracts (which bake only the single default
|
|
// identity schema via defaultSchema), this contract needs the full
|
|
// PoolConfigValues schema set baked into the registry's base config.
|
|
ctx := t.Context()
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, configx.WithValues(identity.PoolConfigValues()))
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
identity.TestPool(ctx, p, reg.IdentityManager(), name)(t)
|
|
})
|
|
t.Run("contract=registration.TestFlowPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
registration.TestFlowPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=errorx.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
errorx.TestPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=login.TestFlowPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
login.TestFlowPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=settings.TestFlowPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
settings.TestFlowPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=session.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
session.TestPersister(ctx, reg.Config(), p)(t)
|
|
})
|
|
t.Run("contract=sessiontokenexchange.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
sessiontokenexchange.TestPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=courier.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
upsert, insert := sqltesthelpers.DefaultNetworkWrapper(p)
|
|
courier.TestPersister(ctx, upsert, insert)(t)
|
|
})
|
|
t.Run("contract=verification.TestFlowPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
verification.TestFlowPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=recovery.TestFlowPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
recovery.TestFlowPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=link.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
link.TestPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=code.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
code.TestPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=continuity.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
continuity.TestPersister(ctx, p)(t)
|
|
})
|
|
t.Run("contract=batch.TestPersister", func(t *testing.T) {
|
|
t.Parallel()
|
|
dsn := dsn
|
|
// Just have a separate DB for sqlite to speed it up.
|
|
if name == "sqlite" {
|
|
dsn = dbal.NewSQLiteTestDatabase(t)
|
|
}
|
|
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(t, dsn, defaultSchema)
|
|
_, p := testhelpers.NewNetwork(t, ctx, reg.Persister())
|
|
batch.TestPersister(ctx, reg.Tracer(ctx), p)(t)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func getErr(args ...interface{}) error {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
lastArg := args[len(args)-1]
|
|
if e, ok := lastArg.(error); ok {
|
|
return e
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestPersister_Transaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, reg := pkg.NewFastRegistryWithMocks(t)
|
|
p := reg.Persister()
|
|
|
|
t.Run("case=should not create identity because callback returned error", func(t *testing.T) {
|
|
i := &ri.Identity{
|
|
ID: x.NewUUID(),
|
|
State: ri.StateActive,
|
|
Traits: ri.Traits(`{}`),
|
|
}
|
|
errMessage := "failing because why not"
|
|
err := p.Transaction(t.Context(), func(_ context.Context, connection *pop.Connection) error {
|
|
require.NoError(t, connection.Create(i))
|
|
return errors.New(errMessage)
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), errMessage)
|
|
_, err = p.GetIdentity(t.Context(), i.ID, ri.ExpandNothing)
|
|
require.Error(t, err)
|
|
assert.Equal(t, sqlcon.ErrNoRows().Error(), err.Error())
|
|
})
|
|
|
|
t.Run("case=functions should use the context connection", func(t *testing.T) {
|
|
c := p.GetConnection(t.Context())
|
|
errMessage := "some stupid error you can't debug"
|
|
lr := &lf.Flow{
|
|
ID: x.NewUUID(),
|
|
}
|
|
err := c.Transaction(func(tx *pop.Connection) error {
|
|
ctx := popx.WithTransaction(t.Context(), tx)
|
|
require.NoError(t, p.CreateLoginFlow(ctx, lr), "%+v", lr)
|
|
require.NoError(t, getErr(p.GetLoginFlow(ctx, lr.ID)), "%+v", lr)
|
|
return errors.New(errMessage)
|
|
})
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), errMessage)
|
|
_, err = p.GetLoginFlow(t.Context(), lr.ID)
|
|
require.Error(t, err)
|
|
assert.Equal(t, sqlcon.ErrNoRows().Error(), err.Error())
|
|
})
|
|
}
|
|
|
|
func Benchmark_BatchCreateIdentities(b *testing.B) {
|
|
ctx := b.Context()
|
|
batchSizes := []int{1, 10, 100, 500, 800, 900, 1000, 2000, 3000}
|
|
parallelRequests := []int{1, 4, 8, 16}
|
|
|
|
for _, name := range dbNames {
|
|
b.Run(fmt.Sprintf("database=%s", name), func(b *testing.B) {
|
|
dsn := setupDatabase(b, name)
|
|
_, reg := pkg.NewRegistryDefaultWithDSN(b, dsn)
|
|
conf := reg.Config()
|
|
_, p := testhelpers.NewNetwork(b, ctx, reg.Persister())
|
|
multipleEmailsSchema := schema.Schema{
|
|
ID: "multiple_emails",
|
|
URL: urlx.ParseOrPanic("file://./stub/handler/multiple_emails.schema.json"),
|
|
RawURL: "file://./stub/identity-2.schema.json",
|
|
}
|
|
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, []config.Schema{
|
|
{
|
|
ID: multipleEmailsSchema.ID,
|
|
URL: multipleEmailsSchema.RawURL,
|
|
},
|
|
})
|
|
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "http://localhost/")
|
|
|
|
run := 0
|
|
for _, batchSize := range batchSizes {
|
|
b.Run(fmt.Sprintf("batch-size=%d", batchSize), func(b *testing.B) {
|
|
for _, paralellism := range parallelRequests {
|
|
b.Run(fmt.Sprintf("parallelism=%d", paralellism), func(b *testing.B) {
|
|
start := time.Now()
|
|
for i := 0; i < b.N; i++ {
|
|
wg := new(errgroup.Group)
|
|
for paralell := 0; paralell < paralellism; paralell++ {
|
|
paralell := paralell
|
|
wg.Go(func() error {
|
|
identities := make([]*ri.Identity, batchSize)
|
|
prefix := fmt.Sprintf("bench-insert-run-%d", run+paralell)
|
|
for j := range identities {
|
|
identities[j] = identity.NewTestIdentity(1, prefix, j)
|
|
}
|
|
|
|
return p.CreateIdentities(ctx, identities)
|
|
})
|
|
}
|
|
assert.NoError(b, wg.Wait())
|
|
run += paralellism
|
|
}
|
|
end := time.Now()
|
|
b.ReportMetric(float64(paralellism*batchSize*b.N), "identites_created")
|
|
b.ReportMetric(float64(paralellism*batchSize*b.N)/end.Sub(start).Seconds(), "identities/s")
|
|
})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newLocalTestCRDBServer(t testing.TB) string {
|
|
ts, err := testserver.NewTestServer(testserver.CustomVersionOpt("v25.4.1"))
|
|
require.NoError(t, err)
|
|
t.Cleanup(ts.Stop)
|
|
|
|
require.NoError(t, ts.WaitForInit())
|
|
|
|
ts.PGURL().Scheme = "cockroach"
|
|
return ts.PGURL().String()
|
|
}
|