diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9a5aec --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/cmd/godb/main.go b/cmd/godb/main.go new file mode 100644 index 0000000..2e2791d --- /dev/null +++ b/cmd/godb/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "godb/internals/repl" +) + +func main() { + repl.Run() +} diff --git a/go.mod b/go.mod index cfeceaf..672f71b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module godb/main +module godb -go 1.23.2 +go 1.26.3 diff --git a/internals/buffer/buffer.go b/internals/buffer/buffer.go new file mode 100644 index 0000000..f6709dc --- /dev/null +++ b/internals/buffer/buffer.go @@ -0,0 +1,30 @@ +package buffer + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +type InputBuffer struct { + Buffer string + Reader *bufio.Reader +} + +func NewInputBuffer() *InputBuffer { + return &InputBuffer{ + Buffer: "", + Reader: bufio.NewReader(os.Stdin), + } +} + +func (inputBuffer *InputBuffer) ReadConsole() { + line, err := inputBuffer.Reader.ReadString('\n') + if err != nil && err != io.EOF { + fmt.Println("Error reading input", err) + os.Exit(1) + } + inputBuffer.Buffer = strings.TrimSpace(line) +} diff --git a/internals/buffer/buffer_test.go b/internals/buffer/buffer_test.go new file mode 100644 index 0000000..097788a --- /dev/null +++ b/internals/buffer/buffer_test.go @@ -0,0 +1,23 @@ +package buffer + +import ( + "bufio" + "strings" + "testing" +) + +func TestReadConsole(t *testing.T) { + mockMessage := "Reading for Unit test of readconsole" + + inputBuffer := &InputBuffer{ + Buffer: "", + Reader: bufio.NewReader(strings.NewReader(mockMessage)), + } + + inputBuffer.ReadConsole() + + if inputBuffer.Buffer != mockMessage { + t.Errorf("ReadConsole() should be %q but is %q", mockMessage, inputBuffer.Buffer) + } + +} diff --git a/internals/repl/repl.go b/internals/repl/repl.go new file mode 100644 index 0000000..7f60529 --- /dev/null +++ b/internals/repl/repl.go @@ -0,0 +1,50 @@ +package repl + +import ( + "fmt" + "strings" + + . "godb/internals/buffer" + . "godb/internals/vm" +) + +func PrintConsole() { + fmt.Print("godb > ") +} + +func Run() { + + inputBuffer := NewInputBuffer() + statement := NewStatement() + table := NewTable() + for { + PrintConsole() + inputBuffer.ReadConsole() + if len(inputBuffer.Buffer) > 0 && strings.HasPrefix(inputBuffer.Buffer, ".") { + switch DoMetaCommands(inputBuffer) { + case MetaCommandSuccess: + continue + case MetaCommandUnrecognised: + fmt.Printf("unrecognised command %v\n", inputBuffer.Buffer) + continue + } + } + + switch statement.PrepareStatements(inputBuffer) { + case PrepareSuccess: + //todo + case PrepareSyntaxError: + fmt.Println("Syntax error, could not parse statement") + case PrepareUnrecognised: + fmt.Printf("Unrecognised keyword at the start of '%s'.\n", inputBuffer.Buffer) + } + + switch statement.ExecuteStatement(table) { + case ExecuteSuccess: + fmt.Println("Executed.") + case ExecuteTableFull: + fmt.Println("Error: Table is full") + } + + } +} diff --git a/internals/vm/vm.go b/internals/vm/vm.go new file mode 100644 index 0000000..86f7162 --- /dev/null +++ b/internals/vm/vm.go @@ -0,0 +1,180 @@ +package vm + +import ( + "encoding/binary" + "fmt" + "os" + "strings" + + . "godb/internals/buffer" +) + +type MetaCommandResults int + +const ( + MetaCommandSuccess MetaCommandResults = iota + MetaCommandUnrecognised +) + +type PrepareResults int + +const ( + PrepareSuccess PrepareResults = iota + PrepareUnrecognised + PrepareSyntaxError +) + +type statementType int + +const ( + StatementSelect statementType = iota + StatementInsert +) + +const ( + idSize = 8 + usernameSize = 32 + emailSize = 255 + + idOffset = 0 + usernameOffset = idOffset + idSize + emailOffset = usernameOffset + usernameSize + + pageSize = 4096 + maxPages = 100 + rowSize = idSize + usernameSize + emailSize + rowsPerPage = pageSize / rowSize + maxRows = rowsPerPage * maxPages +) + +type ExecuteResult int + +const ( + ExecuteSuccess ExecuteResult = iota + ExecuteTableFull +) + +type row struct { + id uint + username string + email string +} + +type Statement struct { + stype statementType + row row +} + +type Table struct { + numOfRows uint + pages [maxPages]any +} + +func NewStatement() *Statement { + return &Statement{0, row{0, "", ""}} +} + +func NewTable() *Table { + newTable := Table{numOfRows: 0} + for i := range maxPages { + newTable.pages[i] = nil + } + return &newTable +} + +func rowToUse(table *Table, rowNo uint) []byte { + pageNo := rowNo / rowsPerPage + var page []byte + if table.pages[pageNo] == nil { + page = make([]byte, pageSize) + table.pages[pageNo] = page + } else { + i := table.pages[pageNo] + if t, ok := i.([]byte); !ok { + page = []byte(t) + } else { + page = t + } + } + rowOffset := rowNo % rowsPerPage + byteOffset := rowOffset * rowSize + return page[byteOffset : byteOffset+rowSize] +} + +func (row *row) seralizeRow(page []byte) { + binary.NativeEndian.PutUint64(page[idOffset:], uint64(row.id)) + + usernameBuff := page[usernameOffset : usernameOffset+usernameSize] + clear(usernameBuff) + copy(usernameBuff, row.username) + + emailBuff := page[emailOffset : emailOffset+emailSize] + clear(emailBuff) + copy(emailBuff, row.email) +} + +func (row *row) deSeralizeRow(page []byte) { + row.id = uint(binary.NativeEndian.Uint64(page[idOffset:idSize])) + row.username = string(page[usernameOffset : usernameOffset+usernameSize]) + row.email = string(page[emailOffset : emailOffset+emailSize]) +} + +func (statement *Statement) execInsert(table *Table) ExecuteResult { + if table.numOfRows >= maxRows { + return ExecuteTableFull + } + statement.row.seralizeRow(rowToUse(table, table.numOfRows)) + table.numOfRows += 1 + return ExecuteSuccess +} + +func (statement *Statement) execSelect(table *Table) ExecuteResult { + var row row + for i := range table.numOfRows { + row.deSeralizeRow(rowToUse(table, i)) + fmt.Printf("%v\n", row) + } + return ExecuteSuccess +} + +func DoMetaCommands(inputBuffer *InputBuffer) MetaCommandResults { + metacommand := MetaCommandUnrecognised + if inputBuffer.Buffer == ".exit" { + fmt.Println("bye!") + os.Exit(0) + } else { + metacommand = MetaCommandUnrecognised + } + return metacommand +} + +func (statement *Statement) PrepareStatements(inputBuffer *InputBuffer) PrepareResults { + if strings.HasPrefix(inputBuffer.Buffer, "insert") { + argsAssigned, err := fmt.Sscanf(inputBuffer.Buffer, "insert %d %s %s", &statement.row.id, &statement.row.username, &statement.row.email) + // if err != nil { + // fmt.Println("error parsing insert statement") + // fmt.Println(err.Error()) + // } + if err != nil || argsAssigned > 3 { + return PrepareSyntaxError + } + statement.stype = StatementInsert + return PrepareSuccess + } + if strings.HasPrefix(inputBuffer.Buffer, "select") { + statement.stype = StatementSelect + return PrepareSuccess + } + return PrepareUnrecognised +} + +func (statement *Statement) ExecuteStatement(table *Table) ExecuteResult { + var result ExecuteResult + switch statement.stype { + case StatementInsert: + result = statement.execInsert(table) + case StatementSelect: + result = statement.execSelect(table) + } + return result +} diff --git a/licence b/licence new file mode 100644 index 0000000..43f07fe --- /dev/null +++ b/licence @@ -0,0 +1,19 @@ +MIT License + +Copyright (c) 2026 TakshakRamteke + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/main.go b/main.go deleted file mode 100644 index e69de29..0000000 diff --git a/makefile b/makefile new file mode 100644 index 0000000..7ac398b --- /dev/null +++ b/makefile @@ -0,0 +1,26 @@ +BINARY_NAME=godb + +run: + @go run cmd/godb/main.go + +build: + @go build -o tmp/${BINARY_NAME} cmd/godb/main.go + +start: + @./tmp/${BINARY_NAME} + +test-unit: + @go test ./internals/... -v + +test-inte: + @go test ./tests/... -v + +test: build test-unit test-inte + +clean: + @go clean + @rm -rf ${BINARY_NAME} + +reset: clean build test clean + +.PHONY: clean build test diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..09a036a --- /dev/null +++ b/readme.md @@ -0,0 +1,23 @@ +### Godb + +A SQLite like database made in golang + +Inorder to run this locally, after cloning this repo run `go mod tidy` + +Then use the appropriate `make` command listed below + +Run + +```make run``` + +Build + +```make build``` + +Start + +```make start``` + +Cleanup + +```make clean``` diff --git a/tests/main_test.go b/tests/main_test.go new file mode 100644 index 0000000..6d4551d --- /dev/null +++ b/tests/main_test.go @@ -0,0 +1,85 @@ +package tests + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "slices" + "strings" + "testing" +) + +func runner(commands []string, t *testing.T) []string { + executablePath := "../tmp/godb" + t.Logf("starting godb from %v\n", executablePath) + + executable := exec.Command(executablePath) + + results := make([]string, 0) + + stdin, err := executable.StdinPipe() + if err != nil { + t.Errorf("Failed to create stdin %q\n", err) + } + + stdout, err := executable.StdoutPipe() + if err != nil { + t.Errorf("Failed to create stdout %q\n", err) + } + + if err := executable.Start(); err != nil { + t.Errorf("Failed to start executable %q\n", err) + } + + go func() { + defer stdin.Close() + for _, c := range commands { + _, _ = io.WriteString(stdin, c+"\n") + } + }() + + reader := bufio.NewReader(stdout) + for { + line, err := reader.ReadString('\n') + if line != "" { + formattedline := fmt.Sprintf("%v", strings.Trim(strings.ReplaceAll(line, "\x00", ""), "\n")) + results = append(results, formattedline) + } + if err != nil { + t.Logf("stoped reading due to %q\n", err) + break + } + + } + + if err := executable.Wait(); err != nil { + t.Logf("Error occured while waiting for cleanup %q\n", err) + } + + return results +} + +func TestMain(t *testing.T) { + commands := []string{ + "insert 1 user1 person1@example.com", + "select", + ".exit", + } + + expected := []string{ + "godb > Executed.", + "godb > {1 user1 person1@example.com}", + "Executed.", + "godb > bye!", + } + results := runner(commands, t) + + for i := range results { + results[i] = strings.ReplaceAll(results[i], "\n", "") + } + + if !slices.Equal(expected, results) { + t.Errorf("Output Mismatch\nExpected: %v\nGot: %v\n", expected, results) + } +}