├── .air.toml ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── assets └── sqlite-admin-filtering.png ├── chinook.db ├── cmd └── sqliteadmin │ ├── main.go │ ├── root.go │ └── serve.go ├── errors.go ├── examples ├── chi │ └── main.go └── stdlib │ └── stdlib.go ├── go.mod ├── go.sum ├── queryhandlers.go ├── sakila.db ├── sqliteadmin.go ├── sqliteadmin_test.go └── test.db /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [screen] 45 | clear_on_rebuild = false 46 | keep_scroll = true 47 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.23.3' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | pull_request: 5 | push: 6 | # run only against tags 7 | tags: 8 | - "v*" 9 | 10 | permissions: 11 | contents: write 12 | # packages: write 13 | # issues: write 14 | # id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | # More assembly might be required: Docker logins, GPG, etc. 29 | # It all depends on your needs. 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | # either 'goreleaser' (default) or 'goreleaser-pro' 34 | distribution: goreleaser 35 | # 'latest', 'nightly', or a semver 36 | version: "~> v2" 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 41 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with "go test -c" 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | tmp/ 20 | 21 | # IDE specific files 22 | .vscode 23 | .idea 24 | 25 | # .env file 26 | .env 27 | 28 | # Project build 29 | main 30 | *templ.go 31 | 32 | # OS X generated file 33 | .DS_Store 34 | # Added by goreleaser init: 35 | dist/ 36 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | main: ./cmd/sqliteadmin 22 | binary: sqliteadmin 23 | goos: 24 | - linux 25 | - darwin 26 | 27 | archives: 28 | - formats: [tar.gz] 29 | # this name template makes the OS and Arch compatible with the results of `uname`. 30 | name_template: >- 31 | {{ .ProjectName }}_ 32 | {{- title .Os }}_ 33 | {{- if eq .Arch "amd64" }}x86_64 34 | {{- else if eq .Arch "386" }}i386 35 | {{- else }}{{ .Arch }}{{ end }} 36 | {{- if .Arm }}v{{ .Arm }}{{ end }} 37 | 38 | changelog: 39 | sort: asc 40 | filters: 41 | exclude: 42 | - "^docs:" 43 | - "^test:" 44 | 45 | release: 46 | footer: >- 47 | 48 | --- 49 | 50 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joel Sequeira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | main_package_path = ./cmd/sqliteadmin 2 | binary_name = sqliteadmin 3 | 4 | # ==================================================================================== # 5 | # QUALITY CONTROL 6 | # ==================================================================================== # 7 | 8 | ## audit: run quality control checks 9 | .PHONY: audit 10 | audit: test 11 | go mod tidy -diff 12 | go mod verify 13 | test -z "$(shell gofmt -l .)" 14 | go vet ./... 15 | go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./... 16 | go run golang.org/x/vuln/cmd/govulncheck@latest ./... 17 | 18 | ## test: run all tests 19 | .PHONY: test 20 | test: 21 | go test -v -race ./... 22 | 23 | ## test/cover: run all tests and display coverage 24 | .PHONY: test/cover 25 | test/cover: 26 | go test -v -race -buildvcs -coverprofile=/tmp/coverage.out ./... 27 | go tool cover -html=/tmp/coverage.out 28 | 29 | # ==================================================================================== # 30 | # DEVELOPMENT 31 | # ==================================================================================== # 32 | 33 | ## tidy: tidy modfiles and format .go files 34 | .PHONY: tidy 35 | tidy: 36 | go mod tidy -v 37 | go fmt ./... 38 | 39 | ## build: build the application 40 | .PHONY: build 41 | build: 42 | go build -o=/tmp/bin/${binary_name} ${main_package_path} 43 | 44 | ## run: run the application 45 | .PHONY: run 46 | run: build 47 | /tmp/bin/${binary_name} 48 | 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLite Admin 2 | 3 | [![Build](https://github.com/joelseq/sqliteadmin-go/actions/workflows/build.yml/badge.svg)](https://github.com/joelseq/sqliteadmin-go/actions/workflows/build.yml) 4 | 5 | SQLite Admin is a Golang library and binary which enables you to easily interact with a SQLite database. It allows you to: 6 | 7 | - Browse tables and their schemas. 8 | - View table data along with adding filters, limits and offsets. 9 | - Modify individual columns in existing rows. 10 | 11 | ![screenshot](assets/sqlite-admin-filtering.png) 12 | 13 | It can either be integrated into an existing Golang backend as a library or installed as a binary. 14 | 15 | The web server can be interacted with by going to https://sqliteadmin.dev. 16 | 17 | The source code for the web UI can be found at https://github.com/joelseq/sqliteadmin-ui 18 | 19 | ## Motivation 20 | 21 | SQLite is very easy to add as an embedded database but it's difficult to manage the database once it's deployed in an application. 22 | 23 | Existing tools primarily focus on local SQLite files, requiring manual interaction through CLI tools or desktop applications. If your SQLite database is running embedded within an application, there are few (if any) solutions that let you inspect, query, and modify it without complex workarounds. 24 | 25 | The alternative is to use a cloud-hosted version like those provided by [Turso](https://turso.tech/) which enables interacting with the database using tools like [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview). This adds complexity to the setup and deployment of your application and you lose out on the value of having an embedded database. 26 | 27 | This project fills that gap by providing an easy way to view and manage an embedded SQLite database via a web UI — no need to migrate to a cloud provider or use ad-hoc solutions. 28 | 29 | ## Using as a library 30 | 31 | ```go 32 | config := sqliteadmin.Config{ 33 | DB: db, // *sql.DB 34 | Username: "username", // username to use to login from https://sqliteadmin.dev 35 | Password: "password", // password to use to login from https://sqliteadmin.dev 36 | Logger: logger, // optional, implements the Logger interface 37 | } 38 | admin := sqliteadmin.New(config) 39 | 40 | // HandlePost is a HandlerFunc that you can pass in to your router 41 | router.Post("/admin", admin.HandlePost) 42 | ``` 43 | 44 | Check out the full code at `examples/chi/main.go`. 45 | 46 | You can also run the example to test out the admin UI: 47 | 48 | ```bash 49 | go run examples/chi/main.go 50 | ``` 51 | 52 | This will spin up a server on `http://localhost:8080`. You can then interact with your local server by going to `https://sqliteadmin.dev` and passing in the following credentials: 53 | 54 | ``` 55 | username: user 56 | password: password 57 | endpoint: http://localhost:8080/admin 58 | ``` 59 | 60 | > [!NOTE] 61 | > If you are seeing "An unexpected error occurred" when trying to connect, try disabling your adblock. 62 | 63 | ## Installing as a binary 64 | 65 | 1. Using `go install`: 66 | 67 | ```bash 68 | go install github.com/joelseq/sqliteadmin-go/cmd/sqliteadmin@latest 69 | ``` 70 | 71 | 2. Using `go build` (after cloning the repository): 72 | 73 | ```bash 74 | make build 75 | ``` 76 | 77 | This will add the `sqliteadmin` binary to `/tmp/bin` 78 | 79 | ### Usage 80 | 81 | In order to add authentication, the following environment variables are required: `SQLITEADMIN_USERNAME`, `SQLITEADMIN_PASSWORD`. 82 | 83 | e.g.: 84 | 85 | ```bash 86 | export SQLITEADMIN_USERNAME=user 87 | export SQLITEADMIN_PASSWORD=password 88 | ``` 89 | 90 | Start the server 91 | 92 | ```bash 93 | sqliteadmin serve -p 8080 94 | ``` 95 | 96 | Your SQLite database can now be accessed by visiting https://sqliteadmin.dev and providing the credentials and endpoint (including port). 97 | 98 | ## Inspiration 99 | 100 | The UI is heavily inspired by [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview). 101 | -------------------------------------------------------------------------------- /assets/sqlite-admin-filtering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joelseq/sqliteadmin-go/2f0228f5aeb0efd87db894acfb2f2ca1460f3551/assets/sqlite-admin-filtering.png -------------------------------------------------------------------------------- /chinook.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joelseq/sqliteadmin-go/2f0228f5aeb0efd87db894acfb2f2ca1460f3551/chinook.db -------------------------------------------------------------------------------- /cmd/sqliteadmin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | Execute() 5 | } 6 | -------------------------------------------------------------------------------- /cmd/sqliteadmin/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var rootCmd = &cobra.Command{ 10 | Use: "sqliteadmin", 11 | Short: "A web-based SQLite database management tool", 12 | Long: `sqliteadmin is a web-based SQLite database management tool that allows you to view and manage your SQLite database through a web interface (https://sqliteadmin.dev).`, 13 | } 14 | 15 | func Execute() { 16 | if err := rootCmd.Execute(); err != nil { 17 | log.Fatalln(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/sqliteadmin/serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/go-chi/chi/v5" 16 | "github.com/go-chi/chi/v5/middleware" 17 | "github.com/go-chi/cors" 18 | "github.com/joelseq/sqliteadmin-go" 19 | "github.com/spf13/cobra" 20 | _ "modernc.org/sqlite" 21 | ) 22 | 23 | var port uint 24 | 25 | func init() { 26 | serveCmd.Flags().UintVarP(&port, "port", "p", 8080, "Port to run server on") 27 | rootCmd.AddCommand(serveCmd) 28 | } 29 | 30 | var serveCmd = &cobra.Command{ 31 | Use: "serve [DB_PATH]", 32 | Short: "Spin up an HTTP server to serve requests to the SQLiteAdmin UI", 33 | Args: cobra.MinimumNArgs(1), 34 | Run: func(cmd *cobra.Command, args []string) { 35 | dbPath := args[0] 36 | username := os.Getenv("SQLITEADMIN_USERNAME") 37 | password := os.Getenv("SQLITEADMIN_PASSWORD") 38 | 39 | r := getRouter(dbPath, username, password) 40 | 41 | addr := fmt.Sprintf(":%d", port) 42 | 43 | // Create a done channel to signal when the shutdown is complete 44 | done := make(chan bool, 1) 45 | 46 | httpServer := newHTTPServer(addr, r) 47 | 48 | // Run graceful shutdown in a separate goroutine 49 | go gracefulShutdown(httpServer, done) 50 | 51 | err := httpServer.ListenAndServe() 52 | if err != nil && err != http.ErrServerClosed { 53 | panic(fmt.Sprintf("http server error: %s", err)) 54 | } 55 | 56 | // Wait for the graceful shutdown to complete 57 | <-done 58 | log.Println("Graceful shutdown complete.") 59 | }, 60 | } 61 | 62 | func newHTTPServer(addr string, mux *chi.Mux) *http.Server { 63 | return &http.Server{ 64 | Addr: addr, 65 | Handler: mux, 66 | IdleTimeout: time.Minute, 67 | ReadTimeout: 10 * time.Second, 68 | WriteTimeout: 30 * time.Second, 69 | } 70 | } 71 | 72 | func getRouter(dbPath, username, password string) *chi.Mux { 73 | db, err := sql.Open("sqlite", dbPath) 74 | if err != nil { 75 | log.Fatalf("Error opening database: %v", err) 76 | } 77 | 78 | logger := slog.Default() 79 | 80 | // Setup the handler for SQLiteAdmin 81 | config := sqliteadmin.Config{ 82 | DB: db, 83 | Username: username, 84 | Password: password, 85 | Logger: logger, 86 | } 87 | admin := sqliteadmin.New(config) 88 | 89 | r := chi.NewRouter() 90 | r.Use(middleware.Logger) 91 | r.Use(cors.Handler(cors.Options{ 92 | AllowedOrigins: []string{"https://*", "http://*"}, 93 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, 94 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, 95 | AllowCredentials: true, 96 | MaxAge: 300, 97 | })) 98 | r.Post("/", admin.HandlePost) 99 | 100 | return r 101 | } 102 | 103 | func gracefulShutdown(apiServer *http.Server, done chan bool) { 104 | // Create context that listens for the interrupt signal from the OS. 105 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 106 | defer stop() 107 | 108 | // Listen for the interrupt signal. 109 | <-ctx.Done() 110 | 111 | log.Println("shutting down gracefully, press Ctrl+C again to force") 112 | 113 | // The context is used to inform the server it has 5 seconds to finish 114 | // the request it is currently handling 115 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 116 | defer cancel() 117 | if err := apiServer.Shutdown(ctx); err != nil { 118 | log.Printf("Server forced to shutdown with error: %v", err) 119 | } 120 | 121 | log.Println("Server exiting") 122 | 123 | // Notify the main goroutine that the shutdown is complete 124 | done <- true 125 | } 126 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package sqliteadmin 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | var ( 10 | ErrMissingTableName = errors.New("missing table name") 11 | ErrMissingRow = errors.New("missing row") 12 | ErrInvalidOrMissingIds = errors.New("invalid or missing ids") 13 | ErrInvalidInput = errors.New("invalid input") 14 | ) 15 | 16 | type APIError struct { 17 | StatusCode int `json:"statusCode"` 18 | Message string `json:"message"` 19 | } 20 | 21 | func (e APIError) Error() string { 22 | return fmt.Sprintf("api error: %d, %s", e.StatusCode, e.Message) 23 | } 24 | 25 | func apiErrUnauthorized() APIError { 26 | return APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid credentials"} 27 | } 28 | 29 | func apiErrBadRequest(details string) APIError { 30 | return APIError{StatusCode: http.StatusBadRequest, Message: "Bad request: " + details} 31 | } 32 | 33 | func apiErrSomethingWentWrong() APIError { 34 | return APIError{StatusCode: http.StatusInternalServerError, Message: "Something went wrong"} 35 | } 36 | -------------------------------------------------------------------------------- /examples/chi/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "log/slog" 8 | "net/http" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/chi/v5/middleware" 12 | "github.com/go-chi/cors" 13 | "github.com/joelseq/sqliteadmin-go" 14 | _ "modernc.org/sqlite" 15 | ) 16 | 17 | const addr string = ":8080" 18 | 19 | func main() { 20 | db, err := sql.Open("sqlite", "chinook.db") 21 | if err != nil { 22 | log.Fatalf("Error opening database: %v", err) 23 | } 24 | 25 | logger := slog.Default() 26 | 27 | config := sqliteadmin.Config{ 28 | DB: db, 29 | Username: "user", 30 | Password: "password", 31 | Logger: logger, 32 | } 33 | admin := sqliteadmin.New(config) 34 | 35 | r := chi.NewRouter() 36 | r.Use(middleware.Logger) 37 | r.Use(cors.Handler(cors.Options{ 38 | AllowedOrigins: []string{"https://*", "http://*"}, 39 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, 40 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, 41 | AllowCredentials: true, 42 | MaxAge: 300, 43 | })) 44 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 45 | w.Write([]byte("welcome")) 46 | }) 47 | // Setup the handler for SQLiteAdmin 48 | r.Post("/admin", admin.HandlePost) 49 | 50 | fmt.Printf("--> Starting server on %s\n", addr) 51 | http.ListenAndServe(":8080", r) 52 | } 53 | -------------------------------------------------------------------------------- /examples/stdlib/stdlib.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "net/http" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/joelseq/sqliteadmin-go" 15 | "github.com/rs/cors" 16 | _ "modernc.org/sqlite" 17 | ) 18 | 19 | func main() { 20 | db, err := sql.Open("sqlite", "test.db") 21 | if err != nil { 22 | log.Fatalf("Error opening database: %v", err) 23 | } 24 | logger := slog.Default() 25 | 26 | config := sqliteadmin.Config{ 27 | DB: db, 28 | Username: "user", 29 | Password: "password", 30 | Logger: logger, 31 | } 32 | admin := sqliteadmin.New(config) 33 | 34 | mux := http.NewServeMux() 35 | 36 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 37 | w.Write([]byte("Hello, World!")) 38 | }) 39 | 40 | mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { 41 | if r.Method == "POST" { 42 | admin.HandlePost(w, r) 43 | } 44 | }) 45 | 46 | handler := cors.Default().Handler(mux) 47 | 48 | s := http.Server{ 49 | Addr: ":8080", 50 | Handler: handler, 51 | } 52 | 53 | // Create a done channel to signal when the shutdown is complete 54 | done := make(chan bool, 1) 55 | 56 | // Run graceful shutdown in a separate goroutine 57 | go gracefulShutdown(&s, done) 58 | 59 | fmt.Println("--> Server listening on port 8080") 60 | 61 | err = s.ListenAndServe() 62 | if err != nil && err != http.ErrServerClosed { 63 | panic(fmt.Sprintf("http server error: %s", err)) 64 | } 65 | 66 | // Wait for the graceful shutdown to complete 67 | <-done 68 | log.Println("Graceful shutdown complete.") 69 | } 70 | 71 | func gracefulShutdown(apiServer *http.Server, done chan bool) { 72 | // Create context that listens for the interrupt signal from the OS. 73 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 74 | defer stop() 75 | 76 | // Listen for the interrupt signal. 77 | <-ctx.Done() 78 | 79 | log.Println("shutting down gracefully, press Ctrl+C again to force") 80 | 81 | // The context is used to inform the server it has 5 seconds to finish 82 | // the request it is currently handling 83 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 84 | defer cancel() 85 | if err := apiServer.Shutdown(ctx); err != nil { 86 | log.Printf("Server forced to shutdown with error: %v", err) 87 | } 88 | 89 | log.Println("Server exiting") 90 | 91 | // Notify the main goroutine that the shutdown is complete 92 | done <- true 93 | } 94 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joelseq/sqliteadmin-go 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.2.0 7 | github.com/go-chi/cors v1.2.1 8 | github.com/mitchellh/mapstructure v1.5.0 9 | github.com/rs/cors v1.11.1 10 | github.com/spf13/cobra v1.9.1 11 | modernc.org/sqlite v1.35.0 12 | ) 13 | 14 | require ( 15 | github.com/dustin/go-humanize v1.0.1 // indirect 16 | github.com/google/uuid v1.6.0 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/mattn/go-isatty v0.0.20 // indirect 19 | github.com/ncruces/go-strftime v0.1.9 // indirect 20 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 21 | github.com/spf13/pflag v1.0.6 // indirect 22 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect 23 | golang.org/x/sys v0.28.0 // indirect 24 | modernc.org/libc v1.61.13 // indirect 25 | modernc.org/mathutil v1.7.1 // indirect 26 | modernc.org/memory v1.8.2 // indirect 27 | ) 28 | 29 | require ( 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/stretchr/testify v1.10.0 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 5 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 6 | github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 7 | github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 8 | github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 9 | github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 10 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 11 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 12 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 13 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 14 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 15 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 17 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 18 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 19 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 20 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 21 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 25 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 26 | github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= 27 | github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 28 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 29 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 30 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 31 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 32 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 33 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 34 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 35 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= 36 | golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 37 | golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= 38 | golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 39 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 40 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 43 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 44 | golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= 45 | golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 49 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 50 | modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= 51 | modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 52 | modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= 53 | modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= 54 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 55 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 56 | modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= 57 | modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 58 | modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= 59 | modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= 60 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 61 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 62 | modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= 63 | modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= 64 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 65 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 66 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 67 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 68 | modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw= 69 | modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic= 70 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 71 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 72 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 73 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 74 | -------------------------------------------------------------------------------- /queryhandlers.go: -------------------------------------------------------------------------------- 1 | package sqliteadmin 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/mitchellh/mapstructure" 12 | ) 13 | 14 | func (a *Admin) ping(w http.ResponseWriter) { 15 | a.logger.Info("Command: Ping") 16 | w.WriteHeader(http.StatusOK) 17 | json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 18 | } 19 | 20 | func (a *Admin) listTables(w http.ResponseWriter) { 21 | a.logger.Info("Command: ListTables") 22 | rows, err := a.db.Query("SELECT name FROM sqlite_master WHERE type='table';") 23 | if err != nil { 24 | a.logger.Error(fmt.Sprintf("Error listing tables: %v", err)) 25 | writeError(w, apiErrSomethingWentWrong()) 26 | return 27 | } 28 | defer rows.Close() 29 | 30 | var tables []string 31 | for rows.Next() { 32 | var table string 33 | if err := rows.Scan(&table); err != nil { 34 | a.logger.Error(fmt.Sprintf("Error scanning rows: %v", err)) 35 | writeError(w, apiErrSomethingWentWrong()) 36 | return 37 | } 38 | tables = append(tables, table) 39 | } 40 | 41 | json.NewEncoder(w).Encode(map[string][]string{"tables": tables}) 42 | } 43 | 44 | func (a *Admin) getTable(w http.ResponseWriter, params map[string]interface{}) { 45 | // Parse table name 46 | table, ok := params["tableName"].(string) 47 | if !ok { 48 | writeError(w, apiErrBadRequest(ErrMissingTableName.Error())) 49 | return 50 | } 51 | 52 | // Parse limit 53 | limit := DefaultLimit 54 | if params["limit"] != nil { 55 | // convert the limit parameter to an int 56 | limit, ok = convertNumber(params["limit"]) 57 | if !ok { 58 | limit = DefaultLimit 59 | } 60 | } 61 | 62 | // Parse offset 63 | offset := DefaultOffset 64 | if params["offset"] != nil { 65 | // convert the offset parameter to an int 66 | offset, ok = convertNumber(params["offset"]) 67 | if !ok { 68 | offset = DefaultOffset 69 | } 70 | } 71 | 72 | a.logger.Info(fmt.Sprintf("Command: GetTable, table=%s, limit=%d, offset=%d", table, limit, offset)) 73 | 74 | var condition *Condition 75 | conditionParam, ok := params["condition"] 76 | if ok { 77 | condition, ok = toCondition(conditionParam, a.logger) 78 | if !ok { 79 | writeError(w, apiErrBadRequest("Invalid condition")) 80 | return 81 | } 82 | a.logger.Debug(fmt.Sprintf("Condition provided: %v", condition)) 83 | } else { 84 | a.logger.Debug("No condition provided") 85 | } 86 | 87 | data, err := queryTable(a.db, table, condition, limit, offset, a.logger) 88 | if err != nil { 89 | a.logger.Error(fmt.Sprintf("Error querying table: %v", err)) 90 | writeError(w, apiErrSomethingWentWrong()) 91 | return 92 | } 93 | response := map[string]interface{}{"rows": data} 94 | 95 | if params["includeInfo"] == true { 96 | tableInfo, err := getTableInfo(a.db, table) 97 | if err != nil { 98 | a.logger.Error(fmt.Sprintf("Error getting table info: %v", err)) 99 | writeError(w, apiErrSomethingWentWrong()) 100 | return 101 | } 102 | response["tableInfo"] = tableInfo 103 | } 104 | a.logger.Info(fmt.Sprintf("Fetched %d rows", len(data))) 105 | 106 | json.NewEncoder(w).Encode(response) 107 | } 108 | 109 | func (a *Admin) deleteRows(w http.ResponseWriter, params map[string]interface{}) { 110 | table, ok := params["tableName"].(string) 111 | if !ok { 112 | writeError(w, apiErrBadRequest(ErrMissingTableName.Error())) 113 | return 114 | } 115 | 116 | ids, ok := convertToStrSlice(params["ids"]) 117 | if !ok { 118 | writeError(w, apiErrBadRequest(ErrInvalidOrMissingIds.Error())) 119 | return 120 | } 121 | 122 | a.logger.Info(fmt.Sprintf("Command: DeleteRows, table=%s, ids=%v", table, ids)) 123 | 124 | exists, err := checkTableExists(a.db, table) 125 | if err != nil { 126 | a.logger.Error(fmt.Sprintf("Error checking table existence: %v", err)) 127 | writeError(w, apiErrSomethingWentWrong()) 128 | return 129 | } 130 | if !exists { 131 | a.logger.Error(fmt.Sprintf("Error table does not exist: %s", table)) 132 | writeError(w, apiErrBadRequest(ErrInvalidInput.Error())) 133 | return 134 | } 135 | 136 | rowsAffected, err := batchDelete(a.db, table, ids) 137 | if err != nil { 138 | a.logger.Error(fmt.Sprintf("Error deleting rows from table: %v", err)) 139 | writeError(w, apiErrSomethingWentWrong()) 140 | return 141 | } 142 | a.logger.Info(fmt.Sprintf("Deleted %d row(s)", rowsAffected)) 143 | 144 | json.NewEncoder(w).Encode(map[string]string{"rowsAffected": fmt.Sprintf("%d", rowsAffected)}) 145 | } 146 | 147 | func (a *Admin) updateRow(w http.ResponseWriter, params map[string]interface{}) { 148 | table, ok := params["tableName"].(string) 149 | if !ok { 150 | writeError(w, apiErrBadRequest(ErrMissingTableName.Error())) 151 | return 152 | } 153 | 154 | row, ok := params["row"].(map[string]interface{}) 155 | if !ok { 156 | writeError(w, apiErrBadRequest(ErrMissingRow.Error())) 157 | return 158 | } 159 | 160 | a.logger.Info(fmt.Sprintf("Command: UpdateRow, table=%s, row=%v", table, row)) 161 | 162 | err := editRow(a.db, table, row) 163 | if err != nil { 164 | a.logger.Error(fmt.Sprintf("Error editing row: %v", err)) 165 | writeError(w, apiErrSomethingWentWrong()) 166 | return 167 | } 168 | a.logger.Info("Row updated") 169 | 170 | json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 171 | } 172 | 173 | func checkTableExists(db *sql.DB, tableName string) (bool, error) { 174 | var exists int 175 | err := db.QueryRow(` 176 | SELECT COUNT(*) FROM sqlite_master 177 | WHERE type='table' AND name=?`, tableName).Scan(&exists) 178 | if err != nil { 179 | return false, fmt.Errorf("error checking table existence: %v", err) 180 | } 181 | return exists > 0, nil 182 | } 183 | 184 | func queryTable(db *sql.DB, tableName string, condition *Condition, limit int, offset int, logger Logger) ([]map[string]interface{}, error) { 185 | // First, verify the table exists to prevent SQL injection 186 | exists, err := checkTableExists(db, tableName) 187 | if err != nil { 188 | return nil, fmt.Errorf("error checking table existence: %v", err) 189 | } 190 | if !exists { 191 | return nil, fmt.Errorf("table %s does not exist", tableName) 192 | } 193 | 194 | // Query to get column names 195 | rows, err := db.Query(fmt.Sprintf("SELECT * FROM %q LIMIT 0", tableName)) 196 | if err != nil { 197 | return nil, fmt.Errorf("error getting columns: %v", err) 198 | } 199 | columns, err := rows.Columns() 200 | rows.Close() 201 | if err != nil { 202 | return nil, fmt.Errorf("error reading columns: %v", err) 203 | } 204 | 205 | var query string 206 | 207 | var args []interface{} 208 | if condition != nil && len(condition.Cases) > 0 { 209 | // Build the query 210 | query = fmt.Sprintf("SELECT * FROM %s WHERE ", tableName) 211 | 212 | // Generate the conditions for the where clause 213 | var conditionQuery string 214 | conditionQuery, args = getCondition(condition) 215 | logger.Debug(fmt.Sprintf("ConditionQuery: %s", conditionQuery)) 216 | logger.Debug(fmt.Sprintf("Args: %v", args)) 217 | query += conditionQuery 218 | query += fmt.Sprintf(" LIMIT %d", limit) 219 | } else { 220 | query = fmt.Sprintf("SELECT * FROM %q LIMIT %d OFFSET %d", tableName, limit, offset) 221 | } 222 | 223 | logger.Info(fmt.Sprintf("About to perform query: `%s`", query)) 224 | 225 | // Now perform the actual query 226 | rows, err = db.Query(query, args...) 227 | if err != nil { 228 | return nil, fmt.Errorf("error querying table: %v", err) 229 | } 230 | defer rows.Close() 231 | 232 | // Prepare the result slice 233 | var result []map[string]interface{} 234 | 235 | // Prepare value holders 236 | values := make([]interface{}, len(columns)) 237 | scanArgs := make([]interface{}, len(columns)) 238 | for i := range values { 239 | scanArgs[i] = &values[i] 240 | } 241 | 242 | // Iterate through rows 243 | for rows.Next() { 244 | err = rows.Scan(scanArgs...) 245 | if err != nil { 246 | return nil, fmt.Errorf("error scanning row: %v", err) 247 | } 248 | 249 | // Create a map for this row 250 | row := make(map[string]interface{}) 251 | for i, col := range columns { 252 | val := values[i] 253 | switch v := val.(type) { 254 | case []byte: 255 | row[col] = string(v) 256 | default: 257 | row[col] = v 258 | } 259 | } 260 | result = append(result, row) 261 | } 262 | 263 | if err = rows.Err(); err != nil { 264 | return nil, fmt.Errorf("error reading rows: %v", err) 265 | } 266 | 267 | return result, nil 268 | } 269 | 270 | func getCondition(condition *Condition) (string, []interface{}) { 271 | var clause string 272 | var args []interface{} 273 | 274 | for i, c := range condition.Cases { 275 | if i > 0 { 276 | clause += fmt.Sprintf(" %s ", condition.LogicalOperator) 277 | } 278 | switch c.ConditionCaseType() { 279 | case "condition": 280 | condition := c.(Condition) 281 | subClause, subArgs := getCondition(&condition) 282 | clause += "(" + subClause + ")" 283 | args = append(args, subArgs...) 284 | case "filter": 285 | filter := c.(Filter) 286 | clause += getClause(filter) 287 | args = append(args, filter.Value) 288 | } 289 | } 290 | return clause, args 291 | } 292 | 293 | func getClause(filter Filter) string { 294 | switch filter.Operator { 295 | case OperatorEquals: 296 | return fmt.Sprintf("%s = ?", filter.Column) 297 | case OperatorLike: 298 | return fmt.Sprintf("%s LIKE '%%' || ? || '%%'", filter.Column) 299 | case OperatorNotEquals: 300 | return fmt.Sprintf("%s != ?", filter.Column) 301 | case OperatorLessThan: 302 | return fmt.Sprintf("%s < ?", filter.Column) 303 | case OperatorLessThanOrEquals: 304 | return fmt.Sprintf("%s <= ?", filter.Column) 305 | case OperatorGreaterThan: 306 | return fmt.Sprintf("%s > ?", filter.Column) 307 | case OperatorGreaterThanOrEquals: 308 | return fmt.Sprintf("%s >= ?", filter.Column) 309 | case OperatorIsNull: 310 | return fmt.Sprintf("%s IS NULL", filter.Column) 311 | case OperatorIsNotNull: 312 | return fmt.Sprintf("%s IS NOT NULL", filter.Column) 313 | default: 314 | return "" 315 | } 316 | } 317 | 318 | func batchDelete(db *sql.DB, tableName string, ids []any) (int64, error) { 319 | // Handle empty case 320 | if len(ids) == 0 { 321 | return 0, nil 322 | } 323 | 324 | // Get the primary key of the table 325 | tableInfo, err := getTableInfo(db, tableName) 326 | if err != nil { 327 | return 0, fmt.Errorf("error getting primary key for delete: %v", err) 328 | } 329 | columns, ok := tableInfo["columns"].([]map[string]interface{}) 330 | if !ok { 331 | return 0, fmt.Errorf("error getting primary key for delete") 332 | } 333 | var primaryKey string 334 | for _, column := range columns { 335 | if column["pk"].(int) == 1 { 336 | primaryKey = column["name"].(string) 337 | break 338 | } 339 | } 340 | 341 | if primaryKey == "" { 342 | return 0, fmt.Errorf("table %s does not have a primary key", tableName) 343 | } 344 | 345 | // Create the placeholders for the query (?,?,?) 346 | placeholders := make([]string, len(ids)) 347 | for i := range ids { 348 | placeholders[i] = "?" 349 | } 350 | 351 | // Build the query 352 | query := fmt.Sprintf( 353 | "DELETE FROM %s WHERE %s IN (%s)", 354 | tableName, 355 | primaryKey, 356 | strings.Join(placeholders, ","), 357 | ) 358 | 359 | // Execute the delete 360 | result, err := db.Exec(query, ids...) 361 | if err != nil { 362 | return 0, fmt.Errorf("batch delete failed: %v", err) 363 | } 364 | 365 | // Return number of rows affected 366 | return result.RowsAffected() 367 | } 368 | 369 | func getTableInfo(db *sql.DB, tableName string) (map[string]interface{}, error) { 370 | // First, verify the table exists to prevent SQL injection 371 | exists, err := checkTableExists(db, tableName) 372 | if err != nil { 373 | return nil, fmt.Errorf("error checking table existence: %v", err) 374 | } 375 | if !exists { 376 | return nil, fmt.Errorf("table %s does not exist", tableName) 377 | } 378 | 379 | // Query to get column names 380 | rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", tableName)) 381 | if err != nil { 382 | return nil, fmt.Errorf("error getting columns: %v", err) 383 | } 384 | defer rows.Close() 385 | 386 | // Prepare the result slice 387 | var result []map[string]interface{} 388 | 389 | // Iterate through rows 390 | for rows.Next() { 391 | var cid int 392 | var name string 393 | var dataType string 394 | var notNull int 395 | var defaultValue interface{} 396 | var pk int 397 | if err = rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil { 398 | return nil, fmt.Errorf("error scanning row: %v", err) 399 | } 400 | 401 | // Create a map for this row 402 | row := map[string]interface{}{ 403 | "cid": cid, 404 | "name": name, 405 | "dataType": dataType, 406 | "notNull": notNull, 407 | "pk": pk, 408 | } 409 | result = append(result, row) 410 | } 411 | 412 | if err = rows.Err(); err != nil { 413 | return nil, fmt.Errorf("error reading rows: %v", err) 414 | } 415 | 416 | // Get the number of rows 417 | var count int 418 | err = db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %q", tableName)).Scan(&count) 419 | if err != nil { 420 | return nil, fmt.Errorf("error getting row count: %v", err) 421 | } 422 | 423 | return map[string]interface{}{"columns": result, "count": count}, nil 424 | } 425 | 426 | func editRow(db *sql.DB, tableName string, row map[string]interface{}) error { 427 | // Get the primary key of the table 428 | tableInfo, err := getTableInfo(db, tableName) 429 | if err != nil { 430 | return fmt.Errorf("error getting primary key for edit: %v", err) 431 | } 432 | columns, ok := tableInfo["columns"].([]map[string]interface{}) 433 | if !ok { 434 | return fmt.Errorf("error getting primary key for edit") 435 | } 436 | var primaryKey string 437 | for _, column := range columns { 438 | if column["pk"].(int) == 1 { 439 | primaryKey = column["name"].(string) 440 | break 441 | } 442 | } 443 | 444 | if primaryKey == "" { 445 | return fmt.Errorf("table %s does not have a primary key", tableName) 446 | } 447 | 448 | if _, ok := row[primaryKey]; !ok { 449 | return fmt.Errorf("row does not contain primary key") 450 | } 451 | 452 | nonPKColumns := make(map[string]interface{}) 453 | for k, v := range row { 454 | if k != primaryKey { 455 | nonPKColumns[k] = v 456 | } 457 | } 458 | 459 | // Create the placeholders for the query (?,?,?) 460 | // We exclude the primary key from the placeholders 461 | placeholders := make([]string, len(row)-1) 462 | values := make([]interface{}, len(row)-1) 463 | i := 0 464 | for k, v := range nonPKColumns { 465 | // Add the column name to the placeholder string 466 | placeholders[i] = fmt.Sprintf("%s = ?", k) 467 | values[i] = v 468 | i++ 469 | } 470 | 471 | // Build the query 472 | query := fmt.Sprintf( 473 | "UPDATE %s SET %s WHERE %s = ?", 474 | tableName, 475 | strings.Join(placeholders, ","), 476 | primaryKey, 477 | ) 478 | 479 | // Add the primary key value to the end of the values slice 480 | values = append(values, row[primaryKey]) 481 | 482 | // Execute the update 483 | _, err = db.Exec(query, values...) 484 | if err != nil { 485 | return fmt.Errorf("edit row failed: %v", err) 486 | } 487 | 488 | return nil 489 | } 490 | 491 | func writeError(w http.ResponseWriter, err APIError) { 492 | w.WriteHeader(err.StatusCode) 493 | json.NewEncoder(w).Encode(err) 494 | } 495 | 496 | func convertToStrSlice(val interface{}) ([]any, bool) { 497 | // Check if the value is a slice 498 | slice, ok := val.([]interface{}) 499 | if !ok { 500 | return nil, false 501 | } 502 | 503 | // Convert each element to a string 504 | var result []any 505 | for _, v := range slice { 506 | str, ok := v.(string) 507 | if !ok { 508 | return nil, false 509 | } 510 | result = append(result, str) 511 | } 512 | 513 | return result, true 514 | } 515 | 516 | func toCondition(val interface{}, logger Logger) (*Condition, bool) { 517 | // Check if val is a map 518 | valMap, ok := val.(map[string]interface{}) 519 | if !ok { 520 | return nil, false 521 | } 522 | 523 | // Decode the value into a Condition 524 | condition := Condition{} 525 | 526 | if valMap["cases"] != nil { 527 | cases, ok := valMap["cases"].([]interface{}) 528 | if !ok { 529 | logger.Debug("Cases is not an array") 530 | return nil, false 531 | } 532 | for _, c := range cases { 533 | caseMap, ok := c.(map[string]interface{}) 534 | if !ok { 535 | logger.Debug("Case is not a map") 536 | return nil, false 537 | } 538 | // If the logicalOperator field exists then it is a Sub-Condition 539 | if caseMap["logicalOperator"] != nil { 540 | subCondition, ok := toCondition(caseMap, logger) 541 | if !ok { 542 | logger.Debug("Could not convert sub-condition") 543 | return nil, false 544 | } 545 | condition.Cases = append(condition.Cases, *subCondition) 546 | } else { 547 | filter := Filter{} 548 | err := mapstructure.Decode(c, &filter) 549 | if err != nil { 550 | logger.Error(fmt.Sprintf("Error decoding filter: %v", err)) 551 | return nil, false 552 | } 553 | condition.Cases = append(condition.Cases, filter) 554 | } 555 | } 556 | } 557 | 558 | if valMap["logicalOperator"] != nil { 559 | condition.LogicalOperator = LogicalOperator(valMap["logicalOperator"].(string)) 560 | } 561 | 562 | return &condition, true 563 | } 564 | 565 | func convertNumber(val interface{}) (int, bool) { 566 | switch v := val.(type) { 567 | case int: 568 | return v, true 569 | case float64: 570 | return int(v), true 571 | case string: 572 | i, err := strconv.Atoi(v) 573 | if err != nil { 574 | return 0, false 575 | } 576 | return i, true 577 | default: 578 | return 0, false 579 | } 580 | } 581 | -------------------------------------------------------------------------------- /sakila.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joelseq/sqliteadmin-go/2f0228f5aeb0efd87db894acfb2f2ca1460f3551/sakila.db -------------------------------------------------------------------------------- /sqliteadmin.go: -------------------------------------------------------------------------------- 1 | // Package sqliteadmin allows you to view and manage your SQLite database by 2 | // exposing an HTTP handler that you can easily integrate into any Go web 3 | // framework. 4 | 5 | package sqliteadmin 6 | 7 | import ( 8 | "database/sql" 9 | "encoding/json" 10 | "net/http" 11 | ) 12 | 13 | type Admin struct { 14 | db *sql.DB 15 | username string 16 | password string 17 | logger Logger 18 | } 19 | 20 | type Command string 21 | 22 | type Filter struct { 23 | Column string `json:"column"` 24 | Operator Operator `json:"operator"` 25 | Value string `json:"value"` 26 | } 27 | 28 | type Condition struct { 29 | Cases []Case `json:"cases" mapstructure:"cases"` 30 | LogicalOperator LogicalOperator `json:"logicalOperator" mapstructure:"logicalOperator"` 31 | } 32 | 33 | type Case interface { 34 | ConditionCaseType() string 35 | } 36 | 37 | func (c Condition) ConditionCaseType() string { 38 | return "condition" 39 | } 40 | 41 | func (f Filter) ConditionCaseType() string { 42 | return "filter" 43 | } 44 | 45 | type LogicalOperator string 46 | 47 | const ( 48 | LogicalOperatorAnd LogicalOperator = "and" 49 | LogicalOperatorOr LogicalOperator = "or" 50 | ) 51 | 52 | type Operator string 53 | 54 | const ( 55 | OperatorEquals Operator = "eq" 56 | OperatorLike Operator = "like" 57 | OperatorNotEquals Operator = "neq" 58 | OperatorLessThan Operator = "lt" 59 | OperatorLessThanOrEquals Operator = "lte" 60 | OperatorGreaterThan Operator = "gt" 61 | OperatorGreaterThanOrEquals Operator = "gte" 62 | OperatorIsNull Operator = "null" 63 | OperatorIsNotNull Operator = "notnull" 64 | ) 65 | 66 | const ( 67 | Ping Command = "Ping" 68 | ListTables Command = "ListTables" 69 | GetTable Command = "GetTable" 70 | DeleteRows Command = "DeleteRows" 71 | UpdateRow Command = "UpdateRow" 72 | ) 73 | 74 | const pathPrefixPlaceholder = "%%__path_prefix__%%" 75 | 76 | const ( 77 | DefaultLimit = 100 78 | DefaultOffset = 0 79 | ) 80 | 81 | type Logger interface { 82 | Info(format string, args ...interface{}) 83 | Error(format string, args ...interface{}) 84 | Debug(format string, args ...interface{}) 85 | } 86 | 87 | type LogLevel string 88 | 89 | const ( 90 | LogLevelInfo LogLevel = "info" 91 | LogLevelDebug LogLevel = "debug" 92 | ) 93 | 94 | type Config struct { 95 | DB *sql.DB 96 | Username string 97 | Password string 98 | Logger Logger 99 | } 100 | 101 | // Returns a *Admin which has a HandlePost method that can be used to handle 102 | // requests from https://sqliteadmin.dev. 103 | func New(c Config) *Admin { 104 | h := &Admin{ 105 | db: c.DB, 106 | username: c.Username, 107 | password: c.Password, 108 | logger: c.Logger, 109 | } 110 | 111 | if h.logger == nil { 112 | h.logger = &defaultLogger{} 113 | } 114 | 115 | return h 116 | } 117 | 118 | type CommandRequest struct { 119 | Command Command `json:"command"` 120 | Params map[string]interface{} `json:"params"` 121 | } 122 | 123 | // Handles the incoming HTTP POST request. This is responsible for handling 124 | // all the supported operations from https://sqliteadmin.dev 125 | func (a *Admin) HandlePost(w http.ResponseWriter, r *http.Request) { 126 | // Check for auth header that contains username and password 127 | w.Header().Set("Content-Type", "application/json") 128 | if a.username != "" && a.password != "" { 129 | authHeader := r.Header.Get("Authorization") 130 | if a.username+":"+a.password != authHeader { 131 | writeError(w, apiErrUnauthorized()) 132 | return 133 | } 134 | } 135 | 136 | var cr CommandRequest 137 | err := json.NewDecoder(r.Body).Decode(&cr) 138 | if err != nil { 139 | w.WriteHeader(http.StatusBadRequest) 140 | json.NewEncoder(w).Encode(map[string]string{"error": "Invalid Request Body"}) 141 | return 142 | } 143 | 144 | switch cr.Command { 145 | case Ping: 146 | a.ping(w) 147 | return 148 | case ListTables: 149 | a.listTables(w) 150 | return 151 | case GetTable: 152 | a.getTable(w, cr.Params) 153 | return 154 | case DeleteRows: 155 | a.deleteRows(w, cr.Params) 156 | return 157 | case UpdateRow: 158 | a.updateRow(w, cr.Params) 159 | return 160 | default: 161 | http.Error(w, "Invalid command", http.StatusBadRequest) 162 | } 163 | } 164 | 165 | var _ Logger = &defaultLogger{} 166 | 167 | type defaultLogger struct{} 168 | 169 | func (l *defaultLogger) Info(format string, args ...interface{}) {} 170 | 171 | func (l *defaultLogger) Error(format string, args ...interface{}) {} 172 | 173 | func (l *defaultLogger) Debug(format string, args ...interface{}) {} 174 | -------------------------------------------------------------------------------- /sqliteadmin_test.go: -------------------------------------------------------------------------------- 1 | package sqliteadmin_test 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/joelseq/sqliteadmin-go" 13 | "github.com/stretchr/testify/assert" 14 | _ "modernc.org/sqlite" 15 | ) 16 | 17 | func TestPing(t *testing.T) { 18 | ts, close := setupTestServer(t) 19 | defer close() 20 | 21 | body := sqliteadmin.CommandRequest{ 22 | Command: sqliteadmin.Ping, 23 | } 24 | 25 | req := makeRequest(t, ts.server.URL, body) 26 | res, err := http.DefaultClient.Do(req) 27 | assert.NoError(t, err) 28 | 29 | assert.Equal(t, http.StatusOK, res.StatusCode) 30 | 31 | result := readBody(t, res.Body) 32 | assert.Equal(t, "ok", result["status"]) 33 | } 34 | 35 | func TestListTables(t *testing.T) { 36 | ts, close := setupTestServer(t) 37 | defer close() 38 | 39 | body := sqliteadmin.CommandRequest{ 40 | Command: sqliteadmin.ListTables, 41 | } 42 | 43 | req := makeRequest(t, ts.server.URL, body) 44 | res, err := http.DefaultClient.Do(req) 45 | assert.NoError(t, err) 46 | 47 | assert.Equal(t, http.StatusOK, res.StatusCode) 48 | 49 | result := readBody(t, res.Body) 50 | assert.Equal(t, []interface{}{"users"}, result["tables"]) 51 | } 52 | 53 | type TestCase struct { 54 | name string 55 | params map[string]interface{} 56 | expectedStatus int 57 | expectedResponse map[string]interface{} 58 | } 59 | 60 | func TestDeleteRows(t *testing.T) { 61 | ts, close := setupTestServer(t) 62 | defer close() 63 | 64 | cases := []TestCase{ 65 | { 66 | name: "Failure: Missing Table Name", 67 | params: map[string]interface{}{ 68 | "ids": []string{"1", "2"}, 69 | }, 70 | expectedStatus: http.StatusBadRequest, 71 | expectedResponse: map[string]interface{}{ 72 | "statusCode": float64(http.StatusBadRequest), 73 | "message": "Bad request: missing table name", 74 | }, 75 | }, 76 | { 77 | name: "Failure: Missing IDs", 78 | params: map[string]interface{}{ 79 | "tableName": "users", 80 | }, 81 | expectedStatus: http.StatusBadRequest, 82 | expectedResponse: map[string]interface{}{ 83 | "statusCode": float64(http.StatusBadRequest), 84 | "message": "Bad request: invalid or missing ids", 85 | }, 86 | }, 87 | { 88 | name: "Failure: Invalid IDs", 89 | params: map[string]interface{}{ 90 | "tableName": "invalid", 91 | "ids": []string{"1", "2"}, 92 | }, 93 | expectedStatus: http.StatusBadRequest, 94 | expectedResponse: map[string]interface{}{ 95 | "statusCode": float64(http.StatusBadRequest), 96 | "message": "Bad request: invalid input", 97 | }, 98 | }, 99 | { 100 | name: "Success: Delete Rows", 101 | params: map[string]interface{}{ 102 | "tableName": "users", 103 | "ids": []string{"1", "2"}, 104 | }, 105 | expectedStatus: http.StatusOK, 106 | expectedResponse: map[string]interface{}{ 107 | "rowsAffected": "2", 108 | }, 109 | }, 110 | } 111 | 112 | runTestCases(cases, sqliteadmin.DeleteRows, t, ts.server) 113 | 114 | rows, err := getTableValues(ts.db, "users") 115 | 116 | assert.NoError(t, err) 117 | assert.Equal(t, 7, len(rows)) 118 | } 119 | 120 | func TestUpdateRow(t *testing.T) { 121 | ts, close := setupTestServer(t) 122 | defer close() 123 | 124 | cases := []TestCase{ 125 | { 126 | name: "Failure: Missing Table Name", 127 | params: map[string]interface{}{ 128 | "row": map[string]interface{}{ 129 | "id": "1", 130 | "name": "Alice", 131 | "email": "alice-updated@gmail.com", 132 | }, 133 | }, 134 | expectedStatus: http.StatusBadRequest, 135 | expectedResponse: map[string]interface{}{ 136 | "statusCode": float64(http.StatusBadRequest), 137 | "message": "Bad request: missing table name", 138 | }, 139 | }, 140 | { 141 | name: "Failure: Invalid Table Name", 142 | params: map[string]interface{}{ 143 | "tableName": "invalid", 144 | "row": map[string]interface{}{ 145 | "id": "1", 146 | "name": "Alice", 147 | "email": "alice-updated@gmail.com", 148 | }, 149 | }, 150 | expectedStatus: http.StatusInternalServerError, 151 | expectedResponse: map[string]interface{}{ 152 | "statusCode": float64(http.StatusInternalServerError), 153 | "message": "Something went wrong", 154 | }, 155 | }, 156 | { 157 | name: "Failure: Row does not contain primary key column", 158 | params: map[string]interface{}{ 159 | "tableName": "invalid", 160 | "row": map[string]interface{}{ 161 | "name": "Alice", 162 | "email": "alice-updated@gmail.com", 163 | }, 164 | }, 165 | expectedStatus: http.StatusInternalServerError, 166 | expectedResponse: map[string]interface{}{ 167 | "statusCode": float64(http.StatusInternalServerError), 168 | "message": "Something went wrong", 169 | }, 170 | }, 171 | { 172 | name: "Success: Update Row", 173 | params: map[string]interface{}{ 174 | "tableName": "users", 175 | "row": map[string]interface{}{ 176 | "id": "1", 177 | "name": "Alice", 178 | "email": "alice-updated@gmail.com", 179 | }, 180 | }, 181 | expectedStatus: http.StatusOK, 182 | expectedResponse: map[string]interface{}{ 183 | "status": "ok", 184 | }, 185 | }, 186 | } 187 | 188 | runTestCases(cases, sqliteadmin.UpdateRow, t, ts.server) 189 | 190 | rows, err := getTableValues(ts.db, "users") 191 | 192 | assert.NoError(t, err) 193 | assert.Equal(t, "alice-updated@gmail.com", rows[0]["email"]) 194 | } 195 | 196 | func TestGetTable(t *testing.T) { 197 | ts, close := setupTestServer(t) 198 | defer close() 199 | 200 | cases := []TestCase{ 201 | { 202 | name: "Failure: Missing Table Name", 203 | params: map[string]interface{}{ 204 | "limit": 10, 205 | }, 206 | expectedStatus: http.StatusBadRequest, 207 | expectedResponse: map[string]interface{}{ 208 | "statusCode": float64(http.StatusBadRequest), 209 | "message": "Bad request: missing table name", 210 | }, 211 | }, 212 | { 213 | name: "Failure: Invalid Table Name", 214 | params: map[string]interface{}{ 215 | "tableName": "invalid", 216 | "limit": 10, 217 | }, 218 | expectedStatus: http.StatusInternalServerError, 219 | expectedResponse: map[string]interface{}{ 220 | "statusCode": float64(http.StatusInternalServerError), 221 | "message": "Something went wrong", 222 | }, 223 | }, 224 | { 225 | name: "Success: Get Table with limit and offset", 226 | params: map[string]interface{}{ 227 | "tableName": "users", 228 | "limit": 2, 229 | "offset": 2, 230 | }, 231 | expectedStatus: http.StatusOK, 232 | expectedResponse: map[string]interface{}{ 233 | "rows": []interface{}{ 234 | map[string]interface{}{ 235 | "id": float64(3), 236 | "name": "Charlie", 237 | "email": "charlie@gmail.com", 238 | }, 239 | map[string]interface{}{ 240 | "id": float64(4), 241 | "name": "David", 242 | "email": "david@gmail.com", 243 | }, 244 | }, 245 | }, 246 | }, 247 | makeGetTableCondition("Success: Get Table with equal condition", 248 | sqliteadmin.Condition{ 249 | Cases: []sqliteadmin.Case{ 250 | sqliteadmin.Filter{ 251 | Column: "id", 252 | Operator: sqliteadmin.OperatorEquals, 253 | Value: "1", 254 | }, 255 | }, 256 | }, 257 | makeGetTableResponse([]responseRow{ 258 | {id: 1, name: "Alice", email: "alice@gmail.com"}, 259 | }), 260 | ), 261 | makeGetTableCondition("Success: Get Table with like condition", 262 | sqliteadmin.Condition{ 263 | Cases: []sqliteadmin.Case{ 264 | sqliteadmin.Filter{ 265 | Column: "email", 266 | Operator: sqliteadmin.OperatorLike, 267 | Value: "@gmail.com", 268 | }, 269 | }, 270 | }, 271 | makeGetTableResponse([]responseRow{ 272 | {id: 1, name: "Alice", email: "alice@gmail.com"}, 273 | {id: 2, name: "Bob", email: "bob@gmail.com"}, 274 | {id: 3, name: "Charlie", email: "charlie@gmail.com"}, 275 | {id: 4, name: "David", email: "david@gmail.com"}, 276 | {id: 7, name: "Grace", email: "grace@gmail.com"}, 277 | {id: 8, name: "Henry", email: "henry@gmail.com"}, 278 | }), 279 | ), 280 | makeGetTableCondition("Success: Get Table with not equals condition", 281 | sqliteadmin.Condition{ 282 | Cases: []sqliteadmin.Case{ 283 | sqliteadmin.Filter{ 284 | Column: "id", 285 | Operator: sqliteadmin.OperatorNotEquals, 286 | Value: "1", 287 | }, 288 | }, 289 | }, 290 | makeGetTableResponse([]responseRow{ 291 | {id: 2, name: "Bob", email: "bob@gmail.com"}, 292 | {id: 3, name: "Charlie", email: "charlie@gmail.com"}, 293 | {id: 4, name: "David", email: "david@gmail.com"}, 294 | {id: 5, name: "Eve", email: "eve@outlook.com"}, 295 | {id: 6, name: "Frank", email: "frank@yahoo.com"}, 296 | {id: 7, name: "Grace", email: "grace@gmail.com"}, 297 | {id: 8, name: "Henry", email: "henry@gmail.com"}, 298 | {id: 9, name: "Ivy", email: nil}, 299 | }), 300 | ), 301 | makeGetTableCondition("Success: Get Table with less than condition", 302 | sqliteadmin.Condition{ 303 | Cases: []sqliteadmin.Case{ 304 | sqliteadmin.Filter{ 305 | Column: "id", 306 | Operator: sqliteadmin.OperatorLessThan, 307 | Value: "3", 308 | }, 309 | }, 310 | }, 311 | makeGetTableResponse([]responseRow{ 312 | {id: 1, name: "Alice", email: "alice@gmail.com"}, 313 | {id: 2, name: "Bob", email: "bob@gmail.com"}, 314 | }), 315 | ), 316 | makeGetTableCondition("Success: Get Table with less than or equals condition", 317 | sqliteadmin.Condition{ 318 | Cases: []sqliteadmin.Case{ 319 | sqliteadmin.Filter{ 320 | Column: "id", 321 | Operator: sqliteadmin.OperatorLessThanOrEquals, 322 | Value: "2", 323 | }, 324 | }, 325 | }, 326 | makeGetTableResponse([]responseRow{ 327 | {id: 1, name: "Alice", email: "alice@gmail.com"}, 328 | {id: 2, name: "Bob", email: "bob@gmail.com"}, 329 | }), 330 | ), 331 | makeGetTableCondition("Success: Get Table with greater than condition", 332 | sqliteadmin.Condition{ 333 | Cases: []sqliteadmin.Case{ 334 | sqliteadmin.Filter{ 335 | Column: "id", 336 | Operator: sqliteadmin.OperatorGreaterThan, 337 | Value: "6", 338 | }, 339 | }, 340 | }, 341 | makeGetTableResponse([]responseRow{ 342 | {id: 7, name: "Grace", email: "grace@gmail.com"}, 343 | {id: 8, name: "Henry", email: "henry@gmail.com"}, 344 | {id: 9, name: "Ivy", email: nil}, 345 | }), 346 | ), 347 | makeGetTableCondition("Success: Get Table with greater than or equals condition", 348 | sqliteadmin.Condition{ 349 | Cases: []sqliteadmin.Case{ 350 | sqliteadmin.Filter{ 351 | Column: "id", 352 | Operator: sqliteadmin.OperatorGreaterThanOrEquals, 353 | Value: "7", 354 | }, 355 | }, 356 | }, 357 | makeGetTableResponse([]responseRow{ 358 | {id: 7, name: "Grace", email: "grace@gmail.com"}, 359 | {id: 8, name: "Henry", email: "henry@gmail.com"}, 360 | {id: 9, name: "Ivy", email: nil}, 361 | }), 362 | ), 363 | makeGetTableCondition("Success: Get Table with null condition", 364 | sqliteadmin.Condition{ 365 | Cases: []sqliteadmin.Case{ 366 | sqliteadmin.Filter{ 367 | Column: "email", 368 | Operator: sqliteadmin.OperatorIsNull, 369 | }, 370 | }, 371 | }, 372 | makeGetTableResponse([]responseRow{ 373 | {id: 9, name: "Ivy", email: nil}, 374 | }), 375 | ), 376 | makeGetTableCondition("Success: Get Table with not null condition", 377 | sqliteadmin.Condition{ 378 | Cases: []sqliteadmin.Case{ 379 | sqliteadmin.Filter{ 380 | Column: "email", 381 | Operator: sqliteadmin.OperatorIsNotNull, 382 | }, 383 | }, 384 | }, 385 | makeGetTableResponse([]responseRow{ 386 | {id: 1, name: "Alice", email: "alice@gmail.com"}, 387 | {id: 2, name: "Bob", email: "bob@gmail.com"}, 388 | {id: 3, name: "Charlie", email: "charlie@gmail.com"}, 389 | {id: 4, name: "David", email: "david@gmail.com"}, 390 | {id: 5, name: "Eve", email: "eve@outlook.com"}, 391 | {id: 6, name: "Frank", email: "frank@yahoo.com"}, 392 | {id: 7, name: "Grace", email: "grace@gmail.com"}, 393 | {id: 8, name: "Henry", email: "henry@gmail.com"}, 394 | }), 395 | ), 396 | } 397 | 398 | runTestCases(cases, sqliteadmin.GetTable, t, ts.server) 399 | } 400 | 401 | func runTestCases(testCases []TestCase, command sqliteadmin.Command, t *testing.T, srv *httptest.Server) { 402 | for _, tc := range testCases { 403 | t.Run(tc.name, func(t *testing.T) { 404 | body := sqliteadmin.CommandRequest{ 405 | Command: command, 406 | Params: tc.params, 407 | } 408 | 409 | req := makeRequest(t, srv.URL, body) 410 | res, err := http.DefaultClient.Do(req) 411 | assert.NoError(t, err) 412 | 413 | assert.Equal(t, tc.expectedStatus, res.StatusCode) 414 | 415 | result := readBody(t, res.Body) 416 | assert.EqualValues(t, tc.expectedResponse, result) 417 | }) 418 | } 419 | } 420 | 421 | func makeGetTableCondition(name string, condition sqliteadmin.Condition, expectedResponse map[string]interface{}) TestCase { 422 | return TestCase{ 423 | name: name, 424 | params: map[string]interface{}{ 425 | "tableName": "users", 426 | "condition": condition, 427 | }, 428 | expectedStatus: http.StatusOK, 429 | expectedResponse: expectedResponse, 430 | } 431 | } 432 | 433 | type responseRow struct { 434 | id int 435 | name string 436 | email any 437 | } 438 | 439 | func makeGetTableResponse(values []responseRow) map[string]interface{} { 440 | rows := make([]interface{}, len(values)) 441 | 442 | for i, v := range values { 443 | rows[i] = map[string]interface{}{ 444 | "id": float64(v.id), 445 | "name": v.name, 446 | "email": v.email, 447 | } 448 | } 449 | 450 | return map[string]interface{}{ 451 | "rows": rows, 452 | } 453 | } 454 | 455 | var testValues = [][]string{ 456 | {"Alice", "alice@gmail.com"}, 457 | {"Bob", "bob@gmail.com"}, 458 | {"Charlie", "charlie@gmail.com"}, 459 | {"David", "david@gmail.com"}, 460 | {"Eve", "eve@outlook.com"}, 461 | {"Frank", "frank@yahoo.com"}, 462 | {"Grace", "grace@gmail.com"}, 463 | {"Henry", "henry@gmail.com"}, 464 | {"Ivy"}, 465 | } 466 | 467 | type TestServer struct { 468 | server *httptest.Server 469 | db *sql.DB 470 | } 471 | 472 | func setupTestServer(t *testing.T) (*TestServer, func()) { 473 | db := setupDB(t) 474 | 475 | c := sqliteadmin.Config{ 476 | DB: db, 477 | Username: "user", 478 | Password: "password", 479 | } 480 | 481 | a := sqliteadmin.New(c) 482 | mux := http.NewServeMux() 483 | 484 | mux.HandleFunc("/", a.HandlePost) 485 | 486 | srv := httptest.NewServer(mux) 487 | 488 | // Create a simple http server 489 | // and send a request to the handler 490 | // to test the Ping command 491 | return &TestServer{ 492 | server: srv, 493 | db: db, 494 | }, func() { 495 | srv.Close() 496 | db.Close() 497 | } 498 | } 499 | 500 | func makeRequest(t *testing.T, url string, body interface{}) *http.Request { 501 | bodyJSON, err := json.Marshal(body) 502 | assert.NoError(t, err) 503 | 504 | bodyRdr := bytes.NewReader(bodyJSON) 505 | 506 | req, err := http.NewRequest("POST", url, bodyRdr) 507 | assert.NoError(t, err) 508 | req.Header.Set("Content-Type", "application/json") 509 | req.Header.Set("Authorization", "user:password") 510 | 511 | return req 512 | } 513 | 514 | func readBody(t *testing.T, body io.ReadCloser) map[string]interface{} { 515 | var res map[string]interface{} 516 | err := json.NewDecoder(body).Decode(&res) 517 | assert.NoError(t, err) 518 | return res 519 | } 520 | 521 | func setupDB(t *testing.T) *sql.DB { 522 | db, err := sql.Open("sqlite", ":memory:") 523 | assert.NoError(t, err) 524 | 525 | err = seedData(db) 526 | assert.NoError(t, err) 527 | 528 | return db 529 | } 530 | 531 | func seedData(db *sql.DB) error { 532 | _, err := db.Exec(` 533 | CREATE TABLE users ( 534 | id INTEGER PRIMARY KEY, 535 | name TEXT NOT NULL, 536 | email TEXT 537 | ); 538 | `) 539 | if err != nil { 540 | return err 541 | } 542 | 543 | for _, v := range testValues { 544 | if len(v) != 2 { 545 | _, err = db.Exec(` 546 | INSERT INTO users (name) VALUES (?) 547 | `, v[0]) 548 | if err != nil { 549 | return err 550 | } 551 | } else { 552 | _, err = db.Exec(` 553 | INSERT INTO users (name, email) VALUES (?, ?) 554 | `, v[0], v[1]) 555 | if err != nil { 556 | return err 557 | } 558 | } 559 | } 560 | 561 | return nil 562 | } 563 | 564 | func getTableValues(db *sql.DB, tableName string) ([]map[string]interface{}, error) { 565 | rows, err := db.Query("SELECT * FROM " + tableName) 566 | if err != nil { 567 | return nil, err 568 | } 569 | defer rows.Close() 570 | 571 | columns, err := rows.Columns() 572 | if err != nil { 573 | return nil, err 574 | } 575 | 576 | values := make([]map[string]interface{}, 0) 577 | 578 | for rows.Next() { 579 | rowValues := make([]interface{}, len(columns)) 580 | rowPointers := make([]interface{}, len(columns)) 581 | for i := range rowValues { 582 | rowPointers[i] = &rowValues[i] 583 | } 584 | 585 | err = rows.Scan(rowPointers...) 586 | if err != nil { 587 | return nil, err 588 | } 589 | 590 | rowMap := make(map[string]interface{}) 591 | for i, colName := range columns { 592 | val := rowValues[i] 593 | if b, ok := val.([]byte); ok { 594 | rowMap[colName] = string(b) 595 | } else { 596 | rowMap[colName] = val 597 | } 598 | } 599 | values = append(values, rowMap) 600 | } 601 | 602 | return values, nil 603 | } 604 | -------------------------------------------------------------------------------- /test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joelseq/sqliteadmin-go/2f0228f5aeb0efd87db894acfb2f2ca1460f3551/test.db --------------------------------------------------------------------------------