diff --git a/core/go.mod b/core/go.mod index 796ebea6..05e2619a 100644 --- a/core/go.mod +++ b/core/go.mod @@ -4,6 +4,7 @@ go 1.22.1 require ( github.com/99designs/gqlgen v0.17.48 + github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 github.com/elastic/go-elasticsearch/v8 v8.14.0 github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 diff --git a/core/go.sum b/core/go.sum index 2541fc58..8d7437d2 100644 --- a/core/go.sum +++ b/core/go.sum @@ -10,6 +10,8 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= +github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= diff --git a/core/graph/model/models_gen.go b/core/graph/model/models_gen.go index 8d9422b1..f80cb431 100644 --- a/core/graph/model/models_gen.go +++ b/core/graph/model/models_gen.go @@ -99,6 +99,7 @@ const ( DatabaseTypeRedis DatabaseType = "Redis" DatabaseTypeElasticSearch DatabaseType = "ElasticSearch" DatabaseTypeMariaDb DatabaseType = "MariaDB" + DatabaseTypeMemcached DatabaseType = "Memcached" ) var AllDatabaseType = []DatabaseType{ @@ -109,11 +110,12 @@ var AllDatabaseType = []DatabaseType{ DatabaseTypeRedis, DatabaseTypeElasticSearch, DatabaseTypeMariaDb, + DatabaseTypeMemcached, } func (e DatabaseType) IsValid() bool { switch e { - case DatabaseTypePostgres, DatabaseTypeMySQL, DatabaseTypeSqlite3, DatabaseTypeMongoDb, DatabaseTypeRedis, DatabaseTypeElasticSearch, DatabaseTypeMariaDb: + case DatabaseTypePostgres, DatabaseTypeMySQL, DatabaseTypeSqlite3, DatabaseTypeMongoDb, DatabaseTypeRedis, DatabaseTypeElasticSearch, DatabaseTypeMariaDb, DatabaseTypeMemcached: return true } return false diff --git a/core/graph/schema.graphqls b/core/graph/schema.graphqls index c84ee163..514d680d 100644 --- a/core/graph/schema.graphqls +++ b/core/graph/schema.graphqls @@ -10,6 +10,7 @@ enum DatabaseType { Redis, ElasticSearch, MariaDB, + Memcached, } type Column { diff --git a/core/src/engine/engine.go b/core/src/engine/engine.go index b2a67150..c77e5d78 100644 --- a/core/src/engine/engine.go +++ b/core/src/engine/engine.go @@ -12,6 +12,7 @@ const ( DatabaseType_MongoDB = "MongoDB" DatabaseType_Redis = "Redis" DatabaseType_ElasticSearch = "ElasticSearch" + DatabaseType_Memcached = "Memcached" ) type Engine struct { diff --git a/core/src/plugins/memcached/add.go b/core/src/plugins/memcached/add.go new file mode 100644 index 00000000..34fbcea6 --- /dev/null +++ b/core/src/plugins/memcached/add.go @@ -0,0 +1,15 @@ +package memcached + +import ( + "errors" + + "github.com/clidey/whodb/core/src/engine" +) + +func (p *MemcachedPlugin) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields map[string]string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (p *MemcachedPlugin) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + return false, errors.ErrUnsupported +} diff --git a/core/src/plugins/memcached/db.go b/core/src/plugins/memcached/db.go new file mode 100644 index 00000000..56e7fe71 --- /dev/null +++ b/core/src/plugins/memcached/db.go @@ -0,0 +1,22 @@ +package memcached + +import ( + "fmt" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +func DB(config *engine.PluginConfig) (*memcache.Client, error) { + client := memcache.New(getAddress(config)) + if client == nil { + return nil, fmt.Errorf("failed to create Memcached client") + } + return client, nil +} + +func getAddress(config *engine.PluginConfig) string { + port := common.GetRecordValueOrDefault(config.Credentials.Advanced, "Port", "11211") + return fmt.Sprintf("%s:%s", config.Credentials.Hostname, port) +} diff --git a/core/src/plugins/memcached/delete.go b/core/src/plugins/memcached/delete.go new file mode 100644 index 00000000..38ea3346 --- /dev/null +++ b/core/src/plugins/memcached/delete.go @@ -0,0 +1,21 @@ +package memcached + +import ( + "github.com/bradfitz/gomemcache/memcache" + "github.com/clidey/whodb/core/src/engine" +) + +func (p *MemcachedPlugin) DeleteRow(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + defer client.Close() + + err = client.Delete(storageUnit) + if err != nil && err != memcache.ErrCacheMiss { + return false, err + } + + return true, nil +} diff --git a/core/src/plugins/memcached/memcached.go b/core/src/plugins/memcached/memcached.go new file mode 100644 index 00000000..aa45cf4a --- /dev/null +++ b/core/src/plugins/memcached/memcached.go @@ -0,0 +1,151 @@ +package memcached + +import ( + "bufio" + "errors" + "fmt" + "net" + "strings" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/clidey/whodb/core/src/engine" +) + +type MemcachedPlugin struct{} + +func (p *MemcachedPlugin) IsAvailable(config *engine.PluginConfig) bool { + client, err := DB(config) + if err != nil { + return false + } + _, err = client.Get("test_key") + if err != nil && err != memcache.ErrCacheMiss { + return false + } + return true +} + +func (p *MemcachedPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + return nil, errors.New("unsupported operation for Memcached") +} + +func (p *MemcachedPlugin) GetSchema(config *engine.PluginConfig) ([]string, error) { + return nil, errors.New("unsupported operation for Memcached") +} + +func (p *MemcachedPlugin) GetStorageUnits(config *engine.PluginConfig, schema string) ([]engine.StorageUnit, error) { + client, err := DB(config) + if err != nil { + return nil, err + } + defer client.Close() + + rows, err := p.GetRows(config, schema, "", "", 0, 0) + if err != nil { + return nil, err + } + + count := len(rows.Rows) + + return []engine.StorageUnit{ + { + Name: "default", + Attributes: []engine.Record{ + { + Key: "Count", + Value: fmt.Sprintf("%v", count), + }, + }, + }, + }, nil +} + +func (p *MemcachedPlugin) GetRows(config *engine.PluginConfig, schema string, storageUnit string, where string, pageSize int, pageOffset int) (*engine.GetRowsResult, error) { + client, err := DB(config) + if err != nil { + return nil, err + } + defer client.Close() + + conn, err := net.Dial("tcp", getAddress(config)) + if err != nil { + return nil, fmt.Errorf("failed to connect to Memcached server: %v", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + slabs := make(map[int]int) + + for scanner.Scan() { + line := scanner.Text() + if line == "END" { + break + } + + if strings.HasPrefix(line, "STAT items") { + parts := strings.Split(line, ":") + if len(parts) > 2 { + slabID := 0 + number := 0 + fmt.Sscanf(parts[1], "%d", &slabID) + fmt.Sscanf(parts[2], "number %d", &number) + slabs[slabID] = number + } + } + } + + rows := [][]string{} + + for range slabs { + for scanner.Scan() { + line := scanner.Text() + if line == "END" { + break + } + + parts := strings.Split(line, " ") + if len(parts) > 1 { + key := parts[1] + + item, err := client.Get(key) + if err != nil { + return nil, fmt.Errorf("failed to retrieve value for key %s: %v", key, err) + } + rows = append(rows, []string{string(item.Value)}) + } + } + } + + if scanner.Err() != nil { + return nil, fmt.Errorf("error reading from connection: %v", scanner.Err()) + } + + return &engine.GetRowsResult{ + Columns: []engine.Column{ + { + Name: "Value", + Type: "string", + }, + }, + Rows: rows, + }, nil +} + +func (p *MemcachedPlugin) GetGraph(config *engine.PluginConfig, schema string) ([]engine.GraphUnit, error) { + return nil, errors.New("unsupported operation for Memcached") +} + +func (p *MemcachedPlugin) RawExecute(config *engine.PluginConfig, query string) (*engine.GetRowsResult, error) { + return nil, errors.New("unsupported operation for Memcached") +} + +func (p *MemcachedPlugin) Chat(config *engine.PluginConfig, schema string, model string, previousConversation string, query string) ([]*engine.ChatMessage, error) { + return nil, errors.New("unsupported operation for Memcached") +} + +func NewMemcachedPlugin() *engine.Plugin { + return &engine.Plugin{ + Type: engine.DatabaseType_Memcached, + PluginFunctions: &MemcachedPlugin{}, + } +} diff --git a/core/src/plugins/memcached/update.go b/core/src/plugins/memcached/update.go new file mode 100644 index 00000000..fa3ecbe6 --- /dev/null +++ b/core/src/plugins/memcached/update.go @@ -0,0 +1,32 @@ +package memcached + +import ( + "errors" + + "github.com/bradfitz/gomemcache/memcache" + "github.com/clidey/whodb/core/src/engine" +) + +func (p *MemcachedPlugin) UpdateStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + defer client.Close() + + if len(values) != 1 { + return false, errors.New("invalid number of fields for Memcached key") + } + + value, ok := values["value"] + if !ok { + return false, errors.New("missing 'value' for update") + } + + err = client.Set(&memcache.Item{Key: storageUnit, Value: []byte(value)}) + if err != nil { + return false, err + } + + return true, nil +} diff --git a/core/src/src.go b/core/src/src.go index 643bc31c..f04d9464 100644 --- a/core/src/src.go +++ b/core/src/src.go @@ -6,6 +6,7 @@ import ( "github.com/clidey/whodb/core/src/engine" "github.com/clidey/whodb/core/src/env" "github.com/clidey/whodb/core/src/plugins/elasticsearch" + "github.com/clidey/whodb/core/src/plugins/memcached" "github.com/clidey/whodb/core/src/plugins/mongodb" "github.com/clidey/whodb/core/src/plugins/mysql" "github.com/clidey/whodb/core/src/plugins/postgres" @@ -24,6 +25,7 @@ func InitializeEngine() *engine.Engine { MainEngine.RegistryPlugin(mongodb.NewMongoDBPlugin()) MainEngine.RegistryPlugin(redis.NewRedisPlugin()) MainEngine.RegistryPlugin(elasticsearch.NewElasticSearchPlugin()) + MainEngine.RegistryPlugin(memcached.NewMemcachedPlugin()) return MainEngine } diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index fe10e7ca..0715a18c 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -61,6 +61,17 @@ services: - redis:/bitnami networks: - db + memcached: + image: bitnami/memcached + ports: + - '11211:11211' + environment: + MEMCACHED_CACHE_SIZE: 64 + MEMCACHED_MAX_CONNECTIONS: 1024 + volumes: + - memcached:/bitnami + networks: + - db elasticsearch: container_name: elasticsearch image: docker.elastic.co/elasticsearch/elasticsearch:8.14.1 @@ -99,4 +110,5 @@ volumes: mariadb: mongo: redis: - elasticsearch: \ No newline at end of file + elasticsearch: + memcached: \ No newline at end of file diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 1dea78da..7f7950ae 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -126,5 +126,6 @@ export const Icons = { MongoDB: , Redis: , ElasticSearch: , + Memcached: , }, } \ No newline at end of file diff --git a/frontend/src/generated/graphql.tsx b/frontend/src/generated/graphql.tsx index 8b295e8e..fd81e200 100644 --- a/frontend/src/generated/graphql.tsx +++ b/frontend/src/generated/graphql.tsx @@ -39,6 +39,7 @@ export type Column = { export enum DatabaseType { ElasticSearch = 'ElasticSearch', MariaDb = 'MariaDB', + Memcached = 'Memcached', MongoDb = 'MongoDB', MySql = 'MySQL', Postgres = 'Postgres', diff --git a/frontend/src/pages/auth/login.tsx b/frontend/src/pages/auth/login.tsx index 9bd0af70..570992c8 100644 --- a/frontend/src/pages/auth/login.tsx +++ b/frontend/src/pages/auth/login.tsx @@ -59,6 +59,12 @@ const databaseTypeDropdownItems: IDropdownItem>[] = [ icon: Icons.Logos.ElasticSearch, extra: {"Port": "9200", "SSL Mode": "disable"}, }, + { + id: "Memcached", + label: "Memcached", + icon: Icons.Logos.Memcached, + extra: {"Port": "11211"}, + }, ] export const LoginPage: FC = () => { @@ -89,7 +95,7 @@ export const LoginPage: FC = () => { if (([DatabaseType.MySql, DatabaseType.Postgres].includes(databaseType.id as DatabaseType) && (hostName.length === 0 || database.length === 0 || username.length === 0)) || (databaseType.id === DatabaseType.Sqlite3 && database.length === 0) || (databaseType.id === DatabaseType.MongoDb && (hostName.length === 0 || username.length === 0)) - || (databaseType.id === DatabaseType.Redis && (hostName.length === 0))) { + || ([DatabaseType.Redis, DatabaseType.Memcached].includes(databaseType.id as DatabaseType) && (hostName.length === 0))) { return setError("All fields are required"); } setError(undefined); @@ -241,9 +247,9 @@ export const LoginPage: FC = () => { } return <> - { databaseType.id !== DatabaseType.Redis && } + { databaseType.id !== DatabaseType.Redis && databaseType.id !== DatabaseType.Memcached && } - { (databaseType.id !== DatabaseType.MongoDb && databaseType.id !== DatabaseType.Redis && databaseType.id !== DatabaseType.ElasticSearch) && } + { (databaseType.id !== DatabaseType.MongoDb && databaseType.id !== DatabaseType.Redis && databaseType.id !== DatabaseType.Memcached && databaseType.id !== DatabaseType.ElasticSearch) && } }, [database, databaseType.id, databasesLoading, foundDatabases?.Database, handleHostNameChange, hostName, password, username]); diff --git a/frontend/src/pages/storage-unit/explore-storage-unit.tsx b/frontend/src/pages/storage-unit/explore-storage-unit.tsx index ba0ccd0d..ad4cddef 100644 --- a/frontend/src/pages/storage-unit/explore-storage-unit.tsx +++ b/frontend/src/pages/storage-unit/explore-storage-unit.tsx @@ -72,7 +72,12 @@ export const ExploreStorageUnit: FC = () => { where: whereCondition, pageSize: Number.parseInt(bufferPageSize), pageOffset: currentPage, - } + }, + onCompleted(data) { + setRows(data.Row); + setPageSize(bufferPageSize); + }, + fetchPolicy: "no-cache", }); }, [getStorageUnitRows, current?.Type, schema, unitName, whereCondition, bufferPageSize, currentPage]); @@ -301,7 +306,7 @@ export const ExploreStorageUnit: FC = () => { if (columns.length === 0) { columns = rows?.Columns ?? []; } - setNewRowForm((columns.map(col => { + setNewRowForm((rows?.Columns.map(col => { const colName = col.Name.toLowerCase(); const isId = colName === "id" && col.Type === "UUID"; const isDate = col.Type === "TIMESTAMPTZ"; @@ -321,7 +326,7 @@ export const ExploreStorageUnit: FC = () => { }, ], } - }))); + }) ?? [])); } } setShowAdd(showAddStatus); diff --git a/frontend/src/pages/storage-unit/storage-unit.tsx b/frontend/src/pages/storage-unit/storage-unit.tsx index 4f8e7d62..70246cec 100644 --- a/frontend/src/pages/storage-unit/storage-unit.tsx +++ b/frontend/src/pages/storage-unit/storage-unit.tsx @@ -211,7 +211,6 @@ export const StorageUnitPage: FC = () => { return items.map(item => createDropdownItem(item)); }, [current?.Type]); - if (loading) { return diff --git a/frontend/src/utils/functions.ts b/frontend/src/utils/functions.ts index 12044037..92c35802 100644 --- a/frontend/src/utils/functions.ts +++ b/frontend/src/utils/functions.ts @@ -40,6 +40,7 @@ export function isNoSQL(databaseType: string) { switch (databaseType) { case DatabaseType.MongoDb: case DatabaseType.Redis: + case DatabaseType.Memcached: case DatabaseType.ElasticSearch: return true; }