diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b943dbc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} \ No newline at end of file diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..f6613a3 Binary files /dev/null and b/dump.rdb differ diff --git a/scripts/all_commands.txt b/examples/all_commands.txt similarity index 98% rename from scripts/all_commands.txt rename to examples/all_commands.txt index eaf283a..a13854e 100644 --- a/scripts/all_commands.txt +++ b/examples/all_commands.txt @@ -25,8 +25,6 @@ SMEMBERS myset # returns x,z KEYS INFO FLUSHDB -KEYS -INFO # Ping PING diff --git a/examples/crud_demo.go b/examples/crud_demo.go new file mode 100644 index 0000000..e206c22 --- /dev/null +++ b/examples/crud_demo.go @@ -0,0 +1,58 @@ +package main + +import ( + "bufio" + "fmt" + "net" + "strings" +) + +func sendCommand(conn net.Conn, cmd string) string { + fmt.Fprintf(conn, "%s\n", cmd) + reader := bufio.NewReader(conn) + resp, _ := reader.ReadString('\n') + return strings.TrimSpace(resp) +} + +func main() { + conn, err := net.Dial("tcp", "127.0.0.1:7070") + if err != nil { + fmt.Println("Error connecting:", err) + return + } + defer conn.Close() + + fmt.Println("Connected to FurrDB") + + // CREATE + fmt.Println("[CREATE] Set user:1 name and email") + fmt.Println(sendCommand(conn, "SET user:1:name Alice")) + fmt.Println(sendCommand(conn, "SET user:1:email alice@example.com")) + + // READ + fmt.Println("\n[READ] Get user:1 name and email") + fmt.Println("Name:", sendCommand(conn, "GET user:1:name")) + fmt.Println("Email:", sendCommand(conn, "GET user:1:email")) + + // UPDATE + fmt.Println("\n[UPDATE] Update user:1 name") + fmt.Println(sendCommand(conn, "SET user:1:name Alicia")) + fmt.Println("Updated Name:", sendCommand(conn, "GET user:1:name")) + + // EXISTS + fmt.Println("\n[EXISTS] Check if user:1:email exists") + fmt.Println("Exists:", sendCommand(conn, "EXISTS user:1:email")) + + // DELETE + fmt.Println("\n[DELETE] Delete user:1:email") + fmt.Println(sendCommand(conn, "DEL user:1:email")) + fmt.Println("Email after delete:", sendCommand(conn, "GET user:1:email")) + + // KEYS + fmt.Println("\n[KEYS] List all keys") + fmt.Println(sendCommand(conn, "KEYS")) + + // EXIT + sendCommand(conn, "EXIT") + fmt.Println("\nConnection closed") +} diff --git a/examples/crud_demo.py b/examples/crud_demo.py new file mode 100644 index 0000000..b2fb7cc --- /dev/null +++ b/examples/crud_demo.py @@ -0,0 +1,49 @@ +import socket + +HOST = '127.0.0.1' +PORT = 7070 + +def send_command(sock, cmd): + sock.sendall((cmd + '\n').encode()) + resp = b'' + while not resp.endswith(b'\n'): + chunk = sock.recv(4096) + if not chunk: + break + resp += chunk + return resp.decode().strip() + +with socket.create_connection((HOST, PORT)) as s: + print('Connected to FurrDB\n') + + # CREATE + print('[CREATE] Set user:1 name and email') + print(send_command(s, 'SET user:1:name Alice')) + print(send_command(s, 'SET user:1:email alice@example.com')) + + # READ + print('\n[READ] Get user:1 name and email') + print('Name:', send_command(s, 'GET user:1:name')) + print('Email:', send_command(s, 'GET user:1:email')) + + # UPDATE + print('\n[UPDATE] Update user:1 name') + print(send_command(s, 'SET user:1:name Alicia')) + print('Updated Name:', send_command(s, 'GET user:1:name')) + + # EXISTS + print('\n[EXISTS] Check if user:1:email exists') + print('Exists:', send_command(s, 'EXISTS user:1:email')) + + # DELETE + print('\n[DELETE] Delete user:1:email') + print(send_command(s, 'DEL user:1:email')) + print('Email after delete:', send_command(s, 'GET user:1:email')) + + # KEYS + print('\n[KEYS] List all keys') + print(send_command(s, 'KEYS')) + + # EXIT + send_command(s, 'EXIT') + print('\nConnection closed') \ No newline at end of file diff --git a/examples/crud_demo.ts b/examples/crud_demo.ts new file mode 100644 index 0000000..5213f74 --- /dev/null +++ b/examples/crud_demo.ts @@ -0,0 +1,59 @@ +// FurrDB CRUD Demo (Deno TypeScript) +// Demonstrates basic CRUD operations for a user profile + +const HOST = "127.0.0.1"; +const PORT = 7070; + +// Helper to send a command and receive a response +async function sendCommand(conn: Deno.Conn, cmd: string): Promise { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + await conn.write(encoder.encode(cmd + "\n")); + let resp = ""; + while (!resp.endsWith("\n")) { + const buf = new Uint8Array(1024); + const n = await conn.read(buf); + if (n === null) break; + resp += decoder.decode(buf.subarray(0, n)); + } + return resp.trim(); +} + +// Main demo logic +const conn = await Deno.connect({ hostname: HOST, port: PORT }); +console.log("Connected to FurrDB\n"); + +// Create (SET user:1 name and email) +console.log("[CREATE] Set user:1 name and email"); +console.log(await sendCommand(conn, 'SET user:1:name Alice')); +console.log(await sendCommand(conn, 'SET user:1:email alice@example.com')); + +// Read (GET user:1 name and email) +console.log("\n[READ] Get user:1 name and email"); +console.log('Name:', await sendCommand(conn, 'GET user:1:name')); +console.log('Email:', await sendCommand(conn, 'GET user:1:email')); + +// Update (SET user:1 name) +console.log("\n[UPDATE] Update user:1 name"); +console.log(await sendCommand(conn, 'SET user:1:name Alicia')); +console.log('Updated Name:', await sendCommand(conn, 'GET user:1:name')); + +// Exists +console.log("\n[EXISTS] Check if user:1:email exists"); +console.log('Exists:', await sendCommand(conn, 'EXISTS user:1:email')); + +// Delete (DEL user:1 email) +console.log("\n[DELETE] Delete user:1:email"); +console.log(await sendCommand(conn, 'DEL user:1:email')); +console.log('Email after delete:', await sendCommand(conn, 'GET user:1:email')); + +// List all keys +console.log("\n[KEYS] List all keys"); +console.log(await sendCommand(conn, 'KEYS')); + +// Exit +await sendCommand(conn, 'EXIT'); +conn.close(); +console.log("\nConnection closed"); + +export {}; \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index ec56347..c87c8a2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -57,7 +57,7 @@ var Commands = map[string]HandlerFunc{ "INFO": infoHandler, "EXPIRE": expireHandler, "TTL": ttlHandler, - "SNAPSHOT": snapshotHandler, + "SAVE": snapshotHandler, } func (s *Store) ttlCleaner() { diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 6b3c84b..deacaef 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -7,6 +7,10 @@ import ( "time" ) +const ( + TEST_FILE = "test_dump.rdb" +) + func TestStoreBasicOps(t *testing.T) { db := NewStore() db.mu.Lock() @@ -127,7 +131,7 @@ func TestSnapshotSaveLoad(t *testing.T) { _, _ = saddHandler([]string{"snapset", "a", "b"}) _, _ = expireHandler([]string{"snapkey", "10"}) - err := SaveSnapshot("test_dump.rdb") + err := SaveSnapshot(TEST_FILE) if err != nil { t.Fatalf("SaveSnapshot failed: %v", err) } @@ -139,7 +143,7 @@ func TestSnapshotSaveLoad(t *testing.T) { t.Errorf("expected empty after clear, got %s", val) } - err = LoadSnapshot("test_dump.rdb") + err = LoadSnapshot(TEST_FILE) if err != nil { t.Fatalf("LoadSnapshot failed: %v", err) } @@ -151,5 +155,5 @@ func TestSnapshotSaveLoad(t *testing.T) { if !(strings.Contains(members, "a") && strings.Contains(members, "b")) { t.Errorf("expected set members a and b, got %s", members) } - _ = os.Remove("test_dump.rdb") + _ = os.Remove(TEST_FILE) } diff --git a/internal/engine/test_aof.log b/internal/engine/test_aof.log index f0cddba..833fff4 100644 --- a/internal/engine/test_aof.log +++ b/internal/engine/test_aof.log @@ -9,3 +9,7 @@ SET testkey testval SET testkey testval SET testkey testval SET testkey testval +SET testkey testval +SET testkey testval +SET testkey testval +SET testkey testval diff --git a/internal/handlers/script_handlers.go b/internal/handlers/script_handlers.go index 81cb318..6d23f66 100644 --- a/internal/handlers/script_handlers.go +++ b/internal/handlers/script_handlers.go @@ -30,7 +30,7 @@ func evalHandler(args []string) (string, error) { return "", fmt.Errorf("missing argument for EVAL") } scriptStr := strings.Join(args, " ") - return script.EvalScript(scriptStr, nil) + return script.EvalScript(scriptStr) } func init() { diff --git a/internal/repl/repl.go b/internal/repl/repl.go index 0f938fd..85ff378 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -4,7 +4,6 @@ import ( "bufio" "fmt" "os" - "runtime" "strings" "furr/internal/db" @@ -30,39 +29,32 @@ func Start() { } cmd := strings.ToUpper(tokens[0]) args := tokens[1:] - if cmd == "EXIT" { + switch cmd { + case "EXIT": fmt.Println("bye!") return - } - if cmd == "HELP" { + case "HELP": printHelp() - continue - } - if cmd == "CLEAR" { + case "CLEAR": clearScreen() - continue - } - handler, ok := db.Commands[cmd] - if !ok { - fmt.Println("ERR unknown command") - continue + default: + handler, ok := db.Commands[cmd] + if !ok { + fmt.Println("ERR unknown command") + continue + } + result, err := handler(args) + if err != nil { + fmt.Println("ERR", err) + continue + } + fmt.Println(result) } - result, err := handler(args) - if err != nil { - fmt.Println("ERR", err) - continue - } - fmt.Println(result) } } func clearScreen() { - if runtime.GOOS == "windows" { - fmt.Print("\033[2J\033[H") // ANSI clear for modern Windows terminals - // Optionally, use syscall for legacy cmd.exe, but most support ANSI now - } else { - fmt.Print("\033[2J\033[H") // ANSI escape codes for Unix - } + fmt.Print("\033[2J\033[H") // ANSI escape code } func printHelp() { diff --git a/internal/script/script.go b/internal/script/script.go index 9022a17..9b93a71 100644 --- a/internal/script/script.go +++ b/internal/script/script.go @@ -25,100 +25,152 @@ func RunScript(hash string, args []string) (string, error) { if !ok { return "", nil } - return evalScriptLines(script, args) + return evalScriptLines(script) } // EvalScript evaluates a script string without storing (stub) -func EvalScript(script string, args []string) (string, error) { - return evalScriptLines(script, args) +func EvalScript(script string) (string, error) { + return evalScriptLines(script) } -func evalScriptLines(script string, args []string) (string, error) { +// getWhitelist returns the allowed commands +func getWhitelist() map[string]bool { + return map[string]bool{ + "SET": true, "GET": true, "DEL": true, "EXISTS": true, + "LPUSH": true, "RPUSH": true, "LPOP": true, "RPOP": true, "LRANGE": true, + "SADD": true, "SREM": true, "SMEMBERS": true, + } +} + +// handleSkipping processes lines when in a skipping state +func handleSkipping(line string, skipToEnd int) int { + if strings.HasPrefix(line, "IF ") { + return skipToEnd + 1 + } else if line == "END" { + return skipToEnd - 1 + } + return skipToEnd +} + +// handleLetStatement processes a LET statement +func handleLetStatement(line string, lineNum int, whitelist map[string]bool, vars map[string]string) (string, error) { + parts := strings.Fields(line) + if len(parts) < 5 || parts[2] != "=" { + return "", fmt.Errorf("ERR invalid LET syntax on line %d", lineNum) + } + + varName := parts[1] + cmd := strings.ToUpper(parts[3]) + cmdArgs := parts[4:] + + if !whitelist[cmd] { + return "", fmt.Errorf("ERR command %s not allowed in LET on line %d", cmd, lineNum) + } + + handler, ok := db.Commands[cmd] + if !ok { + return "", fmt.Errorf("ERR unknown command '%s' in LET on line %d", cmd, lineNum) + } + + result, err := handler(cmdArgs) + if err != nil { + return "", fmt.Errorf("ERR %v on line %d", err, lineNum) + } + + vars[varName] = result + return result, nil +} + +// handleIfStatement processes an IF statement +func handleIfStatement(line string, lineNum int, vars map[string]string) (int, error) { + parts := strings.Fields(line) + if len(parts) != 4 || parts[2] != "==" { + return 0, fmt.Errorf("ERR invalid IF syntax on line %d", lineNum) + } + + varName := parts[1] + expected := parts[3] + + if vars[varName] != expected { + return 1, nil + } + return 0, nil +} + +// executeCommand executes a normal DB command +func executeCommand(line string, lineNum int, whitelist map[string]bool) (string, error) { + tokens := strings.Fields(line) + if len(tokens) == 0 { + return "", nil + } + + cmd := strings.ToUpper(tokens[0]) + params := tokens[1:] + + if !whitelist[cmd] { + return "", fmt.Errorf("ERR command %s not allowed in script on line %d", cmd, lineNum) + } + + handler, ok := db.Commands[cmd] + if !ok { + return "", fmt.Errorf("ERR unknown command '%s' on line %d", cmd, lineNum) + } + + result, err := handler(params) + if err != nil { + return "", fmt.Errorf("ERR %v on line %d", err, lineNum) + } + + return result, nil +} + +func evalScriptLines(script string) (string, error) { lines := strings.Split(script, ";") vars := make(map[string]string) var last string - maxLines := 100 + + const maxLines = 100 if len(lines) > maxLines { return "", fmt.Errorf("ERR script too long (max %d lines)", maxLines) } - whitelist := map[string]bool{ - "SET": true, "GET": true, "DEL": true, "EXISTS": true, - "LPUSH": true, "RPUSH": true, "LPOP": true, "RPOP": true, "LRANGE": true, - "SADD": true, "SREM": true, "SMEMBERS": true, - } - var skipToEnd int // 0=not skipping, >0=inside nested IFs + + whitelist := getWhitelist() + var skipToEnd int + for i, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if skipToEnd > 0 { - if strings.HasPrefix(line, "IF ") { - skipToEnd++ - } else if line == "END" { - skipToEnd-- - } - continue - } - if strings.HasPrefix(line, "LET ") { - // LET x = CMD args - parts := strings.Fields(line) - if len(parts) < 5 || parts[2] != "=" { - return "", fmt.Errorf("ERR invalid LET syntax on line %d", i+1) - } - varName := parts[1] - cmd := strings.ToUpper(parts[3]) - cmdArgs := parts[4:] - if !whitelist[cmd] { - return "", fmt.Errorf("ERR command %s not allowed in LET on line %d", cmd, i+1) - } - handler, ok := db.Commands[cmd] - if !ok { - return "", fmt.Errorf("ERR unknown command '%s' in LET on line %d", cmd, i+1) - } - result, err := handler(cmdArgs) - if err != nil { - return "", fmt.Errorf("ERR %v on line %d", err, i+1) - } - vars[varName] = result - last = result + skipToEnd = handleSkipping(line, skipToEnd) continue } - if strings.HasPrefix(line, "IF ") { - // IF var == value - parts := strings.Fields(line) - if len(parts) != 4 || parts[2] != "==" { - return "", fmt.Errorf("ERR invalid IF syntax on line %d", i+1) - } - varName := parts[1] - expected := parts[3] - if vars[varName] != expected { - skipToEnd = 1 - } - continue - } - if line == "END" { - continue + result, skip, err := evalScriptLine(line, i+1, whitelist, vars) + if err != nil { + return "", err } - // Normal DB command - tokens := strings.Fields(line) - if len(tokens) == 0 { + if skip > 0 { + skipToEnd = skip continue } - cmd := strings.ToUpper(tokens[0]) - params := tokens[1:] - if !whitelist[cmd] { - return "", fmt.Errorf("ERR command %s not allowed in script on line %d", cmd, i+1) - } - handler, ok := db.Commands[cmd] - if !ok { - return "", fmt.Errorf("ERR unknown command '%s' on line %d", cmd, i+1) - } - result, err := handler(params) - if err != nil { - return "", fmt.Errorf("ERR %v on line %d", err, i+1) + if result != "" { + last = result } - last = result } return last, nil } + +func evalScriptLine(line string, lineNum int, whitelist map[string]bool, vars map[string]string) (result string, skip int, err error) { + switch { + case strings.HasPrefix(line, "LET "): + result, err = handleLetStatement(line, lineNum, whitelist, vars) + case strings.HasPrefix(line, "IF "): + skip, err = handleIfStatement(line, lineNum, vars) + case line == "END": + // nothing to do + default: + result, err = executeCommand(line, lineNum, whitelist) + } + return +} diff --git a/internal/script/script_test.go b/internal/script/script_test.go index 10faf43..453c572 100644 --- a/internal/script/script_test.go +++ b/internal/script/script_test.go @@ -20,7 +20,7 @@ func TestRegisterAndRunScript(t *testing.T) { } func TestEvalScript(t *testing.T) { - res, err := EvalScript("SET baz qux; GET baz", nil) + res, err := EvalScript("SET baz qux; GET baz") if err != nil { t.Fatal(err) } @@ -29,10 +29,10 @@ func TestEvalScript(t *testing.T) { } } -func TestScriptDSL_LetIfEnd(t *testing.T) { +func TestScriptDSLLetIfEnd(t *testing.T) { scriptStr := `LET x = GET foo; IF x == bar; SET foo baz; END; GET foo` _, _ = db.Commands["SET"]([]string{"foo", "bar"}) - res, err := EvalScript(scriptStr, nil) + res, err := EvalScript(scriptStr) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -41,18 +41,18 @@ func TestScriptDSL_LetIfEnd(t *testing.T) { } } -func TestScriptDSL_Sandbox(t *testing.T) { +func TestScriptDSLSandbox(t *testing.T) { scriptStr := `FLUSHDB; GET foo` _, _ = db.Commands["SET"]([]string{"foo", "bar"}) - _, err := EvalScript(scriptStr, nil) + _, err := EvalScript(scriptStr) if err == nil || err.Error() != "ERR command FLUSHDB not allowed in script on line 1" { t.Errorf("expected sandbox error, got %v", err) } } -func TestScriptDSL_LetSyntaxError(t *testing.T) { +func TestScriptDSLLetSyntaxError(t *testing.T) { scriptStr := `LET x GET foo` - _, err := EvalScript(scriptStr, nil) + _, err := EvalScript(scriptStr) if err == nil || err.Error() != "ERR invalid LET syntax on line 1" { t.Errorf("expected LET syntax error, got %v", err) } diff --git a/internal/server/server.go b/internal/server/server.go index 577cc1d..8a6266d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -35,38 +35,48 @@ func handleConn(conn net.Conn) { if err != nil { return } - line = strings.TrimSpace(line) - if line == "" { - continue - } - tokens := strings.Fields(line) + + tokens := parseInput(line) if len(tokens) == 0 { continue } + cmd := strings.ToUpper(tokens[0]) args := tokens[1:] - var resp string - switch cmd { - case "PING": - resp = "PONG" - case "EXIT": + + if cmd == "EXIT" { w.WriteString("BYE\n") w.Flush() return - default: - handler, ok := db.Commands[cmd] - if !ok { - resp = "ERR unknown command" - } else { - result, err := handler(args) - if err != nil { - resp = "ERR " + err.Error() - } else { - resp = result - } - } } + + resp := processCommand(cmd, args) w.WriteString(resp + "\n") w.Flush() } } + +func parseInput(line string) []string { + line = strings.TrimSpace(line) + if line == "" { + return nil + } + return strings.Fields(line) +} + +func processCommand(cmd string, args []string) string { + if cmd == "PING" { + return "PONG" + } + + handler, ok := db.Commands[cmd] + if !ok { + return "ERR unknown command" + } + + result, err := handler(args) + if err != nil { + return "ERR " + err.Error() + } + return result +} diff --git a/readme.md b/readme.md index 5c527e7..2cafc8d 100644 --- a/readme.md +++ b/readme.md @@ -28,26 +28,6 @@ --- -## 🏗️ Project Structure - -``` -furrdb/ -├── cmd/ -│ └── furrdb/ # Main server entrypoint -├── client/ # CLI client (minidb-cli) -├── internal/ -│ ├── db/ # In-memory data store and command handlers -│ ├── engine/ # Persistence engine (AOF-based) -│ ├── server/ # TCP listener and protocol parser -│ ├── script/ # Script registration, hashing, execution -│ ├── repl/ # Optional local REPL shell -│ └── utils/ # Logging, hashing, and helper functions -├── scripts/ # Sample scripts for testing -├── testdata/ # Persistence and input test files -├── go.mod -└── README.md -``` - --- ## 🧠 Architecture Overview @@ -197,29 +177,49 @@ GET foo ## 🚀 Getting Started -### Build Server +### 1. **Clone the Repository** +```bash +git clone https://github.com/itsfuad/FurrDB.git +cd FurrDB +``` +### 2. **Build the Server** ```bash go build -o furrdb ./cmd/furrdb ``` -### Run Server - +### 3. **Run the Server** ```bash ./furrdb ``` -Server runs on `localhost:7070` by default. +The server will start on `localhost:7070` by default. -### Use Client +### 4. **Use the REPL (Interactive Shell)** +```bash +go run ./cmd/furrdb --repl +``` +Type commands directly, or use `HELP` for a list of commands. +### 5. **Use the CLI Client to Run Scripts** ```bash -go run ./client +go run ./cmd/furrdbcli scripts/all_commands.txt ``` -Or connect manually: +Or connect manually with telnet: ```bash telnet localhost 7070 ``` +### 6. **Using Release Binaries** +- Download the latest release from the [GitHub Releases page](https://github.com/itsfuad/FurrDB/releases). +- Extract and run the binary for your OS: + - `furrdb` (server) + - `furrdbcli` (CLI client) + +### 7. **Run Tests** +```bash +go test ./... +``` + --- ## ⚙️ Configuration