featurebase/dax/controller/schemar/boltdb/schemar.go
Travis Turner 87011e4294
CLI: make it more like psql (#2235)
* Refactor CLI to mimic psql's meta-commands

This PR adds support for meta-commands (also known as "backslash
commands") like those in psql, Postgres's CLI. Only a few meta-commands
are currently implemented, but this was meant to demonstrate how we
could use something like `\i file.csv` to insert local files into SQL
statements.

* Meta-commands: \file and \include

The initial implementation used `\i` as a streaming file handle.

This commit changes that to `\file`, and then implements `\i` (or
`\include`) as handling multiple sql commands.

* Add meta-command "help" (\?)

This is basically a copy of the psql help output, but includes only
those options we currently support.

* Add support for \o [file], and \timing

The \o meta-command writes query output to a file.

The \timing meta-command turns on/off the timing display sent to stdout.

* Add meta-commands: \l (show databases) and \dt (show tables)

* Add meta-command: \watch [period]

* Update meta-command \connect to take database name instead of ID

* Add support for \echo, \qecho, and \warn

This commit contains an known issue in that the `-n` option will exclude
the line feed, but if the output is the terminal, the readline package
clobbers any content on the current line (i.e. anything without a line
feed). That will need to be addressed at some point.

* Add support for \w [FILE] (write query buffer to file)

* Add SchemaAPI no-op implementation

* Refactor query handler to align with /sql and /databases endpoints

We want to standardize on:
/sql
/databases/{databaseID}/sql

* Add CLI support for expanded, border, tuples_only (and pset)

* Add help text for \pset and \t
2023-02-14 09:23:51 -06:00

698 lines
20 KiB
Go

// Package boltdb contains the boltdb implementation of the Schemar
// interfaces.
package boltdb
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/featurebasedb/featurebase/v3/dax"
"github.com/featurebasedb/featurebase/v3/dax/boltdb"
"github.com/featurebasedb/featurebase/v3/dax/controller/schemar"
"github.com/featurebasedb/featurebase/v3/errors"
"github.com/featurebasedb/featurebase/v3/logger"
)
var (
bucketSchemar = boltdb.Bucket("schemar")
)
// SchemarBuckets defines the buckets used by this package. It can be called
// during setup to create the buckets ahead of time.
var SchemarBuckets []boltdb.Bucket = []boltdb.Bucket{
bucketSchemar,
}
// Ensure type implements interface.
var _ schemar.Schemar = (*Schemar)(nil)
type Schemar struct {
db *boltdb.DB
logger logger.Logger
}
// NewSchemar returns a new instance of Schemar with default values.
func NewSchemar(db *boltdb.DB, logger logger.Logger) *Schemar {
return &Schemar{
db: db,
logger: logger,
}
}
// CreateDatabase creates the database provided. If a database with the same
// name already exists then an error is returned. For now, we are not going to
// store the tables in the schemar Database struct.
func (s *Schemar) CreateDatabase(tx dax.Transaction, qdb *dax.QualifiedDatabase) error {
// Ensure the database id is not blank.
if qdb.ID == "" {
return schemar.NewErrDatabaseIDInvalid(qdb.ID)
}
// Ensure the database name is not blank.
if qdb.Name == "" {
return schemar.NewErrDatabaseNameInvalid(qdb.Name)
}
// Set the CreateAt value for the database.
// TODO(tlt): We may want to consider erroring here if the value is != 0.
if qdb.CreatedAt == 0 {
now := timestamp()
qdb.CreatedAt = now
}
//////////// end validation
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Ensure a database with that ID doesn't already exist.
if db, _ := s.databaseByID(txx, qdb.OrganizationID, qdb.ID); db != nil {
return dax.NewErrDatabaseIDExists(qdb.QualifiedID())
}
if err := s.putDatabase(txx, qdb); err != nil {
return errors.Wrap(err, "putting database")
}
// In addition to storing the database in databaseKey, we want to store a
// reverse-lookup (i.e. index) on database name to the databaseKey.
if err := s.putDatabaseName(txx, qdb); err != nil {
return errors.Wrap(err, "putting database name")
}
return nil
}
func (s *Schemar) DatabaseByID(tx dax.Transaction, qdbid dax.QualifiedDatabaseID) (*dax.QualifiedDatabase, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return nil, dax.NewErrInvalidTransaction()
}
return s.databaseByID(txx, qdbid.OrganizationID, qdbid.DatabaseID)
}
func (s *Schemar) databaseByID(tx *boltdb.Tx, orgID dax.OrganizationID, id dax.DatabaseID) (*dax.QualifiedDatabase, error) {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return nil, errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
b := bkt.Get(databaseKey(orgID, id))
if b == nil {
return nil, dax.NewErrDatabaseIDDoesNotExist(dax.QualifiedDatabaseID{OrganizationID: orgID, DatabaseID: id})
}
database := &dax.QualifiedDatabase{}
if err := json.Unmarshal(b, database); err != nil {
return nil, errors.Wrap(err, "unmarshalling database json")
}
return database, nil
}
func (s *Schemar) DatabaseByName(tx dax.Transaction, orgID dax.OrganizationID, dbname dax.DatabaseName) (*dax.QualifiedDatabase, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return nil, dax.NewErrInvalidTransaction()
}
return s.databaseByName(txx, orgID, dbname)
}
func (s *Schemar) databaseByName(tx *boltdb.Tx, orgID dax.OrganizationID, name dax.DatabaseName) (*dax.QualifiedDatabase, error) {
qdbid, err := s.databaseIDByName(tx, orgID, name)
if err != nil {
return nil, errors.Wrap(err, "getting database ID")
}
return s.databaseByID(tx, orgID, qdbid.DatabaseID)
}
func (s *Schemar) databaseIDByName(tx *boltdb.Tx, orgID dax.OrganizationID, name dax.DatabaseName) (dax.QualifiedDatabaseID, error) {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return dax.QualifiedDatabaseID{}, errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
b := bkt.Get(databaseNameKey(orgID, name))
if b == nil {
return dax.QualifiedDatabaseID{}, dax.NewErrDatabaseNameDoesNotExist(name)
}
return keyQualifiedDatabaseID(b)
}
func (s *Schemar) putDatabase(tx *boltdb.Tx, qdb *dax.QualifiedDatabase) error {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
val, err := json.Marshal(qdb)
if err != nil {
return errors.Wrap(err, "marshalling database to json")
}
return bkt.Put(databaseKey(qdb.OrganizationID, qdb.ID), val)
}
func (s *Schemar) putDatabaseName(tx *boltdb.Tx, qdb *dax.QualifiedDatabase) error {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
return bkt.Put(databaseNameKey(qdb.OrganizationID, qdb.Name), databaseKey(qdb.OrganizationID, qdb.ID))
}
// DropDatabase drops the given database. If the named/IDed database does not
// exist then an error is returned.
func (s *Schemar) DropDatabase(tx dax.Transaction, qdbid dax.QualifiedDatabaseID) error {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Ensure the database exists.
qdb, err := s.databaseByID(txx, qdbid.OrganizationID, qdbid.DatabaseID)
if err != nil {
return errors.Wrap(err, "getting database by id")
}
bkt := txx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
// Delete the database by ID.
if err := bkt.Delete(databaseKey(qdb.OrganizationID, qdb.ID)); err != nil {
return errors.Wrap(err, "deleting database by id")
}
// Delete the reverse-lookup database by Name.
if err := bkt.Delete(databaseNameKey(qdb.OrganizationID, qdb.Name)); err != nil {
return errors.Wrap(err, "deleting database by name")
}
return nil
}
// SetDatabaseOption overwrites the existing database option with the provided
// value.
func (s *Schemar) SetDatabaseOption(tx dax.Transaction, qdbid dax.QualifiedDatabaseID, option string, value string) error {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Get the database.
qdb, err := s.databaseByID(txx, qdbid.OrganizationID, qdbid.DatabaseID)
if err != nil {
return errors.Wrapf(err, "getting database: %s", qdbid)
}
// Set the new option.
if err := qdb.Options.Set(option, value); err != nil {
return errors.Wrapf(err, "setting option on database: %s", qdbid)
}
// Put the database.
if err := s.putDatabase(txx, qdb); err != nil {
return errors.Wrap(err, "putting database")
}
return nil
}
func (s *Schemar) Databases(tx dax.Transaction, orgID dax.OrganizationID, ids ...dax.DatabaseID) ([]*dax.QualifiedDatabase, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return nil, dax.NewErrInvalidTransaction()
}
return s.getDatabases(txx, orgID, ids...)
}
func (s *Schemar) getDatabases(tx *boltdb.Tx, orgID dax.OrganizationID, ids ...dax.DatabaseID) (dax.QualifiedDatabases, error) {
c := tx.Bucket(bucketSchemar).Cursor()
// Deserialize rows into Database objects.
databases := make(dax.QualifiedDatabases, 0)
var filterByID bool
if len(ids) > 0 {
filterByID = true
}
prefix := []byte(fmt.Sprintf(prefixFmtDatabases, orgID))
if orgID == "" {
prefix = []byte(prefixDatabases)
}
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
if v == nil {
s.logger.Printf("nil value for key: %s", k)
continue
}
dbID, err := keyDatabaseID(k)
if err != nil {
return nil, errors.Wrap(err, "getting database from key")
}
// Only include databases provided in the ids filter.
if filterByID && !containsDatabaseID(ids, dbID) {
continue
}
database := &dax.QualifiedDatabase{}
if err := json.Unmarshal(v, database); err != nil {
return nil, errors.Wrap(err, "unmarshalling database json")
}
databases = append(databases, database)
}
return databases, nil
}
func containsDatabaseID(s []dax.DatabaseID, e dax.DatabaseID) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
// CreateTable creates the table provided. If a table with the same name already
// exists then an error is returned.
func (s *Schemar) CreateTable(tx dax.Transaction, qtbl *dax.QualifiedTable) error {
// Ensure the table id is not blank.
if qtbl.ID == "" {
return schemar.NewErrTableIDInvalid(qtbl.ID)
}
// Ensure the table name is not blank.
if qtbl.Name == "" {
return schemar.NewErrTableNameInvalid(qtbl.Name)
}
// Ensure that a primary key field is present and valid.
if !qtbl.HasValidPrimaryKey() {
return schemar.NewErrInvalidPrimaryKey()
}
// Set the CreateAt value for the table.
// TODO(tlt): We may want to consider erroring here if the value is != 0.
if qtbl.CreatedAt == 0 {
now := timestamp()
qtbl.CreatedAt = now
// Set CreatedAt for all of the fields as well.
for i := range qtbl.Fields {
qtbl.Fields[i].CreatedAt = now
}
}
//////////// end validation
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Ensure the database, defined in the table's QualifiedDatabaseID, exists.
if _, err := s.databaseByID(txx, qtbl.OrganizationID, qtbl.DatabaseID); err != nil {
return errors.Wrap(err, "validating database")
}
// Ensure a table with that ID doesn't already exist.
if t, _ := s.tableByID(txx, qtbl.QualifiedDatabaseID, qtbl.ID); t != nil {
return dax.NewErrTableIDExists(qtbl.QualifiedID())
}
if err := s.putTable(txx, qtbl); err != nil {
return errors.Wrap(err, "putting table")
}
// In addition to storing the table in tableKey, we want to store a reverse-lookup
// (i.e. index) on table name to the tableKey.
if err := s.putTableName(txx, qtbl); err != nil {
return errors.Wrap(err, "putting table name")
}
return nil
}
// CreateField creates the field provided in the given table. If a field with
// the same name already exists then an error is returned.
func (s *Schemar) CreateField(tx dax.Transaction, qtid dax.QualifiedTableID, fld *dax.Field) error {
// Ensure the field name is not blank.
if fld.Name == "" {
return schemar.NewErrFieldNameInvalid(fld.Name)
}
//////////// end validation
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Get the table.
qtbl, err := s.tableByQTID(txx, qtid)
if err != nil {
return errors.Wrap(err, "getting table by id")
}
// Ensure a field with that name doesn't already exist.
if _, ok := qtbl.Field(fld.Name); ok {
return dax.NewErrFieldExists(fld.Name)
}
qtbl.Fields = append(qtbl.Fields, fld)
// Write table back to database.
if err := s.putTable(txx, qtbl); err != nil {
return errors.Wrap(err, "putting table")
}
return nil
}
// DropField removes the field from the table.
func (s *Schemar) DropField(tx dax.Transaction, qtid dax.QualifiedTableID, fldName dax.FieldName) error {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Get the table.
qtbl, err := s.tableByQTID(txx, qtid)
if err != nil {
return errors.Wrap(err, "getting table by id")
}
// Ensure a field with that name exists.
if _, ok := qtbl.Field(fldName); !ok {
return dax.NewErrFieldDoesNotExist(fldName)
}
_ = qtbl.RemoveField(fldName)
// Write table back to database.
if err := s.putTable(txx, qtbl); err != nil {
return errors.Wrap(err, "putting table")
}
return nil
}
func (s *Schemar) putTable(tx *boltdb.Tx, qtbl *dax.QualifiedTable) error {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
val, err := json.Marshal(qtbl)
if err != nil {
return errors.Wrap(err, "marshalling table to json")
}
return bkt.Put(tableKey(qtbl.OrganizationID, qtbl.DatabaseID, qtbl.Table.ID), val)
}
func (s *Schemar) putTableName(tx *boltdb.Tx, qtbl *dax.QualifiedTable) error {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
return bkt.Put(tableNameKey(qtbl.OrganizationID, qtbl.DatabaseID, qtbl.Name), tableKey(qtbl.OrganizationID, qtbl.DatabaseID, qtbl.ID))
}
// Table returns the TableInfo for the given table. An error is returned if the
// table does not exist.
func (s *Schemar) Table(tx dax.Transaction, qtid dax.QualifiedTableID) (*dax.QualifiedTable, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return nil, dax.NewErrInvalidTransaction()
}
return s.tableByQTID(txx, qtid)
}
// tableByQTID gets the full qualified table by the QualifiedTableID whether it
// has Name or ID set.
func (s *Schemar) tableByQTID(tx *boltdb.Tx, qtid dax.QualifiedTableID) (*dax.QualifiedTable, error) {
if qtid.ID == "" {
return s.tableByName(tx, qtid.QualifiedDatabaseID, qtid.Name)
}
return s.tableByID(tx, qtid.QualifiedDatabaseID, qtid.ID)
}
func (s *Schemar) tableByName(tx *boltdb.Tx, qdbid dax.QualifiedDatabaseID, name dax.TableName) (*dax.QualifiedTable, error) {
qtid, err := s.tableIDByName(tx, qdbid, name)
if err != nil {
return nil, errors.Wrap(err, "getting table ID")
}
return s.tableByID(tx, qtid.QualifiedDatabaseID, qtid.ID) // TODO remove?
}
func (s *Schemar) tableByID(tx *boltdb.Tx, qdbid dax.QualifiedDatabaseID, id dax.TableID) (*dax.QualifiedTable, error) {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return nil, errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
b := bkt.Get(tableKey(qdbid.OrganizationID, qdbid.DatabaseID, id))
if b == nil {
return nil, dax.NewErrTableIDDoesNotExist(dax.QualifiedTableID{QualifiedDatabaseID: qdbid, ID: id})
}
table := &dax.QualifiedTable{}
if err := json.Unmarshal(b, table); err != nil {
return nil, errors.Wrap(err, "unmarshalling table json")
}
return table, nil
}
func (s *Schemar) tableIDByName(tx *boltdb.Tx, qdbid dax.QualifiedDatabaseID, name dax.TableName) (dax.QualifiedTableID, error) {
bkt := tx.Bucket(bucketSchemar)
if bkt == nil {
return dax.QualifiedTableID{}, errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
b := bkt.Get(tableNameKey(qdbid.OrganizationID, qdbid.DatabaseID, name))
if b == nil {
return dax.QualifiedTableID{}, dax.NewErrTableNameDoesNotExist(name)
}
return keyQualifiedTableID(b)
}
// Tables returns a list of Table for all existing tables. If one or more table
// IDs is provided, then only those will be included in the output.
func (s *Schemar) Tables(tx dax.Transaction, qdbid dax.QualifiedDatabaseID, ids ...dax.TableID) ([]*dax.QualifiedTable, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return nil, dax.NewErrInvalidTransaction()
}
return s.getTables(txx, qdbid, ids...)
}
func (s *Schemar) getTables(tx *boltdb.Tx, qdbid dax.QualifiedDatabaseID, ids ...dax.TableID) (dax.QualifiedTables, error) {
c := tx.Bucket(bucketSchemar).Cursor()
// Deserialize rows into Table objects.
tables := make(dax.QualifiedTables, 0)
var filterByID bool
if len(ids) > 0 {
filterByID = true
}
prefix := []byte(fmt.Sprintf(prefixFmtTables, qdbid.OrganizationID, qdbid.DatabaseID))
if qdbid.OrganizationID == "" && qdbid.DatabaseID == "" {
prefix = []byte(prefixTables)
} else if qdbid.DatabaseID == "" {
prefix = []byte(fmt.Sprintf(prefixFmtTablesOrg, qdbid.OrganizationID))
}
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
if v == nil {
s.logger.Printf("nil value for key: %s", k)
continue
}
tblID, err := keyTableID(k)
if err != nil {
return nil, errors.Wrap(err, "getting table from key")
}
// Only include tables provided in the ids filter.
if filterByID && !containsTableID(ids, tblID) {
continue
}
table := &dax.QualifiedTable{}
if err := json.Unmarshal(v, table); err != nil {
return nil, errors.Wrap(err, "unmarshalling table json")
}
tables = append(tables, table)
}
return tables, nil
}
func containsTableID(s []dax.TableID, e dax.TableID) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
// DropTable drops the given table. If the named/IDed table does not exist
// then an error is returned.
func (s *Schemar) DropTable(tx dax.Transaction, qtid dax.QualifiedTableID) error {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.NewErrInvalidTransaction()
}
// Ensure the table exists.
qtbl, err := s.tableByQTID(txx, qtid)
if err != nil {
return errors.Wrap(err, "getting table by id")
}
bkt := txx.Bucket(bucketSchemar)
if bkt == nil {
return errors.Errorf(boltdb.ErrFmtBucketNotFound, bucketSchemar)
}
// Delete the table by ID.
if err := bkt.Delete(tableKey(qtbl.OrganizationID, qtbl.DatabaseID, qtbl.ID)); err != nil {
return errors.Wrap(err, "deleting table by id")
}
// Delete the reverse-lookup table by Name.
if err := bkt.Delete(tableNameKey(qtbl.OrganizationID, qtbl.DatabaseID, qtbl.Name)); err != nil {
return errors.Wrap(err, "deleting table by name")
}
return nil
}
func (s *Schemar) TableID(tx dax.Transaction, qdbid dax.QualifiedDatabaseID, name dax.TableName) (dax.QualifiedTableID, error) {
txx, ok := tx.(*boltdb.Tx)
if !ok {
return dax.QualifiedTableID{}, dax.NewErrInvalidTransaction()
}
return s.tableIDByName(txx, qdbid, name)
}
const (
prefixTables = "tables/"
prefixFmtTablesOrg = prefixTables + "%s/" // org-id
prefixFmtTables = prefixFmtTablesOrg + "%s/" // db-id
prefixFmtTableNames = "tablenames/%s/%s/" // org-id, db-id
prefixDatabases = "databases/"
prefixFmtDatabases = prefixDatabases + "%s/" // org-id
prefixFmtDatabase = prefixFmtDatabases + "%s" // db-id
prefixFmtDatabaseNames = "databasenames/%s/" // org-id
)
// databaseKey returns a key based on a qualified database ID.
func databaseKey(orgID dax.OrganizationID, dbID dax.DatabaseID) []byte {
key := fmt.Sprintf(prefixFmtDatabase, orgID, dbID)
return []byte(key)
}
// databaseNameKey returns a key based on a qualified database name.
func databaseNameKey(orgID dax.OrganizationID, name dax.DatabaseName) []byte {
key := fmt.Sprintf(prefixFmtDatabaseNames+"%s", orgID, name)
return []byte(key)
}
// keyDatabaseID gets the DatabaseID out of the key.
func keyDatabaseID(key []byte) (dax.DatabaseID, error) {
parts := strings.Split(string(key), "/")
if len(parts) != 3 {
return "", errors.New(errors.ErrUncoded, "database key format expected: `databases/orgID/dbID`")
}
return dax.DatabaseID(parts[2]), nil
}
// tableKey returns a key based on a qualified table ID.
func tableKey(orgID dax.OrganizationID, dbID dax.DatabaseID, tblID dax.TableID) []byte {
key := fmt.Sprintf(prefixFmtTables+"%s", orgID, dbID, tblID)
return []byte(key)
}
// tableNameKey returns a key based on a qualified table name.
func tableNameKey(orgID dax.OrganizationID, dbID dax.DatabaseID, name dax.TableName) []byte {
key := fmt.Sprintf(prefixFmtTableNames+"%s", orgID, dbID, name)
return []byte(key)
}
// keyTableID gets the TableID out of the key.
func keyTableID(key []byte) (dax.TableID, error) {
parts := strings.Split(string(key), "/")
if len(parts) != 4 {
return "", errors.New(errors.ErrUncoded, "table key format expected: `tables/orgID/dbID/tblID`")
}
return dax.TableID(parts[3]), nil
}
// keyQualifedTableID gets the QualifiedTableID out of the key.
func keyQualifiedTableID(key []byte) (dax.QualifiedTableID, error) {
parts := strings.Split(string(key), "/")
if len(parts) != 4 {
return dax.QualifiedTableID{}, errors.New(errors.ErrUncoded, "table key format expected: `tables/orgID/dbID/tblID`")
}
return dax.NewQualifiedTableID(
dax.NewQualifiedDatabaseID(
dax.OrganizationID(parts[1]),
dax.DatabaseID(parts[2]),
),
dax.TableID(parts[3]),
), nil
}
// keyQualifedDatabaseID gets the QualifiedDatabaseID out of the key.
func keyQualifiedDatabaseID(key []byte) (dax.QualifiedDatabaseID, error) {
parts := strings.Split(string(key), "/")
if len(parts) != 3 {
return dax.QualifiedDatabaseID{}, errors.New(errors.ErrUncoded, "table key format expected: `databases/orgID/dbID`")
}
return dax.NewQualifiedDatabaseID(
dax.OrganizationID(parts[1]),
dax.DatabaseID(parts[2]),
), nil
}
func timestamp() int64 {
return time.Now().Unix()
}