diff --git a/agents/drivers/cassandra-go/main.go b/agents/drivers/cassandra-go/main.go index 0cd3c06cac..b12fc1a4d3 100644 --- a/agents/drivers/cassandra-go/main.go +++ b/agents/drivers/cassandra-go/main.go @@ -358,7 +358,8 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any case "list_foreign_keys": return []foreignKeyInfo{}, false, nil case "list_triggers": - return []triggerInfo{}, false, nil + result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table")) + return result, false, err case "get_object_source": return nil, false, errors.New("object source is not supported by Cassandra") case "get_table_ddl": diff --git a/agents/drivers/cassandra-go/metadata.go b/agents/drivers/cassandra-go/metadata.go index b30387bd6d..0119eeac26 100644 --- a/agents/drivers/cassandra-go/metadata.go +++ b/agents/drivers/cassandra-go/metadata.go @@ -82,6 +82,15 @@ type triggerInfo struct { Timing string `json:"timing"` } +const cassandraListTriggersCQL = "SELECT trigger_name, options FROM system_schema.triggers WHERE keyspace_name = ? AND table_name = ?" + +type metadataIterator interface { + Scan(...any) bool + Close() error +} + +type metadataQuery func(string, ...any) metadataIterator + type metadataListConstraints struct { Filter string Limit int @@ -321,6 +330,35 @@ func (s *server) querySystemIndexes(schema, table string) ([]indexInfo, error) { return result, nil } +func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) { + session, err := s.runtime.sessionFor("") + if err != nil { + return nil, err + } + return listTriggersWithQuery(func(query string, values ...any) metadataIterator { + return session.Query(query, values...).Iter() + }, schema, table) +} + +func listTriggersWithQuery(query metadataQuery, schema, table string) ([]triggerInfo, error) { + iter := query(cassandraListTriggersCQL, schema, table) + result := []triggerInfo{} + var name string + var options map[string]string + for iter.Scan(&name, &options) { + // Cassandra trigger metadata stores only the trigger class. Cassandra + // invokes every trigger before any DML statement, so expose those fixed + // semantics through the common TriggerInfo contract. + result = append(result, triggerInfo{Name: name, Event: "DML", Timing: "BEFORE"}) + options = nil + } + if err := iter.Close(); err != nil { + return nil, err + } + sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name }) + return result, nil +} + func mergeIndexes(first, second []indexInfo) []indexInfo { byName := make(map[string]indexInfo, len(first)+len(second)) for _, index := range first { diff --git a/agents/drivers/cassandra-go/metadata_test.go b/agents/drivers/cassandra-go/metadata_test.go index 263b7279a2..fe43e15c83 100644 --- a/agents/drivers/cassandra-go/metadata_test.go +++ b/agents/drivers/cassandra-go/metadata_test.go @@ -1,12 +1,37 @@ package main import ( + "errors" "reflect" "testing" gocql "github.com/apache/cassandra-gocql-driver/v2" ) +type triggerMetadataIterator struct { + rows []struct { + name string + options map[string]string + } + index int + closeErr error +} + +func (i *triggerMetadataIterator) Scan(dest ...any) bool { + if i.index >= len(i.rows) { + return false + } + row := i.rows[i.index] + i.index++ + *dest[0].(*string) = row.name + *dest[1].(*map[string]string) = row.options + return true +} + +func (i *triggerMetadataIterator) Close() error { + return i.closeErr +} + func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) { textType := gocql.NewNativeType(4, gocql.TypeVarchar, "") intType := gocql.NewNativeType(4, gocql.TypeInt, "") @@ -84,3 +109,53 @@ func TestQuoteCQLIdentifierEscapesQuotes(t *testing.T) { t.Fatalf("unexpected quoted identifier: %s", got) } } + +func TestListTriggersQueriesExactTableAndMapsMetadata(t *testing.T) { + var statement string + var values []any + triggers, err := listTriggersWithQuery(func(query string, args ...any) metadataIterator { + statement = query + values = args + return &triggerMetadataIterator{rows: []struct { + name string + options map[string]string + }{ + {name: "capture_changes", options: map[string]string{"class": "example.CaptureDataTrigger"}}, + }} + }, "dev", "example") + if err != nil { + t.Fatal(err) + } + if statement != cassandraListTriggersCQL { + t.Fatalf("unexpected trigger query: %s", statement) + } + if !reflect.DeepEqual(values, []any{"dev", "example"}) { + t.Fatalf("trigger filters = %#v", values) + } + want := []triggerInfo{{Name: "capture_changes", Event: "DML", Timing: "BEFORE"}} + if !reflect.DeepEqual(triggers, want) { + t.Fatalf("triggers = %#v, want %#v", triggers, want) + } +} + +func TestListTriggersReturnsEmptySlice(t *testing.T) { + triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator { + return &triggerMetadataIterator{} + }, "dev", "empty_table") + if err != nil { + t.Fatal(err) + } + if triggers == nil || len(triggers) != 0 { + t.Fatalf("triggers = %#v, want a non-nil empty slice", triggers) + } +} + +func TestListTriggersReturnsQueryError(t *testing.T) { + wantErr := errors.New("trigger metadata unavailable") + triggers, err := listTriggersWithQuery(func(string, ...any) metadataIterator { + return &triggerMetadataIterator{closeErr: wantErr} + }, "dev", "example") + if !errors.Is(err, wantErr) || triggers != nil { + t.Fatalf("triggers = %#v, err = %v", triggers, err) + } +}