Some checks are pending
Docs Deploy / build_and_deploy (push) Waiting to run
Generate Docs / cli (push) Waiting to run
Generate Config Doc / cli (push) Waiting to run
Go formatting / go-formatting (push) Waiting to run
Check links / markdown-link-check (push) Waiting to run
Integration / pre-test (push) Waiting to run
Integration / test on (push) Blocked by required conditions
Integration / status (push) Blocked by required conditions
Lint / Lint Go code (push) Waiting to run
Test / test (ubuntu-latest) (push) Waiting to run
424 lines
9.2 KiB
Go
424 lines
9.2 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"github.com/lib/pq"
|
|
|
|
ctypes "github.com/cometbft/cometbft/rpc/core/types"
|
|
|
|
"github.com/ignite/cli/v29/ignite/pkg/cosmosclient"
|
|
"github.com/ignite/cli/v29/ignite/pkg/cosmostxcollector/query"
|
|
"github.com/ignite/cli/v29/ignite/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
DefaultPort = 5432
|
|
DefaultHost = "127.0.0.1"
|
|
)
|
|
|
|
const (
|
|
adapterType = "postgres"
|
|
|
|
sqlSelectBlockHeight = `
|
|
SELECT COALESCE(MAX(height), 0)
|
|
FROM tx
|
|
`
|
|
sqlSelectEventAttrs = `
|
|
SELECT event_id, name, value FROM attribute
|
|
WHERE event_id = ANY($1)
|
|
ORDER BY event_id
|
|
`
|
|
sqlInsertTX = `
|
|
INSERT INTO tx (hash, index, height, block_time)
|
|
VALUES ($1, $2, $3, $4)
|
|
`
|
|
sqlInsertEvent = `
|
|
INSERT INTO event (tx_hash, type, index)
|
|
VALUES ($1, $2, $3) RETURNING id
|
|
`
|
|
sqlInsertEventAttr = `
|
|
INSERT INTO attribute (event_id, name, value)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
sqlInsertRawTX = `
|
|
INSERT INTO raw_tx (hash, data)
|
|
VALUES ($1, $2)
|
|
`
|
|
)
|
|
|
|
//go:embed schemas/*
|
|
var fsSchemas embed.FS
|
|
|
|
// ErrClosed is returned when database connection is not open.
|
|
var ErrClosed = errors.New("no database connection")
|
|
|
|
// Option defines an option for the adapter.
|
|
type Option func(*Adapter)
|
|
|
|
// WithHost configures a database host name or IP.
|
|
func WithHost(host string) Option {
|
|
return func(a *Adapter) {
|
|
a.host = host
|
|
}
|
|
}
|
|
|
|
// WithPort configures a database port.
|
|
func WithPort(port uint) Option {
|
|
return func(a *Adapter) {
|
|
a.port = port
|
|
}
|
|
}
|
|
|
|
// WithUser configures a database user.
|
|
func WithUser(user string) Option {
|
|
return func(a *Adapter) {
|
|
a.user = user
|
|
}
|
|
}
|
|
|
|
// WithPassword configures a database password.
|
|
func WithPassword(password string) Option {
|
|
return func(a *Adapter) {
|
|
a.password = password
|
|
}
|
|
}
|
|
|
|
// WithParams configures extra database parameters.
|
|
func WithParams(params map[string]string) Option {
|
|
return func(a *Adapter) {
|
|
a.params = params
|
|
}
|
|
}
|
|
|
|
// NewAdapter creates a new PostgreSQL adapter.
|
|
func NewAdapter(database string, options ...Option) (Adapter, error) {
|
|
adapter := Adapter{
|
|
host: DefaultHost,
|
|
port: DefaultPort,
|
|
database: database,
|
|
schemas: NewSchemas(fsSchemas, ""),
|
|
}
|
|
|
|
for _, o := range options {
|
|
o(&adapter)
|
|
}
|
|
|
|
db, err := sql.Open("postgres", createPostgresURI(adapter))
|
|
if err != nil {
|
|
return Adapter{}, err
|
|
}
|
|
|
|
adapter.db = db
|
|
|
|
return adapter, nil
|
|
}
|
|
|
|
// Adapter implements a data backend adapter for PostgreSQL.
|
|
type Adapter struct {
|
|
host, user, password, database string
|
|
port uint
|
|
params map[string]string
|
|
db *sql.DB
|
|
schemas Schemas
|
|
}
|
|
|
|
// UpdateSchema updates the database schema to the latest version available.
|
|
// It applies all available schemas that were not applied already.
|
|
func (a Adapter) UpdateSchema(ctx context.Context, s Schemas) error {
|
|
db, err := a.getDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create the schema table if it doesn't exist
|
|
if _, err := db.ExecContext(ctx, s.GetTableDDL()); err != nil {
|
|
return errors.Errorf("failed to check schema table: %w", err)
|
|
}
|
|
|
|
// Get the current schema version
|
|
var v uint64
|
|
if err := db.QueryRowContext(ctx, s.GetSchemaVersionSQL()).Scan(&v); err != nil {
|
|
return errors.Errorf("failed to read current schema version: %w", err)
|
|
}
|
|
|
|
return s.WalkFrom(v+1, func(version uint64, script []byte) error {
|
|
if _, err := db.ExecContext(ctx, string(script)); err != nil {
|
|
return errors.Errorf("error applying schema version %d: %w", version, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (a Adapter) GetType() string {
|
|
return adapterType
|
|
}
|
|
|
|
func (a Adapter) Init(ctx context.Context) error {
|
|
return a.UpdateSchema(ctx, a.schemas)
|
|
}
|
|
|
|
func (a Adapter) Save(ctx context.Context, txs []cosmosclient.TX) error {
|
|
db, err := a.getDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start a transaction
|
|
sqlTx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Rollback won't have any effect if the transaction is committed before
|
|
defer sqlTx.Rollback() //nolint:errcheck
|
|
|
|
// Prepare insert statements to speed up "bulk" saving times
|
|
txStmt, err := sqlTx.PrepareContext(ctx, sqlInsertTX)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer txStmt.Close()
|
|
|
|
evtStmt, err := sqlTx.PrepareContext(ctx, sqlInsertEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer evtStmt.Close()
|
|
|
|
attrStmt, err := sqlTx.PrepareContext(ctx, sqlInsertEventAttr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer attrStmt.Close()
|
|
|
|
// All the transactions are saved within the context of the same database
|
|
// transactions and because of that either all block transactions are
|
|
// saved or none of them.
|
|
for _, tx := range txs {
|
|
if err := saveRawTX(ctx, sqlTx, tx.Raw); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := saveTX(ctx, txStmt, evtStmt, attrStmt, tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return sqlTx.Commit()
|
|
}
|
|
|
|
func (a Adapter) GetLatestHeight(ctx context.Context) (height int64, err error) {
|
|
db, err := a.getDB()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
row := db.QueryRowContext(ctx, sqlSelectBlockHeight)
|
|
if err = row.Scan(&height); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return height, nil
|
|
}
|
|
|
|
func (a Adapter) QueryEvents(ctx context.Context, q query.EventQuery) ([]query.Event, error) {
|
|
db, err := a.getDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sql := parseEventQuery(q)
|
|
args := extractEventQueryArgs(q)
|
|
rows, err := db.QueryContext(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
events []query.Event
|
|
eventIDs []int64
|
|
|
|
// Keep an index of the event position within the events slice
|
|
// to find them later when updating their attributes.
|
|
eventIndexes = make(map[int64]int)
|
|
)
|
|
|
|
for i := 0; rows.Next(); i++ {
|
|
e := query.Event{}
|
|
if err := rows.Scan(&e.ID, &e.Index, &e.TXHash, &e.Type, &e.CreatedAt); err != nil {
|
|
return nil, errors.Errorf("failed to read event: %w", err)
|
|
}
|
|
|
|
events = append(events, e)
|
|
eventIDs = append(eventIDs, e.ID)
|
|
|
|
eventIndexes[e.ID] = i
|
|
}
|
|
|
|
// Don't query attributes when there are no events
|
|
if len(events) == 0 {
|
|
return events, nil
|
|
}
|
|
|
|
// Select the attributes for the events that matched the query
|
|
rows, err = db.QueryContext(ctx, sqlSelectEventAttrs, pq.Array(eventIDs))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update the attributes of the selected events
|
|
for rows.Next() {
|
|
var (
|
|
eventID int64
|
|
name string
|
|
value []byte
|
|
)
|
|
|
|
if err := rows.Scan(&eventID, &name, &value); err != nil {
|
|
return nil, errors.Errorf("failed to read event attribute: %w", err)
|
|
}
|
|
|
|
i := eventIndexes[eventID]
|
|
events[i].Attributes = append(events[i].Attributes, query.NewAttribute(name, value))
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
func (a Adapter) Query(ctx context.Context, q query.Query) (query.Cursor, error) {
|
|
db, err := a.getDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sql, err := parseQuery(q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
args := extractQueryArgs(q)
|
|
rows, err := db.QueryContext(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
func (a Adapter) getDB() (*sql.DB, error) {
|
|
if a.db == nil {
|
|
return nil, ErrClosed
|
|
}
|
|
|
|
return a.db, nil
|
|
}
|
|
|
|
func createPostgresURI(a Adapter) string {
|
|
uri := url.URL{
|
|
Scheme: adapterType,
|
|
Host: fmt.Sprintf("%s:%d", a.host, a.port),
|
|
Path: a.database,
|
|
}
|
|
|
|
if a.user != "" {
|
|
if a.password != "" {
|
|
uri.User = url.UserPassword(a.user, a.password)
|
|
} else {
|
|
uri.User = url.User(a.user)
|
|
}
|
|
}
|
|
|
|
// Add extra params as query arguments
|
|
if a.params != nil {
|
|
val := url.Values{}
|
|
for k, v := range a.params {
|
|
val.Set(k, v)
|
|
}
|
|
|
|
uri.RawQuery = val.Encode()
|
|
}
|
|
|
|
return uri.String()
|
|
}
|
|
|
|
func saveRawTX(ctx context.Context, sqlTx *sql.Tx, rtx *ctypes.ResultTx) error {
|
|
hash := rtx.Hash.String()
|
|
raw, err := json.Marshal(rtx)
|
|
if err != nil {
|
|
return errors.Errorf("failed to encode raw TX %s: %w", hash, err)
|
|
}
|
|
|
|
if _, err := sqlTx.ExecContext(ctx, sqlInsertRawTX, hash, raw); err != nil {
|
|
return errors.Errorf("error saving raw TX %s: %w", hash, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func saveTX(ctx context.Context, txStmt, evtStmt, attrStmt *sql.Stmt, tx cosmosclient.TX) error {
|
|
hash := tx.Raw.Hash.String()
|
|
if _, err := txStmt.ExecContext(ctx, hash, tx.Raw.Index, tx.Raw.Height, tx.BlockTime); err != nil {
|
|
return errors.Errorf("error saving TX %s: %w", hash, err)
|
|
}
|
|
|
|
events, err := tx.GetEvents()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i, evt := range events {
|
|
var evtID int
|
|
|
|
row := evtStmt.QueryRowContext(ctx, hash, evt.Type, i)
|
|
if err := row.Err(); err != nil {
|
|
return errors.Errorf("error saving event '%s': %w", evt.Type, err)
|
|
}
|
|
|
|
if err := row.Scan(&evtID); err != nil {
|
|
return errors.Errorf("error reading event ID: %w", err)
|
|
}
|
|
|
|
for _, attr := range evt.Attributes {
|
|
if _, err := attrStmt.ExecContext(ctx, evtID, attr.Key, attr.Value); err != nil {
|
|
return errors.Errorf("error saving event attr '%s.%s': %w", evt.Type, attr.Key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func extractQueryArgs(q query.Query) []any {
|
|
// When the query is a call to a postgres function
|
|
// add the arguments before the filter values
|
|
args := q.Args()
|
|
|
|
// Add the values from the filters
|
|
for _, f := range q.Filters() {
|
|
if a := f.Value(); a != nil {
|
|
args = append(args, a)
|
|
}
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
func extractEventQueryArgs(q query.EventQuery) (args []any) {
|
|
for _, f := range q.Filters() {
|
|
if a := f.Value(); a != nil {
|
|
args = append(args, a)
|
|
}
|
|
}
|
|
|
|
return args
|
|
}
|