initial commit

This commit is contained in:
Anton
2024-04-07 00:12:54 +05:00
commit e86d717d13
45 changed files with 4951 additions and 0 deletions

42
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: build
on:
push:
branches: [main]
paths-ignore:
- Makefile
- README.md
pull_request:
branches: [main]
paths-ignore:
- Makefile
- README.md
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: "stable"
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsqlite3-dev
go get .
- name: Test and build
run: make test build
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: redka
path: build/redka
retention-days: 7

36
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: publish
on:
push:
tags:
- "*"
workflow_dispatch:
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: "stable"
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsqlite3-dev
go get .
- name: Release and publish
uses: goreleaser/goreleaser-action@v4
with:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CGO_ENABLED: "1"

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
build/

14
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,14 @@
builds:
- env:
- CGO_ENABLED=1
goos:
- linux
- darwin
goarch:
- amd64
- arm64
main: ./cmd/redka/main.go
archives:
- files:
- LICENSE

27
LICENSE Normal file
View File

@@ -0,0 +1,27 @@
Copyright (c) 2024, Anton Zhiyanov <https://antonz.org>
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Redka nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: setup lint vet test build run
ifneq ($(wildcard .git),)
build_rev := $(shell git rev-parse --short HEAD)
endif
build_date := $(shell date -u '+%Y-%m-%dT%H:%M:%S')
setup:
@go mod download
lint:
@golangci-lint run --print-issued-lines=false --out-format=colored-line-number ./...
vet:
@go vet ./...
test:
@go test ./... -v
build:
@CGO_ENABLED=1 go build -ldflags "-X main.commit=$(build_rev) -X main.date=$(build_date)" -o build/redka -v cmd/redka/main.go
run:
@./build/redka

423
README.md Normal file
View File

@@ -0,0 +1,423 @@
Redka aims to reimplement the good parts of Redis with SQLite, while remaining compatible with Redis protocol.
Notable features:
- Data does not have to fit in RAM.
- ACID transactions.
- SQL views for better introspection and reporting.
- Both in-process (Go API) and standalone (RESP) servers.
- Redis-compatible commands and wire protocol.
This is a work in progress. See below for the current status and roadmap.
[Commands](#commands) •
[Installation](#installation) •
[Usage](#usage) •
[Persistence](#persistence) •
[Performance](#performance) •
[Roadmap](#roadmap) •
[More](#more-information)
## Commands
Redka aims to support five core Redis data types: strings, lists, sets, hashes, and sorted sets.
### Strings
Strings are the most basic Redis type, representing a sequence of bytes. Redka supports the following string-related commands:
| Command | Go API | Description |
| ------------- | ----------------------- | --------------------------------------------------------------------- |
| `APPEND` | `DB.Str().Append` | Appends a string to the value of a key. |
| `DECR` | `DB.Str().Incr` | Decrements the integer value of a key by one. |
| `DECRBY` | `DB.Str().Incr` | Decrements a number from the integer value of a key. |
| `GET` | `DB.Str().Get` | Returns the string value of a key. |
| `GETRANGE` | `DB.Str().GetRange` | Returns a substring of the string stored at a key. |
| `GETSET` | `DB.Str().GetSet` | Sets the key to a new value and returns the prev value. |
| `INCR` | `DB.Str().Incr` | Increments the integer value of a key by one. |
| `INCRBY` | `DB.Str().Incr` | Increments the integer value of a key by a number. |
| `INCRBYFLOAT` | `DB.Str().Incr` | Increments the float value of a key by a number. |
| `MGET` | `DB.Str().GetMany` | Returns the string values of one or more keys. |
| `MSET` | `DB.Str().SetMany` | Sets the string values of one or more keys. |
| `MSETNX` | `DB.Str().SetManyNX` | Sets the string values of one or more keys when all keys don't exist. |
| `PSETEX` | `DB.Str().Set` | Sets the string value and expiration time (in ms) of a key. |
| `SET` | `DB.Str().Set` | Sets the string value of a key. |
| `SETEX` | `DB.Str().SetExpires` | Sets the string value and expiration (in sec) time of a key. |
| `SETNX` | `DB.Str().SetNotExists` | Sets the string value of a key when the key doesn't exist. |
| `SETRANGE` | `DB.Str().SetRange` | Overwrites a part of a string value with another by an offset. |
| `STRLEN` | `DB.Str().Length` | Returns the length of a string value. |
| `SUBSTR` | `DB.Str().GetRange` | Same as `GETRANGE`. |
The following string-related commands are not planned for 1.0:
```
GETDEL GETEX LCS
```
### Lists
Lists are lists of strings sorted by insertion order. Redka aims to support the following list-related commands in 1.0:
```
LINDEX LINSERT LLEN LPOP LPUSHX LRANGE LREM LSET
LTRIM RPOP RPOPLPUSH RPUSH RPUSHX
```
### Sets
Sets are unordered collections of unique strings. Redka aims to support the following set-related commands in 1.0:
```
SADD SCARD SDIFF SDIFFSTORE SINTER SINTERSTORE
SISMEMBER SMEMBERS SMOVE SPOP SRANDMEMBER SREM
SUNION SUNIONSTORE
```
### Hashes
Hashes are record types modeled as collections of field-value pairs. Redka aims to support the following hash-related commands in 1.0:
```
HDEL HEXISTS HGET HGETALL HINCRBY HINCRBYFLOAT HKEYS
HLEN HMGET HMSET HSET HSETNX HVALS
```
### Sorted sets
Sorted sets are collections of unique strings ordered by each string's associated score. Redka aims to support the following sorted set related commands in 1.0:
```
ZADD ZCARD ZCOUNT ZINCRBY ZINTERSTORE ZRANGE
ZRANK ZREM ZSCORE
```
### Key management
Redka supports the following key management (generic) commands:
| Command | Go API | Description |
| ----------- | ------------------- | ---------------------------------------------------------- |
| `DEL` | `DB.Key().Delete` | Deletes one or more keys. |
| `EXISTS` | `DB.Key().Exists` | Determines whether one or more keys exist. |
| `EXPIRE` | `DB.Key().Expire` | Sets the expiration time of a key (in seconds). |
| `EXPIREAT` | `DB.Key().ETime` | Sets the expiration time of a key to a Unix timestamp. |
| `KEYS` | `DB.Key().Search` | Returns all key names that match a pattern. |
| `PERSIST` | `DB.Key().Persist` | Removes the expiration time of a key. |
| `PEXPIRE` | `DB.Key().Expire` | Sets the expiration time of a key in ms. |
| `PEXPIREAT` | `DB.Key().ETime` | Sets the expiration time of a key to a Unix ms timestamp. |
| `RANDOMKEY` | `DB.Key().Random` | Returns a random key name from the database. |
| `RENAME` | `DB.Key().Rename` | Renames a key and overwrites the destination. |
| `RENAMENX` | `DB.Key().RenameNX` | Renames a key only when the target key name doesn't exist. |
| `SCAN` | `DB.Key().Scanner` | Iterates over the key names in the database. |
The following generic commands are not planned for 1.0:
```
COPY DUMP EXPIRETIME MIGRATE MOVE OBJECT PEXPIRETIME
PTTL RESTORE SORT SORT_RO TOUCH TTL TYPE UNLINK
WAIT WAITAOF
```
### Transactions
Redka supports the following transaction commands:
| Command | Go API | Description |
| --------- | ----------------------- | --------------------------------------- |
| `DISCARD` | `DB.View` / `DB.Update` | Discards a transaction. |
| `EXEC` | `DB.View` / `DB.Update` | Executes all commands in a transaction. |
| `MULTI` | `DB.View` / `DB.Update` | Starts a transaction. |
Unlike Redis, Redka's transactions are fully ACID, providing automatic rollback in case of failure.
The following transaction commands are not planned for 1.0:
```
UNWATCH WATCH
```
### Server/connection management
Redka supports only a couple of server and connection management commands:
| Command | Go API | Description |
| --------- | ---------- | ---------------------------------- |
| `ECHO` | — | Returns the given string. |
| `FLUSHDB` | `DB.Flush` | Remove all keys from the database. |
The rest of the server and connection management commands are not planned for 1.0.
## Installation
Redka can be installed as a standalone Redis-compatible server, or as a Go module for in-process use.
## Standalone server
Redka server is a single-file binary. Download it from the [releases](https://github.com/nalgeon/redka/releases):
```shell
curl -L -O "https://github.com/nalgeon/redka/releases/download/0.1.0/redka_0.1.0_linux_amd64.tar.gz"
tar xvzf redka_0.1.0_linux_amd64.tar.gz
chmod +x redka
```
[🚧 not implemented] Or pull with Docker as follows:
```shell
docker pull ghcr.io/nalgeon/redka
docker run -p 6379:6379 ghcr.io/nalgeon/redka data.db
```
## Go module
Install the module as follows:
```shell
go get github.com/nalgeon/redka
```
You'll also need an SQLite driver. Use `github.com/mattn/go-sqlite3` if you don't mind CGO. Otherwise use a pure Go driver `modernc.org/sqlite`. Install either with `go get` like this:
```shell
go get github.com/mattn/go-sqlite3
```
## Usage
Redka can be used as a standalone Redis-compatible server, or as an embeddable in-process server with Go API.
### Standalone server
Redka server is a single-file binary. After downloading and unpacking the release asset, run it as follows:
```
redka [-h host] [-p port] [db-path]
```
For example:
```shell
./redka
./redka data.db
./redka -h localhost -p 6379 data.db
```
Running without a DB path creates an in-memory database. The data is not persisted in this case, and will be gone when the server is stopped.
[🚧 not implemented] You can also run Redka with Docker as follows:
```shell
docker run -p 6379:6379 ghcr.io/nalgeon/redka data.db
```
Once the server is running, connect to it using `redis-cli` or an API client like `redis-py` or `go-redis` — just as you would with Redis.
```shell
redis-cli -h localhost -p 6379
```
```
127.0.0.1:6379> echo hello
"hello"
127.0.0.1:6379> set name alice
OK
127.0.0.1:6379> get name
"alice"
```
### In-process server
The primary object in Redka is the `DB`. To open or create your database, use the `redka.Open()` function:
```go
package main
import (
"log"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
)
func main() {
// Open the data.db file. It will be created if it doesn't exist.
db, err := redka.Open("data.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// ...
}
```
Don't forget to import the driver (here I use `github.com/mattn/go-sqlite3`). Using `modernc.org/sqlite` is slightly different, see `example/modernc/main.go` for details.
To open an in-memory database that doesn't persist to disk, use `:memory:` as the path to the file:
```go
redka.Open(":memory:") // All data is lost when the database is closed.
```
After opening the database, call `redka.DB` methods to run individual commands:
```go
db.Str().Set("name", "alice")
db.Str().Set("age", 25)
count, err := db.Key().Exists("name", "age", "city")
slog.Info("exists", "count", count, "err", err)
name, err := db.Str().Get("name")
slog.Info("get", "name", name, "err", err)
```
```
exists count=2 err=<nil>
get name="alice" err=<nil>
```
See the full example in `example/simple/main.go`.
Use transactions to batch commands. There are `View` (read-only transaction) and `Update` (writable transaction) methods for this:
```go
updCount := 0
err := db.Update(func(tx *redka.Tx) error {
err := tx.Str().Set("name", "bob")
if err != nil {
return err
}
updCount++
err = tx.Str().Set("age", 50)
if err != nil {
return err
}
updCount++
return nil
})
slog.Info("updated", "count", updCount, "err", err)
```
```
updated count=2 err=<nil>
```
See the full example in `example/tx/main.go`.
## Persistence
Redka stores data in a SQLite database using the following tables:
```
rkey
---
id integer primary key
key text not null
type integer not null
version integer not null
etime integer
mtime integer not null
rstring
---
key_id integer
value blob not null
```
To access the data with SQL, use views instead of tables:
```sql
select * from vstring;
```
```
┌────────┬──────┬───────┬────────┬───────┬─────────────────────┐
│ key_id │ key │ value │ type │ etime │ mtime │
├────────┼──────┼───────┼────────┼───────┼─────────────────────┤
│ 1 │ name │ alice │ string │ │ 2024-04-03 16:58:14 │
│ 2 │ age │ 50 │ string │ │ 2024-04-03 16:34:52 │
└────────┴──────┴───────┴────────┴───────┴─────────────────────┘
```
Type in views is the Redis data type. Times are in UTC.
## Performance
I've compared Redka with Redis using [redis-benchmark](https://redis.io/docs/management/optimization/benchmarks/) with the following parameters:
- 50 parallel connections
- 1000000 requests
- 10000 randomized keys
- GET/SET commands
Results:
```
redis-server --appendonly no
redis-benchmark -p 6379 -q -c 100 -n 1000000 -r 10000 -t get,set
SET: 162469.53 requests per second, p50=0.319 msec
GET: 157183.28 requests per second, p50=0.319 msec
```
```
./redka -p 6380 data.db
redis-benchmark -p 6380 -q -c 100 -n 1000000 -r 10000 -t get,set
SET: 25178.77 requests per second, p50=1.871 msec
GET: 54875.71 requests per second, p50=0.535 msec
```
So while Redka is 3-7 times slower than Redis (not surprising, since we are comparing a relational database to an in-memory data store), it can still do 25K writes/sec and 55K reads/sec, which is pretty good if you ask me.
## Roadmap
The project is on its way to 1.0.
The 1.0 release will include the following features from Redis 2.x (which I consider the "golden age" of the Redis API):
- Strings, lists, sets, hashes and sorted sets.
- Publish/subscribe.
- Key management.
- Transactions.
Future versions may include data types from later Redis versions (such as streams, HyperLogLog or geo) and more commands for existing types.
Features I'd rather not implement even in future versions:
- Lua scripting.
- Authentication and ACLs.
- Multiple databases.
- Watch/unwatch.
Features I definitely don't want to implement:
- Cluster.
- Sentinel.
## More information
### Contributing
Contributions are welcome. For anything other than bugfixes, please first open an issue to discuss what you want to change.
Be sure to add or update tests as appropriate.
### Acknowledgements
Redka would not be possible without these great projects and their creators:
- [Redis](https://redis.io/) ([Salvatore Sanfilippo](https://github.com/antirez)). It's such an amazing idea to go beyond the get-set paradigm and provide a convenient API for more complex data structures.
- [SQLite](https://sqlite.org/) ([D. Richard Hipp](https://www.sqlite.org/crew.html)). The in-process database powering the world.
- [Redcon](https://github.com/tidwall/redcon) ([Josh Baker](https://github.com/tidwall)). A very clean and convenient implementation of a RESP server.
### License
Copyright 2024 [Anton Zhiyanov](https://antonz.org/).
The software is available under the BSD-3-Clause license.
### Stay tuned
★ [Subscribe](https://antonz.org/subscribe/) to stay on top of new features.

101
cmd/redka/main.go Normal file
View File

@@ -0,0 +1,101 @@
// Redka server.
// Example usage:
// - server: ./redka -h localhost -p 6379 redka.db
// - client: docker run --rm -it redis redis-cli -h host.docker.internal -p 6379
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
"github.com/nalgeon/redka/internal/server"
)
// set by the build process
var (
version = "main"
commit = "none"
date = "unknown"
)
// Config holds the server configuration.
type Config struct {
Host string
Port string
Path string
Verbose bool
}
func (c *Config) Addr() string {
return net.JoinHostPort(c.Host, c.Port)
}
var config Config
func init() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: redka [options] <data-source>\n")
flag.PrintDefaults()
}
flag.StringVar(&config.Host, "h", "localhost", "server host")
flag.StringVar(&config.Port, "p", "6379", "server port")
flag.BoolVar(&config.Verbose, "v", false, "verbose logging")
}
func main() {
// Parse command line arguments.
flag.Parse()
if len(flag.Args()) > 1 {
flag.Usage()
os.Exit(1)
}
if len(flag.Args()) == 1 {
config.Path = flag.Arg(0)
}
// Prepare a context to handle shutdown signals.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Set up logging.
logLevel := new(slog.LevelVar)
logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})
logger := slog.New(logHandler)
slog.SetDefault(logger)
if config.Verbose {
logLevel.Set(slog.LevelDebug)
}
// Print version information.
slog.Info("starting redka", "version", version, "commit", commit, "built_at", date)
// Open the database.
db, err := redka.Open(config.Path)
if err != nil {
slog.Error("data source", "error", err)
os.Exit(1)
}
db.SetLogger(logger)
slog.Info("data source", "path", config.Path)
// Start the server.
srv := server.New(config.Addr(), db)
srv.Start()
// Wait for a shutdown signal.
<-ctx.Done()
// Stop the server.
if err := srv.Stop(); err != nil {
slog.Error("stop server", "error", err)
}
slog.Info("stop server")
}

27
example/go.mod Normal file
View File

@@ -0,0 +1,27 @@
module github.com/nalgeon/redka/example
replace github.com/nalgeon/redka => ../
go 1.22
require (
github.com/mattn/go-sqlite3 v1.14.22
github.com/nalgeon/redka v0.0.0-00010101000000-000000000000
modernc.org/sqlite v1.29.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.16.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

41
example/go.sum Normal file
View File

@@ -0,0 +1,41 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

30
example/modernc/main.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"database/sql"
"log"
"log/slog"
"github.com/nalgeon/redka"
driver "modernc.org/sqlite"
)
func main() {
// An example of using Redka with modernc.org/sqlite driver.
// modernc.org/sqlite uses a different driver name ("sqlite"), while
// Redka expects "sqlite3". So we have to re-register it as "sqlite3".
sql.Register("sqlite3", &driver.Driver{})
db, err := redka.Open("data.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
err = db.Str().Set("name", "alice")
slog.Info("set", "err", err)
count, err := db.Key().Exists("name", "age", "city")
slog.Info("exists", "count", count, "err", err)
}

34
example/simple/main.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"log"
"log/slog"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
)
func main() {
// A simple example of using Redka.
// Open a database.
db, err := redka.Open("data.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Set some string keys.
err = db.Str().Set("name", "alice")
slog.Info("set", "err", err)
err = db.Str().Set("age", 25)
slog.Info("set", "err", err)
// Check if the keys exist.
count, err := db.Key().Exists("name", "age", "city")
slog.Info("exists", "count", count, "err", err)
// Get a key.
name, err := db.Str().Get("name")
slog.Info("get", "name", name, "err", err)
}

65
example/tx/main.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"log"
"log/slog"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
)
func main() {
db, err := redka.Open("data.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
{
// Writable transaction.
updCount := 0
err := db.Update(func(tx *redka.Tx) error {
err := tx.Str().Set("name", "alice")
if err != nil {
return err
}
updCount++
err = tx.Str().Set("age", 25)
if err != nil {
return err
}
updCount++
return nil
})
slog.Info("updated", "count", updCount, "err", err)
}
{
// Read-only transaction.
type person struct {
name string
age int
}
var p person
err := db.View(func(tx *redka.Tx) error {
name, err := db.Str().Get("name")
if err != nil {
return err
}
p.name = name.String()
age, err := db.Str().Get("age")
if err != nil {
return err
}
// Only use MustInt() if you are sure that
// the key exists and is an integer.
p.age = age.MustInt()
return nil
})
slog.Info("get", "person", p, "err", err)
}
}

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module github.com/nalgeon/redka
go 1.22
require (
github.com/mattn/go-sqlite3 v1.14.22
github.com/tidwall/redcon v1.6.2
)
require (
github.com/tidwall/btree v1.7.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
)

9
go.sum Normal file
View File

@@ -0,0 +1,9 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/redcon v1.6.2 h1:5qfvrrybgtO85jnhSravmkZyC0D+7WstbfCs3MmPhow=
github.com/tidwall/redcon v1.6.2/go.mod h1:p5Wbsgeyi2VSTBWOcA5vRXrOb9arFTcU2+ZzFjqV75Y=

92
internal/command/a.go Normal file
View File

@@ -0,0 +1,92 @@
package command
import (
"errors"
"fmt"
"strings"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
var ErrInvalidInt = errors.New("ERR value is not an integer or out of range")
var ErrNestedMulti = errors.New("ERR MULTI calls can not be nested")
var ErrNotInMulti = errors.New("ERR EXEC without MULTI")
var ErrSyntax = errors.New("ERR syntax error")
func ErrInvalidArgNum(cmd string) error {
return fmt.Errorf("ERR wrong number of arguments for '%s' command", cmd)
}
func ErrInvalidExpireTime(cmd string) error {
return fmt.Errorf("ERR invalid expire time in '%s' command", cmd)
}
func ErrUnknownCmd(cmd string) error {
return fmt.Errorf("ERR unknown command '%s'", cmd)
}
func ErrUnknownSubcmd(cmd, subcmd string) error {
return fmt.Errorf("ERR unknown subcommand '%s %s'", cmd, subcmd)
}
// Cmd is a Redis-compatible command.
type Cmd interface {
Name() string
Err() error
String() string
Run(w redcon.Conn, red redka.Redka) (any, error)
}
type baseCmd struct {
name string
args [][]byte
err error
}
func newBaseCmd(args [][]byte) baseCmd {
return baseCmd{
name: strings.ToLower(string(args[0])),
args: args[1:],
}
}
func (cmd baseCmd) Name() string {
return cmd.name
}
func (cmd baseCmd) String() string {
var b strings.Builder
for i, arg := range cmd.args {
if i > 0 {
b.WriteByte(' ')
}
b.Write(arg)
}
return b.String()
}
func (cmd baseCmd) Err() error {
return cmd.err
}
// Parse parses a text representation of a command into a Cmd.
func Parse(args [][]byte) (Cmd, error) {
name := strings.ToLower(string(args[0]))
b := newBaseCmd(args)
switch name {
// server
case "config":
return parseConfig(b)
case "command":
return parseOK(b)
case "info":
return parseOK(b)
// connection
case "echo":
return parseEcho(b)
// string
case "get":
return parseGet(b)
case "set":
return parseSet(b)
default:
return parseUnknown(b)
}
}

125
internal/command/a_test.go Normal file
View File

@@ -0,0 +1,125 @@
package command
import (
"net"
"reflect"
"strconv"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
func getDB(tb testing.TB) *redka.DB {
tb.Helper()
db, err := redka.Open(":memory:")
if err != nil {
tb.Fatal(err)
}
return db
}
func mustParse[T Cmd](s string) T {
parts := strings.Split(s, " ")
args := buildArgs(parts[0], parts[1:]...)
cmd, err := Parse(args)
if err != nil {
panic(err)
}
return cmd.(T)
}
func buildArgs(name string, args ...string) [][]byte {
rargs := make([][]byte, len(args)+1)
rargs[0] = []byte(name)
for i, arg := range args {
rargs[i+1] = []byte(arg)
}
return rargs
}
func assertEqual(tb testing.TB, got, want any) {
tb.Helper()
if !reflect.DeepEqual(got, want) {
tb.Errorf("want %#v, got %#v", want, got)
}
}
func assertNoErr(tb testing.TB, got error) {
tb.Helper()
if got != nil {
tb.Errorf("unexpected error %T (%v)", got, got)
}
}
type fakeConn struct {
parts []string
ctx any
}
func (c *fakeConn) RemoteAddr() string {
return ""
}
func (c *fakeConn) Close() error {
return nil
}
func (c *fakeConn) WriteError(msg string) {
c.append(msg)
}
func (c *fakeConn) WriteString(str string) {
c.append(str)
}
func (c *fakeConn) WriteBulk(bulk []byte) {
c.append(string(bulk))
}
func (c *fakeConn) WriteBulkString(bulk string) {
c.append(bulk)
}
func (c *fakeConn) WriteInt(num int) {
c.append(strconv.Itoa(num))
}
func (c *fakeConn) WriteInt64(num int64) {
c.append(strconv.FormatInt(num, 10))
}
func (c *fakeConn) WriteUint64(num uint64) {
c.append(strconv.FormatUint(num, 10))
}
func (c *fakeConn) WriteArray(count int) {
c.append(strconv.Itoa(count))
}
func (c *fakeConn) WriteNull() {
c.append("(nil)")
}
func (c *fakeConn) WriteRaw(data []byte) {
c.append(string(data))
}
func (c *fakeConn) WriteAny(any interface{}) {
c.append(any.(string))
}
func (c *fakeConn) Context() interface{} {
return c.ctx
}
func (c *fakeConn) SetContext(v interface{}) {
c.ctx = v
}
func (c *fakeConn) SetReadBuffer(bytes int) {}
func (c *fakeConn) Detach() redcon.DetachedConn {
return nil
}
func (c *fakeConn) ReadPipeline() []redcon.Command {
return nil
}
func (c *fakeConn) PeekPipeline() []redcon.Command {
return nil
}
func (c *fakeConn) NetConn() net.Conn {
return nil
}
func (c *fakeConn) append(str string) {
c.parts = append(c.parts, str)
}
func (c *fakeConn) out() string {
return strings.Join(c.parts, ",")
}

View File

@@ -0,0 +1,62 @@
package command
import (
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Config is a container command for runtime configuration commands.
// This is a no-op implementation.
//
// CONFIG GET parameter [parameter ...]
// https://redis.io/commands/config-get
// CONFIG SET parameter value [parameter value ...]
// https://redis.io/commands/config-set
// CONFIG RESETSTAT
// https://redis.io/commands/config-resetstat
// CONFIG REWRITE
// https://redis.io/commands/config-rewrite
type Config struct {
baseCmd
subcmd string
param string
}
func parseConfig(b baseCmd) (*Config, error) {
cmd := &Config{baseCmd: b}
if len(b.args) < 1 {
return cmd, ErrInvalidArgNum(b.name)
}
cmd.subcmd = string(b.args[0])
switch cmd.subcmd {
case "get":
cmd.param = string(b.args[1])
case "set":
cmd.param = string(b.args[1])
case "resetstat":
// no-op
case "rewrite":
// no-op
default:
return cmd, ErrUnknownSubcmd(b.name, cmd.subcmd)
}
return cmd, nil
}
func (c *Config) Run(w redcon.Conn, _ redka.Redka) (any, error) {
switch c.subcmd {
case "get":
w.WriteArray(2)
w.WriteBulkString(c.param)
w.WriteBulkString("")
case "set":
w.WriteString("OK")
case "resetstat":
w.WriteString("OK")
case "rewrite":
w.WriteString("OK")
default:
w.WriteString("OK")
}
return true, nil
}

34
internal/command/echo.go Normal file
View File

@@ -0,0 +1,34 @@
package command
import (
"strings"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Echo returns the given string.
// ECHO message
// https://redis.io/commands/echo
type Echo struct {
baseCmd
parts []string
}
func parseEcho(b baseCmd) (*Echo, error) {
cmd := &Echo{baseCmd: b}
if len(b.args) < 1 {
return cmd, ErrInvalidArgNum(b.name)
}
cmd.parts = make([]string, len(b.args))
for i := 0; i < len(b.args); i++ {
cmd.parts[i] = string(cmd.args[i])
}
return cmd, nil
}
func (c *Echo) Run(w redcon.Conn, _ redka.Redka) (any, error) {
out := strings.Join(c.parts, " ")
w.WriteAny(out)
return out, nil
}

View File

@@ -0,0 +1,78 @@
package command
import (
"testing"
)
func TestEchoParse(t *testing.T) {
tests := []struct {
name string
args [][]byte
want []string
err error
}{
{
name: "echo",
args: buildArgs("echo"),
want: []string{},
err: ErrInvalidArgNum("echo"),
},
{
name: "echo hello",
args: buildArgs("echo", "hello"),
want: []string{"hello"},
err: nil,
},
{
name: "echo one two",
args: buildArgs("echo", "one", "two"),
want: []string{"one", "two"},
err: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd, err := Parse(test.args)
assertEqual(t, err, test.err)
if err == nil {
assertEqual(t, cmd.(*Echo).parts, test.want)
}
})
}
}
func TestEchoExec(t *testing.T) {
db := getDB(t)
defer db.Close()
tests := []struct {
name string
cmd *Echo
res any
out string
}{
{
name: "echo hello",
cmd: mustParse[*Echo]("echo hello"),
res: "hello",
out: "hello",
},
{
name: "echo one two",
cmd: mustParse[*Echo]("echo one two"),
res: "one two",
out: "one two",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
conn := new(fakeConn)
res, err := test.cmd.Run(conn, db)
assertNoErr(t, err)
assertEqual(t, res, test.res)
assertEqual(t, conn.out(), test.out)
})
}
}

38
internal/command/get.go Normal file
View File

@@ -0,0 +1,38 @@
package command
import (
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Get returns the string value of a key.
// GET key
// https://redis.io/commands/get
type Get struct {
baseCmd
key string
}
func parseGet(b baseCmd) (*Get, error) {
cmd := &Get{baseCmd: b}
if len(cmd.args) != 1 {
return cmd, ErrInvalidArgNum(cmd.name)
}
cmd.key = string(cmd.args[0])
return cmd, nil
}
func (cmd *Get) Run(w redcon.Conn, red redka.Redka) (any, error) {
v, err := red.Str().Get(cmd.key)
if err != nil {
w.WriteError(err.Error())
return nil, err
}
if v.IsEmpty() {
w.WriteNull()
return v, nil
}
w.WriteBulkString(v.String())
return v, nil
}

View File

@@ -0,0 +1,85 @@
package command
import (
"testing"
"github.com/nalgeon/redka"
)
func TestGetParse(t *testing.T) {
db := getDB(t)
defer db.Close()
tests := []struct {
name string
args [][]byte
want string
err error
}{
{
name: "get",
args: buildArgs("get"),
want: "",
err: ErrInvalidArgNum("get"),
},
{
name: "get name",
args: buildArgs("get", "name"),
want: "name",
err: nil,
},
{
name: "get name age",
args: buildArgs("get", "name", "age"),
want: "",
err: ErrInvalidArgNum("get"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd, err := Parse(test.args)
assertEqual(t, err, test.err)
if err == nil {
assertEqual(t, cmd.(*Get).key, test.want)
}
})
}
}
func TestGetExec(t *testing.T) {
db := getDB(t)
defer db.Close()
_ = db.Str().Set("name", "alice")
tests := []struct {
name string
cmd *Get
res any
out string
}{
{
name: "get found",
cmd: mustParse[*Get]("get name"),
res: redka.Value("alice"),
out: "alice",
},
{
name: "get not found",
cmd: mustParse[*Get]("get age"),
res: redka.Value(nil),
out: "(nil)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
conn := new(fakeConn)
res, err := test.cmd.Run(conn, db)
assertNoErr(t, err)
assertEqual(t, res, test.res)
assertEqual(t, conn.out(), test.out)
})
}
}

20
internal/command/ok.go Normal file
View File

@@ -0,0 +1,20 @@
package command
import (
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Dummy command that always returns OK.
type OK struct {
baseCmd
}
func parseOK(b baseCmd) (*OK, error) {
return &OK{baseCmd: b}, nil
}
func (c *OK) Run(w redcon.Conn, _ redka.Redka) (any, error) {
w.WriteString("OK")
return true, nil
}

112
internal/command/set.go Normal file
View File

@@ -0,0 +1,112 @@
package command
import (
"strconv"
"time"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Set sets the string value of a key, ignoring its type.
// The key is created if it doesn't exist.
// SET key value [NX | XX] [EX seconds | PX milliseconds ]
// https://redis.io/commands/set
type Set struct {
baseCmd
key string
value []byte
ifNX bool
ifXX bool
ttl time.Duration
}
func parseSet(b baseCmd) (*Set, error) {
parseExists := func(cmd *Set, value string) error {
switch value {
case "nx":
cmd.ifNX = true
case "xx":
cmd.ifXX = true
default:
return ErrSyntax
}
return nil
}
parseExpires := func(cmd *Set, unit string, value string) error {
valueInt, err := strconv.Atoi(value)
if err != nil {
return ErrInvalidInt
}
switch string(unit) {
case "ex":
cmd.ttl = time.Duration(valueInt) * time.Second
case "px":
cmd.ttl = time.Duration(valueInt) * time.Millisecond
default:
return ErrSyntax
}
if cmd.ttl <= 0 {
return ErrInvalidExpireTime(cmd.name)
}
return nil
}
cmd := &Set{baseCmd: b}
if len(cmd.args) < 2 || len(cmd.args) > 5 {
return cmd, ErrInvalidArgNum(cmd.name)
}
cmd.key = string(cmd.args[0])
cmd.value = cmd.args[1]
if len(cmd.args) == 3 || len(cmd.args) == 5 {
err := parseExists(cmd, string(cmd.args[2]))
if err != nil {
return cmd, err
}
}
if len(cmd.args) == 4 {
err := parseExpires(cmd, string(cmd.args[2]), string(cmd.args[3]))
if err != nil {
return cmd, err
}
}
if len(cmd.args) == 5 {
err := parseExpires(cmd, string(cmd.args[3]), string(cmd.args[4]))
if err != nil {
return cmd, err
}
}
return cmd, nil
}
func (cmd *Set) Run(w redcon.Conn, red redka.Redka) (any, error) {
var ok bool
var err error
if cmd.ifXX {
ok, err = red.Str().SetExists(cmd.key, cmd.value, cmd.ttl)
} else if cmd.ifNX {
ok, err = red.Str().SetNotExists(cmd.key, cmd.value, cmd.ttl)
} else {
err = red.Str().SetExpires(cmd.key, cmd.value, cmd.ttl)
ok = err == nil
}
if err != nil {
w.WriteError(err.Error())
return nil, err
}
if !ok {
w.WriteNull()
return false, nil
}
w.WriteString("OK")
return true, nil
}

View File

@@ -0,0 +1,160 @@
package command
import (
"testing"
"time"
)
func TestSetParse(t *testing.T) {
db := getDB(t)
defer db.Close()
tests := []struct {
name string
args [][]byte
want Set
err error
}{
{
name: "set",
args: buildArgs("set"),
want: Set{},
err: ErrInvalidArgNum("set"),
},
{
name: "set name",
args: buildArgs("set", "name"),
want: Set{},
err: ErrInvalidArgNum("set"),
},
{
name: "set name alice",
args: buildArgs("set", "name", "alice"),
want: Set{key: "name", value: []byte("alice")},
err: nil,
},
{
name: "set name alice nx",
args: buildArgs("set", "name", "alice", "nx"),
want: Set{key: "name", value: []byte("alice"), ifNX: true},
err: nil,
},
{
name: "set name alice xx",
args: buildArgs("set", "name", "alice", "xx"),
want: Set{key: "name", value: []byte("alice"), ifXX: true},
err: nil,
},
{
name: "set name alice nx xx",
args: buildArgs("set", "name", "alice", "nx", "xx"),
want: Set{},
// FIXME: should be ErrSyntax
err: ErrInvalidInt,
},
{
name: "set name alice ex 10",
args: buildArgs("set", "name", "alice", "ex", "10"),
want: Set{key: "name", value: []byte("alice"), ttl: 10 * time.Second},
err: nil,
},
{
name: "set name alice px 10",
args: buildArgs("set", "name", "alice", "ex", "0"),
want: Set{},
err: ErrInvalidExpireTime("set"),
},
{
name: "set name alice px 10",
args: buildArgs("set", "name", "alice", "px", "10"),
want: Set{key: "name", value: []byte("alice"), ttl: 10 * time.Millisecond},
err: nil,
},
{
name: "set name alice nx ex 10",
args: buildArgs("set", "name", "alice", "nx", "ex", "10"),
want: Set{key: "name", value: []byte("alice"), ifNX: true, ttl: 10 * time.Second},
err: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd, err := Parse(test.args)
assertEqual(t, err, test.err)
if err == nil {
setCmd := cmd.(*Set)
assertEqual(t, setCmd.key, test.want.key)
assertEqual(t, setCmd.value, test.want.value)
assertEqual(t, setCmd.ifNX, test.want.ifNX)
assertEqual(t, setCmd.ifXX, test.want.ifXX)
assertEqual(t, setCmd.ttl, test.want.ttl)
}
})
}
}
func TestSetExec(t *testing.T) {
db := getDB(t)
defer db.Close()
tests := []struct {
name string
cmd *Set
res any
out string
}{
{
name: "set",
cmd: mustParse[*Set]("set name alice"),
res: true,
out: "OK",
},
{
name: "set nx conflict",
cmd: mustParse[*Set]("set name alice nx"),
res: false,
out: "(nil)",
},
{
name: "set nx",
cmd: mustParse[*Set]("set age alice nx"),
res: true,
out: "OK",
},
{
name: "set xx",
cmd: mustParse[*Set]("set name bob xx"),
res: true,
out: "OK",
},
{
name: "set xx conflict",
cmd: mustParse[*Set]("set city paris xx"),
res: false,
out: "(nil)",
},
{
name: "set ex",
cmd: mustParse[*Set]("set name alice ex 10"),
res: true,
out: "OK",
},
{
name: "set nx ex",
cmd: mustParse[*Set]("set color blue nx ex 10"),
res: true,
out: "OK",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
conn := new(fakeConn)
res, err := test.cmd.Run(conn, db)
assertNoErr(t, err)
assertEqual(t, res, test.res)
assertEqual(t, conn.out(), test.out)
})
}
}

View File

@@ -0,0 +1,22 @@
package command
import (
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Unknown is a placeholder for unknown commands.
// Always returns an error.
type Unknown struct {
baseCmd
}
func parseUnknown(b baseCmd) (*Unknown, error) {
return &Unknown{baseCmd: b}, nil
}
func (cmd *Unknown) Run(w redcon.Conn, _ redka.Redka) (any, error) {
err := ErrUnknownCmd(cmd.name)
w.WriteError(err.Error())
return false, err
}

116
internal/server/handlers.go Normal file
View File

@@ -0,0 +1,116 @@
package server
import (
"log/slog"
"time"
"github.com/nalgeon/redka"
"github.com/nalgeon/redka/internal/command"
"github.com/tidwall/redcon"
)
func createHandlers(db *redka.DB) redcon.HandlerFunc {
return logging(parse(multi(handle(db))))
}
func logging(next redcon.HandlerFunc) redcon.HandlerFunc {
return func(conn redcon.Conn, cmd redcon.Command) {
start := time.Now()
next(conn, cmd)
slog.Debug("process command", "client", conn.RemoteAddr(),
"name", string(cmd.Args[0]), "time", time.Since(start))
}
}
func parse(next redcon.HandlerFunc) redcon.HandlerFunc {
return func(conn redcon.Conn, cmd redcon.Command) {
pcmd, err := command.Parse(cmd.Args)
if err != nil {
conn.WriteError(err.Error())
return
}
state := getState(conn)
state.push(pcmd)
next(conn, cmd)
}
}
func multi(next redcon.HandlerFunc) redcon.HandlerFunc {
return func(conn redcon.Conn, cmd redcon.Command) {
name := normName(cmd)
state := getState(conn)
if state.inMulti {
switch name {
case "multi":
state.pop()
conn.WriteError(command.ErrNestedMulti.Error())
case "exec":
state.pop()
conn.WriteArray(len(state.cmds))
next(conn, cmd)
state.inMulti = false
case "discard":
state.clear()
conn.WriteString("OK")
state.inMulti = false
default:
conn.WriteString("QUEUED")
}
} else {
switch name {
case "multi":
state.inMulti = true
state.pop()
conn.WriteString("OK")
case "exec":
state.pop()
conn.WriteError(command.ErrNotInMulti.Error())
case "discard":
state.pop()
conn.WriteError(command.ErrNotInMulti.Error())
default:
next(conn, cmd)
}
}
}
}
func handle(db *redka.DB) redcon.HandlerFunc {
return func(conn redcon.Conn, cmd redcon.Command) {
state := getState(conn)
if state.inMulti {
handleMulti(conn, state, db)
} else {
handleSingle(conn, state, db)
}
state.clear()
}
}
func handleMulti(conn redcon.Conn, state *connState, db *redka.DB) {
err := db.Update(func(tx *redka.Tx) error {
for _, pcmd := range state.cmds {
out, err := pcmd.Run(conn, tx)
if err != nil {
slog.Debug("run multi command", "client", conn.RemoteAddr(),
"name", pcmd.Name(), "out", out, "err", err)
return err
}
}
return nil
})
if err != nil {
conn.WriteError(err.Error())
}
}
func handleSingle(conn redcon.Conn, state *connState, db *redka.DB) {
pcmd := state.pop()
out, err := pcmd.Run(conn, db)
if err != nil {
slog.Debug("run single command", "client", conn.RemoteAddr(),
"name", pcmd.Name(), "out", out, "err", err)
return
}
}

View File

@@ -0,0 +1,110 @@
package server
import (
"net"
"strconv"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
func TestHandlers(t *testing.T) {
db, err := redka.Open(":memory:")
if err != nil {
t.Fatal(err)
}
mux := createHandlers(db)
tests := []struct {
cmd redcon.Command
want string
}{
{
cmd: redcon.Command{
Raw: []byte("ECHO hello"),
Args: [][]byte{[]byte("ECHO"), []byte("hello")},
},
want: "hello",
},
}
for _, test := range tests {
conn := new(fakeConn)
mux.ServeRESP(conn, test.cmd)
if conn.out() != test.want {
t.Fatalf("want '%s', got '%s'", test.want, conn.out())
}
}
}
type fakeConn struct {
parts []string
ctx any
}
func (c *fakeConn) RemoteAddr() string {
return ""
}
func (c *fakeConn) Close() error {
return nil
}
func (c *fakeConn) WriteError(msg string) {
c.append(msg)
}
func (c *fakeConn) WriteString(str string) {
c.append(str)
}
func (c *fakeConn) WriteBulk(bulk []byte) {
c.append(string(bulk))
}
func (c *fakeConn) WriteBulkString(bulk string) {
c.append(bulk)
}
func (c *fakeConn) WriteInt(num int) {
c.append(strconv.Itoa(num))
}
func (c *fakeConn) WriteInt64(num int64) {
c.append(strconv.FormatInt(num, 10))
}
func (c *fakeConn) WriteUint64(num uint64) {
c.append(strconv.FormatUint(num, 10))
}
func (c *fakeConn) WriteArray(count int) {
c.append(strconv.Itoa(count))
}
func (c *fakeConn) WriteNull() {
c.append("(nil)")
}
func (c *fakeConn) WriteRaw(data []byte) {
c.append(string(data))
}
func (c *fakeConn) WriteAny(any interface{}) {
c.append(any.(string))
}
func (c *fakeConn) Context() interface{} {
return c.ctx
}
func (c *fakeConn) SetContext(v interface{}) {
c.ctx = v
}
func (c *fakeConn) SetReadBuffer(bytes int) {}
func (c *fakeConn) Detach() redcon.DetachedConn {
return nil
}
func (c *fakeConn) ReadPipeline() []redcon.Command {
return nil
}
func (c *fakeConn) PeekPipeline() []redcon.Command {
return nil
}
func (c *fakeConn) NetConn() net.Conn {
return nil
}
func (c *fakeConn) append(str string) {
c.parts = append(c.parts, str)
}
func (c *fakeConn) out() string {
return strings.Join(c.parts, ",")
}

71
internal/server/server.go Normal file
View File

@@ -0,0 +1,71 @@
// Redka server
package server
import (
"log/slog"
"sync"
"github.com/nalgeon/redka"
"github.com/tidwall/redcon"
)
// Server represents a Redka server.
type Server struct {
addr string
srv *redcon.Server
db *redka.DB
wg *sync.WaitGroup
}
// New creates a new Redka server.
func New(addr string, db *redka.DB) *Server {
handler := createHandlers(db)
accept := func(conn redcon.Conn) bool {
slog.Info("accept connection", "client", conn.RemoteAddr())
return true
}
closed := func(conn redcon.Conn, err error) {
if err != nil {
slog.Debug("close connection", "client", conn.RemoteAddr(), "error", err)
} else {
slog.Debug("close connection", "client", conn.RemoteAddr())
}
}
return &Server{
addr: addr,
srv: redcon.NewServer(addr, handler, accept, closed),
db: db,
wg: &sync.WaitGroup{},
}
}
// Start starts the server.
func (s *Server) Start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
slog.Info("serve connections", "addr", s.addr)
err := s.srv.ListenAndServe()
if err != nil {
slog.Error("serve connections", "error", err)
}
}()
}
// Stop stops the server.
func (s *Server) Stop() error {
err := s.srv.Close()
if err != nil {
return err
}
slog.Debug("close redcon server", "addr", s.addr)
err = s.db.Close()
if err != nil {
return err
}
slog.Debug("close database")
s.wg.Wait()
return nil
}

49
internal/server/state.go Normal file
View File

@@ -0,0 +1,49 @@
package server
import (
"fmt"
"strings"
"github.com/nalgeon/redka/internal/command"
"github.com/tidwall/redcon"
)
func normName(cmd redcon.Command) string {
return strings.ToLower(string(cmd.Args[0]))
}
func getState(conn redcon.Conn) *connState {
state := conn.Context()
if state == nil {
state = new(connState)
conn.SetContext(state)
}
return state.(*connState)
}
type connState struct {
inMulti bool
cmds []command.Cmd
}
func (s *connState) push(cmd command.Cmd) {
s.cmds = append(s.cmds, cmd)
}
func (s *connState) pop() command.Cmd {
if len(s.cmds) == 0 {
return nil
}
var last command.Cmd
s.cmds, last = s.cmds[:len(s.cmds)-1], s.cmds[len(s.cmds)-1]
return last
}
func (s *connState) clear() {
s.cmds = []command.Cmd{}
}
func (s *connState) String() string {
cmds := make([]string, len(s.cmds))
for i, cmd := range s.cmds {
cmds[i] = cmd.Name()
}
return fmt.Sprintf("[inMulti=%v,commands=%v]", s.inMulti, cmds)
}

185
keydb.go Normal file
View File

@@ -0,0 +1,185 @@
// Redis-like key repository in SQLite.
package redka
import (
"database/sql"
"time"
)
// Keys is a key repository.
type Keys interface {
Exists(keys ...string) (int, error)
Search(pattern string) ([]string, error)
Scan(cursor int, pattern string, count int) (ScanResult, error)
Scanner(pattern string, pageSize int) *keyScanner
Random() (string, error)
Get(key string) (Key, error)
Expire(key string, ttl time.Duration) (bool, error)
ETime(key string, at time.Time) (bool, error)
Persist(key string) (bool, error)
Rename(key, newKey string) (bool, error)
RenameNX(key, newKey string) (bool, error)
Delete(keys ...string) (int, error)
}
// KeyDB is a database-backed key repository.
type KeyDB struct {
*sqlDB[*KeyTx]
}
// OpenKey creates a new database-backed key repository.
// Creates the database schema if necessary.
func OpenKey(db *sql.DB) (*KeyDB, error) {
d, err := openSQL(db, newKeyTx)
return &KeyDB{d}, err
}
// newKeyDB creates a new database-backed key repository.
// Does not create the database schema.
func newKeyDB(db *sql.DB) *KeyDB {
d := newSqlDB(db, newKeyTx)
return &KeyDB{d}
}
// Exists returns the number of existing keys among specified.
func (db *KeyDB) Exists(keys ...string) (int, error) {
var count int
err := db.View(func(tx *KeyTx) error {
var err error
count, err = tx.Exists(keys...)
return err
})
return count, err
}
// Search returns all keys matching pattern.
func (db *KeyDB) Search(pattern string) ([]string, error) {
var keys []string
err := db.View(func(tx *KeyTx) error {
var err error
keys, err = tx.Search(pattern)
return err
})
return keys, err
}
// Scan iterates over keys matching pattern by returning
// the next page based on the current state of the cursor.
// Count regulates the number of keys returned (count = 0 for default).
func (db *KeyDB) Scan(cursor int, pattern string, count int) (ScanResult, error) {
var out ScanResult
err := db.View(func(tx *KeyTx) error {
var err error
out, err = tx.Scan(cursor, pattern, count)
return err
})
return out, err
}
// Scanner returns an iterator for keys matching pattern.
// The scanner returns keys one by one, fetching a new page
// when the current one is exhausted. Set pageSize to 0 for default.
func (db *KeyDB) Scanner(pattern string, pageSize int) *keyScanner {
return newKeyScanner(db, pattern, pageSize)
}
// Random returns a random key.
func (db *KeyDB) Random() (string, error) {
var key string
err := db.View(func(tx *KeyTx) error {
var err error
key, err = tx.Random()
return err
})
return key, err
}
// Get returns a specific key with all associated details.
func (db *KeyDB) Get(key string) (Key, error) {
var k Key
err := db.View(func(tx *KeyTx) error {
var err error
k, err = tx.Get(key)
return err
})
return k, err
}
// Expire sets a timeout on the key using a relative duration.
func (db *KeyDB) Expire(key string, ttl time.Duration) (bool, error) {
var ok bool
err := db.Update(func(tx *KeyTx) error {
var err error
ok, err = tx.Expire(key, ttl)
return err
})
return ok, err
}
// ETime sets a timeout on the key using an absolute time.
func (db *KeyDB) ETime(key string, at time.Time) (bool, error) {
var ok bool
err := db.Update(func(tx *KeyTx) error {
var err error
ok, err = tx.ETime(key, at)
return err
})
return ok, err
}
// Persist removes a timeout on the key.
func (db *KeyDB) Persist(key string) (bool, error) {
var ok bool
err := db.Update(func(tx *KeyTx) error {
var err error
ok, err = tx.Persist(key)
return err
})
return ok, err
}
// Rename changes the key name.
// If there is an existing key with the new name, it is replaced.
func (db *KeyDB) Rename(key, newKey string) (bool, error) {
var ok bool
err := db.Update(func(tx *KeyTx) error {
var err error
ok, err = tx.Rename(key, newKey)
return err
})
return ok, err
}
// RenameIfNotExists changes the key name.
// If there is an existing key with the new name, does nothing.
func (db *KeyDB) RenameNX(key, newKey string) (bool, error) {
var ok bool
err := db.Update(func(tx *KeyTx) error {
var err error
ok, err = tx.RenameNX(key, newKey)
return err
})
return ok, err
}
// Delete deletes keys and their values.
// Returns the number of deleted keys. Non-existing keys are ignored.
func (db *KeyDB) Delete(keys ...string) (int, error) {
var count int
err := db.Update(func(tx *KeyTx) error {
var err error
count, err = tx.Delete(keys...)
return err
})
return count, err
}
// deleteExpired deletes keys with expired TTL, but no more than n keys.
// If n = 0, deletes all expired keys.
func (db *KeyDB) deleteExpired(n int) (count int, err error) {
err = db.Update(func(tx *KeyTx) error {
count, err = tx.deleteExpired(n)
return err
})
return count, err
}

58
keydb_internal_test.go Normal file
View File

@@ -0,0 +1,58 @@
package redka
import (
"reflect"
"testing"
"time"
)
func Test_deleteExpired(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.keyDB
t.Run("delete all", func(t *testing.T) {
_ = red.Str().SetExpires("name", "alice", 1*time.Millisecond)
_ = red.Str().SetExpires("age", 25, 1*time.Millisecond)
time.Sleep(2 * time.Millisecond)
count, err := db.deleteExpired(0)
assertNoErr(t, err)
assertEqual(t, count, 2)
count, _ = db.Exists("name", "age")
assertEqual(t, count, 0)
})
t.Run("delete n", func(t *testing.T) {
_ = red.Str().SetExpires("name", "alice", 1*time.Millisecond)
_ = red.Str().SetExpires("age", 25, 1*time.Millisecond)
time.Sleep(2 * time.Millisecond)
count, err := db.deleteExpired(1)
assertNoErr(t, err)
assertEqual(t, count, 1)
})
}
func getDB(tb testing.TB) *DB {
tb.Helper()
db, err := Open(":memory:")
if err != nil {
tb.Fatal(err)
}
return db
}
func assertEqual(tb testing.TB, got, want any) {
tb.Helper()
if !reflect.DeepEqual(got, want) {
tb.Errorf("want %#v, got %#v", want, got)
}
}
func assertNoErr(tb testing.TB, got error) {
tb.Helper()
if got != nil {
tb.Errorf("unexpected error %T (%v)", got, got)
}
}

357
keydb_test.go Normal file
View File

@@ -0,0 +1,357 @@
package redka_test
import (
"testing"
"time"
"github.com/nalgeon/redka"
)
func TestKeyExists(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
tests := []struct {
name string
keys []string
want int
}{
{"all found", []string{"name", "age"}, 2},
{"some found", []string{"name", "key1"}, 1},
{"none found", []string{"key1", "key2"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
count, err := db.Exists(tt.keys...)
assertNoErr(t, err)
assertEqual(t, count, tt.want)
})
}
}
func TestKeySearch(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
tests := []struct {
name string
pattern string
want []string
}{
{"all found", "*", []string{"name", "age"}},
{"some found", "na*", []string{"name"}},
{"none found", "key*", []string(nil)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
keys, err := db.Search(tt.pattern)
assertNoErr(t, err)
assertEqual(t, keys, tt.want)
})
}
}
func TestKeyScan(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("11", "11")
_ = red.Str().Set("12", "12")
_ = red.Str().Set("21", "21")
_ = red.Str().Set("22", "22")
_ = red.Str().Set("31", "31")
tests := []struct {
name string
cursor int
pattern string
count int
wantCursor int
wantKeys []string
}{
{"all", 0, "*", 10, 5, []string{"11", "12", "21", "22", "31"}},
{"some", 0, "2*", 10, 4, []string{"21", "22"}},
{"none", 0, "n*", 10, 0, []string{}},
{"cursor 1st", 0, "*", 2, 2, []string{"11", "12"}},
{"cursor 2nd", 2, "*", 2, 4, []string{"21", "22"}},
{"cursor 3rd", 4, "*", 2, 5, []string{"31"}},
{"exhausted", 6, "*", 2, 0, []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := db.Scan(tt.cursor, tt.pattern, tt.count)
assertNoErr(t, err)
assertEqual(t, out.Cursor, tt.wantCursor)
keyNames := make([]string, len(out.Keys))
for i, key := range out.Keys {
keyNames[i] = key.Key
}
assertEqual(t, keyNames, tt.wantKeys)
})
}
}
func TestKeyScanner(t *testing.T) {
red := getDB(t)
defer red.Close()
_ = red.Str().Set("11", "11")
_ = red.Str().Set("12", "12")
_ = red.Str().Set("21", "21")
_ = red.Str().Set("22", "22")
_ = red.Str().Set("31", "31")
var keys []redka.Key
err := red.View(func(tx *redka.Tx) error {
sc := tx.Key().Scanner("*", 2)
for sc.Scan() {
keys = append(keys, sc.Key())
}
return sc.Err()
})
assertNoErr(t, err)
keyNames := make([]string, len(keys))
for i, key := range keys {
keyNames[i] = key.Key
}
assertEqual(t, keyNames, []string{"11", "12", "21", "22", "31"})
}
func TestKeyRandom(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
key, err := db.Random()
assertNoErr(t, err)
if key != "name" && key != "age" {
t.Errorf("want name or age, got %s", key)
}
}
func TestKeyGet(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
tests := []struct {
name string
key string
want redka.Key
}{
{"found", "name",
redka.Key{
ID: 1, Key: "name", Type: 1, Version: 1, ETime: nil, MTime: 0,
},
},
{"not found", "key1", redka.Key{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, err := db.Get(tt.key)
assertNoErr(t, err)
assertEqual(t, key.ID, tt.want.ID)
assertEqual(t, key.Key, tt.want.Key)
assertEqual(t, key.Type, tt.want.Type)
assertEqual(t, key.Version, tt.want.Version)
assertEqual(t, key.ETime, tt.want.ETime)
})
}
}
func TestKeyExpire(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
now := time.Now()
ttl := 10 * time.Second
ok, err := db.Expire("name", ttl)
assertNoErr(t, err)
assertEqual(t, ok, true)
key, _ := db.Get("name")
if key.ETime == nil {
t.Error("want expired time, got nil")
}
got := (*key.ETime) / 1000
want := now.Add(ttl).UnixMilli() / 1000
if got != want {
t.Errorf("want %v, got %v", want, got)
}
}
func TestKeyExpireAt(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
now := time.Now()
at := now.Add(10 * time.Second)
ok, err := db.ETime("name", at)
assertNoErr(t, err)
assertEqual(t, ok, true)
key, _ := db.Get("name")
if key.ETime == nil {
t.Error("want expired time, got nil")
}
got := (*key.ETime) / 1000
want := at.UnixMilli() / 1000
if got != want {
t.Errorf("want %v, got %v", want, got)
}
}
func TestKeyPersist(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
ok, err := db.Expire("name", 10*time.Second)
assertNoErr(t, err)
assertEqual(t, ok, true)
ok, err = db.Persist("name")
assertNoErr(t, err)
assertEqual(t, ok, true)
key, _ := db.Get("name")
if key.ETime != nil {
t.Error("want nil, got expired time")
}
}
func TestKeyRename(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
tests := []struct {
name string
key string
newKey string
val string
}{
{"create", "name", "city", "alice"},
{"rename", "name", "age", "alice"},
{"same", "name", "name", "alice"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
ok, err := db.Rename(tt.key, tt.newKey)
assertNoErr(t, err)
assertEqual(t, ok, true)
val, err := red.Str().Get(tt.newKey)
assertNoErr(t, err)
assertEqual(t, val.String(), tt.val)
})
}
t.Run("not found", func(t *testing.T) {
_ = red.Str().Set("name", "alice")
ok, err := db.Rename("key1", "name")
assertEqual(t, err, redka.ErrKeyNotFound)
assertEqual(t, ok, false)
})
}
func TestKeyRenameNX(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
t.Run("rename", func(t *testing.T) {
_ = red.Str().Set("name", "alice")
ok, err := db.RenameNX("name", "title")
assertNoErr(t, err)
assertEqual(t, ok, true)
title, _ := red.Str().Get("title")
assertEqual(t, title.String(), "alice")
})
t.Run("same name", func(t *testing.T) {
_ = red.Str().Set("name", "alice")
ok, err := db.RenameNX("name", "name")
assertNoErr(t, err)
assertEqual(t, ok, true)
name, _ := red.Str().Get("name")
assertEqual(t, name.String(), "alice")
})
t.Run("old does not exist", func(t *testing.T) {
ok, err := db.RenameNX("key1", "key2")
assertEqual(t, err, redka.ErrKeyNotFound)
assertEqual(t, ok, false)
})
t.Run("new exists", func(t *testing.T) {
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
ok, err := db.RenameNX("name", "age")
assertNoErr(t, err)
assertEqual(t, ok, false)
age, _ := red.Str().Get("age")
assertEqual(t, age, redka.Value("25"))
})
}
func TestDelete(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Key()
tests := []struct {
name string
keys []string
want int
}{
{"all", []string{"name", "age"}, 2},
{"some", []string{"name"}, 1},
{"none", []string{"key1"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = red.Str().Set("name", "alice")
_ = red.Str().Set("age", 25)
count, err := db.Delete(tt.keys...)
assertNoErr(t, err)
assertEqual(t, count, tt.want)
count, _ = db.Exists(tt.keys...)
assertEqual(t, count, 0)
for _, key := range tt.keys {
val, _ := red.Str().Get(key)
assertEqual(t, val.IsEmpty(), true)
}
})
}
}

361
keytx.go Normal file
View File

@@ -0,0 +1,361 @@
// Redis-like key repository in SQLite.
package redka
import (
"database/sql"
"slices"
"time"
)
const sqlKeyKeys = `
select key from rkey
where key glob :pattern and (etime is null or etime > :now)`
const sqlKeyScan = `
select id, key, type, version, etime, mtime from rkey
where id > :cursor and key glob :pattern and (etime is null or etime > :now)
limit :count`
const sqlKeyRandom = `
select key from rkey
where etime is null or etime > ?
order by random() limit 1`
const sqlKeyExpire = `
update rkey set etime = :at
where key = :key and (etime is null or etime > :now)`
const sqlKeyPersist = `
update rkey set etime = null
where key = :key and (etime is null or etime > :now)`
const sqlKeyRename = `
update or replace rkey set
id = old.id,
key = :new_key,
type = old.type,
version = old.version+1,
etime = old.etime,
mtime = :now
from (
select id, key, type, version, etime, mtime
from rkey
where key = :key and (etime is null or etime > :now)
) as old
where rkey.key = :key and (
rkey.etime is null or rkey.etime > :now
)`
const sqlKeyDelAllExpired = `
delete from rkey
where etime <= :now`
const sqlKeyDelNExpired = `
delete from rkey
where rowid in (
select rowid from rkey
where etime <= :now
limit :n
)`
const scanPageSize = 10
// KeyTx is a key repository transaction.
type KeyTx struct {
tx *sql.Tx
}
// newKeyTx creates a key repository transaction
// from a generic database transaction.
func newKeyTx(tx *sql.Tx) *KeyTx {
return &KeyTx{tx}
}
// Exists returns the number of existing keys among specified.
func (tx *KeyTx) Exists(keys ...string) (int, error) {
now := time.Now().UnixMilli()
query, keyArgs := sqlExpandIn(sqlKeyCount, ":keys", keys)
args := slices.Concat(keyArgs, []any{sql.Named("now", now)})
var count int
err := tx.tx.QueryRow(query, args...).Scan(&count)
return count, err
}
// Search returns all keys matching pattern.
func (tx *KeyTx) Search(pattern string) ([]string, error) {
now := time.Now().UnixMilli()
args := []any{sql.Named("pattern", pattern), sql.Named("now", now)}
scan := func(rows *sql.Rows) (string, error) {
var key string
err := rows.Scan(&key)
return key, err
}
var keys []string
keys, err := sqlSelect(tx.tx, sqlKeyKeys, args, scan)
return keys, err
}
// Scan iterates over keys matching pattern by returning
// the next page based on the current state of the cursor.
// Count regulates the number of keys returned (count = 0 for default).
func (tx *KeyTx) Scan(cursor int, pattern string, count int) (ScanResult, error) {
now := time.Now().UnixMilli()
if count == 0 {
count = scanPageSize
}
args := []any{
sql.Named("cursor", cursor),
sql.Named("pattern", pattern),
sql.Named("now", now),
sql.Named("count", count),
}
scan := func(rows *sql.Rows) (Key, error) {
var k Key
err := rows.Scan(&k.ID, &k.Key, &k.Type, &k.Version, &k.ETime, &k.MTime)
return k, err
}
var keys []Key
keys, err := sqlSelect(tx.tx, sqlKeyScan, args, scan)
if err != nil {
return ScanResult{}, err
}
// Select the maximum ID.
maxID := 0
for _, key := range keys {
if key.ID > maxID {
maxID = key.ID
}
}
return ScanResult{maxID, keys}, nil
}
// Scanner returns an iterator for keys matching pattern.
// The scanner returns keys one by one, fetching a new page
// when the current one is exhausted. Set pageSize to 0 for default.
func (tx *KeyTx) Scanner(pattern string, pageSize int) *keyScanner {
return newKeyScanner(tx, pattern, pageSize)
}
// Random returns a random key.
func (tx *KeyTx) Random() (string, error) {
now := time.Now().UnixMilli()
var key string
err := tx.tx.QueryRow(sqlKeyRandom, now).Scan(&key)
if err == sql.ErrNoRows {
return "", nil
}
// err := tx.tx.Get(&key, sqlKeyRandom, now)
return key, err
}
// Get returns a specific key with all associated details.
func (tx *KeyTx) Get(key string) (Key, error) {
now := time.Now().UnixMilli()
var k Key
// err := tx.tx.Get(&k, sqlKeyGet, key, now)
err := tx.tx.QueryRow(sqlKeyGet, key, now).Scan(
&k.ID, &k.Key, &k.Type, &k.Version, &k.ETime, &k.MTime,
)
if err == sql.ErrNoRows {
return Key{}, nil
}
return k, err
}
// Expire sets a timeout on the key using a relative duration.
func (tx *KeyTx) Expire(key string, ttl time.Duration) (bool, error) {
at := time.Now().Add(ttl)
return tx.ETime(key, at)
}
// ETime sets a timeout on the key using an absolute time.
func (tx *KeyTx) ETime(key string, at time.Time) (bool, error) {
now := time.Now().UnixMilli()
args := []any{
sql.Named("key", key),
sql.Named("now", now),
sql.Named("at", at.UnixMilli()),
}
res, err := tx.tx.Exec(sqlKeyExpire, args...)
if err != nil {
return false, err
}
count, _ := res.RowsAffected()
return count > 0, nil
}
// Persist removes a timeout on the key.
func (tx *KeyTx) Persist(key string) (bool, error) {
now := time.Now().UnixMilli()
args := []any{sql.Named("key", key), sql.Named("now", now)}
res, err := tx.tx.Exec(sqlKeyPersist, args...)
if err != nil {
return false, err
}
count, _ := res.RowsAffected()
return count > 0, nil
}
// Rename changes the key name.
// If there is an existing key with the new name, it is replaced.
func (tx *KeyTx) Rename(key, newKey string) (bool, error) {
// Make sure the old key exists.
oldK, err := txKeyGet(tx.tx, key)
if err != nil {
return false, err
}
if !oldK.Exists() {
return false, ErrKeyNotFound
}
// If the keys are the same, do nothing.
if key == newKey {
return true, nil
}
// Delete the new key if it exists.
_, err = tx.Delete(newKey)
if err != nil {
return false, err
}
// Rename the old key to the new key.
now := time.Now().UnixMilli()
args := []any{
sql.Named("key", key),
sql.Named("new_key", newKey),
sql.Named("now", now),
}
_, err = tx.tx.Exec(sqlKeyRename, args...)
return err == nil, err
}
// RenameNX changes the key name.
// If there is an existing key with the new name, does nothing.
func (tx *KeyTx) RenameNX(key, newKey string) (bool, error) {
// Make sure the old key exists.
oldK, err := txKeyGet(tx.tx, key)
if err != nil {
return false, err
}
if !oldK.Exists() {
return false, ErrKeyNotFound
}
// If the keys are the same, do nothing.
if key == newKey {
return true, nil
}
// Make sure the new key does not exist.
count, err := tx.Exists(newKey)
if err != nil {
return false, err
}
if count != 0 {
return false, nil
}
// Rename the old key to the new key.
now := time.Now().UnixMilli()
args := []any{
sql.Named("key", key),
sql.Named("new_key", newKey),
sql.Named("now", now),
}
_, err = tx.tx.Exec(sqlKeyRename, args...)
return err == nil, err
}
// Delete deletes keys and their values.
// Returns the number of deleted keys. Non-existing keys are ignored.
func (tx *KeyTx) Delete(keys ...string) (int, error) {
return txKeyDelete(tx.tx, keys...)
}
// deleteExpired deletes keys with expired TTL, but no more than n keys.
// If n = 0, deletes all expired keys.
func (tx *KeyTx) deleteExpired(n int) (int, error) {
now := time.Now().UnixMilli()
var res sql.Result
var err error
if n > 0 {
args := []any{sql.Named("now", now), sql.Named("n", n)}
res, err = tx.tx.Exec(sqlKeyDelNExpired, args...)
} else {
res, err = tx.tx.Exec(sqlKeyDelAllExpired, now)
}
if err != nil {
return 0, err
}
count, _ := res.RowsAffected()
return int(count), err
}
// ScanResult represents a result of the scan command.
type ScanResult struct {
Cursor int
Keys []Key
}
// keyScanner is the iterator for keys.
// Stops when there are no more keys or an error occurs.
type keyScanner struct {
db Keys
cursor int
pattern string
pageSize int
index int
cur Key
keys []Key
err error
}
func newKeyScanner(db Keys, pattern string, pageSize int) *keyScanner {
if pageSize == 0 {
pageSize = scanPageSize
}
return &keyScanner{
db: db,
cursor: 0,
pattern: pattern,
pageSize: pageSize,
index: 0,
keys: []Key{},
}
}
// Scan advances to the next key, fetching keys from db as necessary.
// Returns false when there are no more keys or an error occurs.
func (sc *keyScanner) Scan() bool {
if sc.index >= len(sc.keys) {
// Fetch a new page of keys.
out, err := sc.db.Scan(sc.cursor, sc.pattern, sc.pageSize)
if err != nil {
sc.err = err
return false
}
sc.cursor = out.Cursor
sc.keys = out.Keys
sc.index = 0
if len(sc.keys) == 0 {
return false
}
}
// Advance to the next key from the current page.
sc.cur = sc.keys[sc.index]
sc.index++
return true
}
// Key returns the current key.
func (sc *keyScanner) Key() Key {
return sc.cur
}
// Err returns the first error encountered during iteration.
func (sc *keyScanner) Err() error {
return sc.err
}

55
redis.go Normal file
View File

@@ -0,0 +1,55 @@
// Redis-specific behavior and errors.
package redka
import (
"strings"
)
// setRange overwrites part of the string according to Redis semantics.
func setRange(s string, offset int, value string) string {
if offset < 0 {
offset += len(s)
if offset < 0 {
offset = 0
} else if offset > len(s) {
offset = len(s)
}
}
if offset > len(s) {
return s + strings.Repeat("\x00", offset-len(s)) + value
}
if offset+len(value) > len(s) {
return s[:offset] + value
}
return s[:offset] + value + s[offset+len(value):]
}
// rangeToSlice converts Redis range offsets to Go slice offsets.
func rangeToSlice(length, start, end int) (int, int) {
if start < 0 {
start += length
}
if start < 0 {
start = 0
} else if start > length {
start = length
}
if end < 0 {
end += length
}
end++
if end <= 0 || end < start {
// for some reason Redis does not return an empty string in this case
return 0, 1
}
if end > length {
end = length
}
return start, end
}

169
redka.go Normal file
View File

@@ -0,0 +1,169 @@
// Redis database in SQLite.
package redka
import (
"context"
"database/sql"
"log/slog"
"time"
)
const driverName = "sqlite3"
const memoryURI = "file:redka?mode=memory&cache=shared"
const sqlDbFlush = `
pragma writable_schema = 1;
delete from sqlite_schema
where name like 'rkey%' or name like 'rstring%';
pragma writable_schema = 0;
vacuum;
pragma integrity_check;`
// Redka is a Redis-like repository.
type Redka interface {
Key() Keys
Str() Strings
}
// DB is a Redis-like database backed by SQLite.
type DB struct {
*sqlDB[*Tx]
keyDB *KeyDB
stringDB *StringDB
bg *time.Ticker
log *slog.Logger
}
// Open opens a new or existing database at the given path.
// Creates the database schema if necessary.
func Open(path string) (*DB, error) {
// Use in-memory database by default.
if path == "" {
path = memoryURI
}
db, err := sql.Open(driverName, path)
if err != nil {
return nil, err
}
return OpenDB(db)
}
// OpenDB connects to the database.
// Creates the database schema if necessary.
func OpenDB(db *sql.DB) (*DB, error) {
sdb, err := openSQL(db, newTx)
if err != nil {
return nil, err
}
rdb := &DB{
sqlDB: sdb,
keyDB: newKeyDB(db),
stringDB: NewStringDB(db),
log: slog.New(new(noopHandler)),
}
rdb.bg = rdb.startBgManager()
return rdb, nil
}
// Str returns the string repository.
func (db *DB) Str() Strings {
return db.stringDB
}
// Key returns the key repository.
func (db *DB) Key() Keys {
return db.keyDB
}
// Close closes the database.
func (db *DB) Close() error {
db.bg.Stop()
return db.db.Close()
}
// Flush deletes all keys and values from the database.
func (db *DB) Flush() error {
db.mu.Lock()
defer db.mu.Unlock()
_, err := db.db.Exec(sqlDbFlush)
if err != nil {
return err
}
err = db.init()
if err != nil {
return err
}
return nil
}
// SetLogger sets the logger for the database.
func (db *DB) SetLogger(l *slog.Logger) {
db.log = l
}
// startBgManager starts the goroutine than runs
// in the background and deletes expired keys.
// Triggers every 60 seconds, deletes up all expired keys.
func (db *DB) startBgManager() *time.Ticker {
// TODO: needs further investigation. Deleting all keys may be expensive
// and lead to timeouts for concurrent write operations.
// Adaptive limits based on the number of changed keys may be a solution.
// (see https://redis.io/docs/management/config-file/ > SNAPSHOTTING)
// And it doesn't help that SQLite's drivers do not support DELETE LIMIT,
// so we have to use DELETE IN (SELECT ...), which is more expensive.
const interval = 60 * time.Second
const nKeys = 0
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
count, err := db.keyDB.deleteExpired(nKeys)
if err != nil {
db.log.Error("bg: delete expired keys", "error", err)
} else {
db.log.Info("bg: delete expired keys", "count", count)
}
}
}()
return ticker
}
// Tx is a Redis-like database transaction.
type Tx struct {
tx *sql.Tx
keyTx *KeyTx
strTx *StringTx
}
func (tx *Tx) Str() Strings {
return tx.strTx
}
func (tx *Tx) Key() Keys {
return tx.keyTx
}
// newTx creates a new database transaction.
func newTx(tx *sql.Tx) *Tx {
return &Tx{tx: tx, keyTx: newKeyTx(tx), strTx: newStringTx(tx)}
}
// noopHandler is a silent log handler.
type noopHandler struct{}
func (h *noopHandler) Enabled(context.Context, slog.Level) bool {
return false
}
func (h *noopHandler) Handle(context.Context, slog.Record) error {
return nil
}
func (h *noopHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
}
func (h *noopHandler) WithGroup(name string) slog.Handler {
return h
}

101
redka_test.go Normal file
View File

@@ -0,0 +1,101 @@
package redka_test
import (
"errors"
"testing"
"github.com/nalgeon/redka"
)
func TestDBView(t *testing.T) {
db := getDB(t)
defer db.Close()
_ = db.Str().Set("name", "alice")
_ = db.Str().Set("age", 25)
err := db.View(func(tx *redka.Tx) error {
count, err := tx.Key().Exists("name", "age")
assertNoErr(t, err)
assertEqual(t, count, 2)
name, err := tx.Str().Get("name")
assertNoErr(t, err)
assertEqual(t, name.String(), "alice")
age, err := tx.Str().Get("age")
assertNoErr(t, err)
assertEqual(t, age.MustInt(), 25)
return nil
})
assertNoErr(t, err)
}
func TestDBUpdate(t *testing.T) {
db := getDB(t)
defer db.Close()
err := db.Update(func(tx *redka.Tx) error {
err := tx.Str().Set("name", "alice")
if err != nil {
return err
}
err = tx.Str().Set("age", 25)
if err != nil {
return err
}
return nil
})
assertNoErr(t, err)
err = db.View(func(tx *redka.Tx) error {
count, _ := tx.Key().Exists("name", "age")
assertEqual(t, count, 2)
name, _ := tx.Str().Get("name")
assertEqual(t, name.String(), "alice")
age, _ := tx.Str().Get("age")
assertEqual(t, age.MustInt(), 25)
return nil
})
assertNoErr(t, err)
}
func TestDBUpdateRollback(t *testing.T) {
db := getDB(t)
defer db.Close()
_ = db.Str().Set("name", "alice")
_ = db.Str().Set("age", 25)
var errRollback = errors.New("rollback")
err := db.Update(func(tx *redka.Tx) error {
_ = tx.Str().Set("name", "bob")
_ = tx.Str().Set("age", 50)
return errRollback
})
assertEqual(t, err, errRollback)
name, _ := db.Str().Get("name")
assertEqual(t, name.String(), "alice")
age, _ := db.Str().Get("age")
assertEqual(t, age.MustInt(), 25)
}
func TestDBFlush(t *testing.T) {
db := getDB(t)
defer db.Close()
_ = db.Str().Set("name", "alice")
_ = db.Str().Set("age", 25)
err := db.Flush()
assertNoErr(t, err)
count, _ := db.Key().Exists("name", "age")
assertEqual(t, count, 0)
}

119
sql.go Normal file
View File

@@ -0,0 +1,119 @@
// SQL schema and query helpers.
package redka
import (
"database/sql"
_ "embed"
"slices"
"strings"
"time"
)
// Database schema version.
// const schemaVersion = 1
// Default SQL settings.
const sqlSettings = `
pragma journal_mode = wal;
pragma synchronous = normal;
pragma temp_store = memory;
pragma mmap_size = 268435456;
pragma foreign_keys = on;
`
//go:embed sql/schema.sql
var sqlSchema string
const sqlKeyCount = `
select count(id) from rkey
where key in (:keys) and (etime is null or etime > :now)`
const sqlKeyGet = `
select id, key, type, version, etime, mtime
from rkey
where key = ? and (etime is null or etime > ?)`
const sqlKeyDel = `
delete from rkey where key in (:keys)
and (etime is null or etime > :now)`
// rowScanner is an interface to scan rows.
type rowScanner interface {
Scan(dest ...any) error
}
// txKeyGet returns the key data structure.
func txKeyGet(tx *sql.Tx, key string) (k Key, err error) {
now := time.Now().UnixMilli()
row := tx.QueryRow(sqlKeyGet, key, now)
err = row.Scan(&k.ID, &k.Key, &k.Type, &k.Version, &k.ETime, &k.MTime)
if err == sql.ErrNoRows {
return k, nil
}
if err != nil {
return k, err
}
return k, nil
}
// txKeyDelete deletes a key and its associated values.
func txKeyDelete(tx *sql.Tx, keys ...string) (int, error) {
now := time.Now().UnixMilli()
query, keyArgs := sqlExpandIn(sqlKeyDel, ":keys", keys)
args := slices.Concat(keyArgs, []any{sql.Named("now", now)})
res, err := tx.Exec(query, args...)
if err != nil {
return 0, err
}
affectedCount, _ := res.RowsAffected()
return int(affectedCount), nil
}
// scanValue scans a key value from the row (rows).
func scanValue(scanner rowScanner) (key string, val Value, err error) {
var value []byte
err = scanner.Scan(&key, &value)
if err == sql.ErrNoRows {
return "", nil, nil
}
if err != nil {
return "", nil, err
}
return key, Value(value), nil
}
// expandIn expands the IN clause in the query for a given parameter.
func sqlExpandIn[T any](query string, param string, args []T) (string, []any) {
anyArgs := make([]any, len(args))
pholders := make([]string, len(args))
for i, arg := range args {
anyArgs[i] = arg
pholders[i] = "?"
}
query = strings.Replace(query, param, strings.Join(pholders, ","), 1)
return query, anyArgs
}
func sqlSelect[T any](db *sql.Tx, query string, args []any,
scan func(rows *sql.Rows) (T, error)) ([]T, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var vals []T
for rows.Next() {
v, err := scan(rows)
if err != nil {
return nil, err
}
vals = append(vals, v)
}
if err = rows.Err(); err != nil {
return nil, err
}
return vals, err
}

43
sql/schema.sql Normal file
View File

@@ -0,0 +1,43 @@
pragma user_version = 1;
-- keys
create table if not exists
rkey (
id integer primary key,
key text not null,
type integer not null,
version integer not null,
etime integer,
mtime integer not null
);
create unique index if not exists
rkey_key_idx on rkey (key);
create index if not exists
rkey_etime_idx on rkey (etime)
where etime is not null;
-- strings
create table if not exists
rstring (
key_id integer,
value blob not null,
foreign key (key_id) references rkey (id)
on delete cascade
);
create unique index if not exists
rstring_pk_idx on rstring (key_id);
create view if not exists
vstring as
select
rkey.id as key_id, rkey.key, rstring.value,
case rkey.type when 1 then 'string' else 'unknown' end as type,
datetime(etime/1000, 'unixepoch', 'utc') as etime,
datetime(mtime/1000, 'unixepoch', 'utc') as mtime
from rkey join rstring on rkey.id = rstring.key_id
where rkey.type = 1
and (rkey.etime is null or rkey.etime > unixepoch('subsec'));

84
sqldb.go Normal file
View File

@@ -0,0 +1,84 @@
package redka
import (
"context"
"database/sql"
"sync"
)
// sqlDB is a generic database-backed repository
// with a domain-specific transaction of type T.
type sqlDB[T any] struct {
db *sql.DB
// newT creates a new domain-specific transaction.
newT func(*sql.Tx) T
mu sync.Mutex
}
// openSQL creates a new database-backed repository.
// Creates the database schema if necessary.
func openSQL[T any](db *sql.DB, newT func(*sql.Tx) T) (*sqlDB[T], error) {
d := newSqlDB(db, newT)
err := d.init()
return d, err
}
// newSqlDB creates a new database-backed repository.
// Like openSQL, but does not create the database schema.
func newSqlDB[T any](db *sql.DB, newT func(*sql.Tx) T) *sqlDB[T] {
d := &sqlDB[T]{db: db, newT: newT}
return d
}
// Update executes a function within a writable transaction.
func (d *sqlDB[T]) Update(f func(tx T) error) error {
return d.UpdateContext(context.Background(), f)
}
// UpdateContext executes a function within a writable transaction.
func (d *sqlDB[T]) UpdateContext(ctx context.Context, f func(tx T) error) error {
return d.execTx(ctx, true, f)
}
// View executes a function within a read-only transaction.
func (d *sqlDB[T]) View(f func(tx T) error) error {
return d.ViewContext(context.Background(), f)
}
// ViewContext executes a function within a read-only transaction.
func (d *sqlDB[T]) ViewContext(ctx context.Context, f func(tx T) error) error {
return d.execTx(ctx, false, f)
}
// init creates the necessary tables.
func (d *sqlDB[T]) init() error {
if _, err := d.db.Exec(sqlSettings); err != nil {
return err
}
if _, err := d.db.Exec(sqlSchema); err != nil {
return err
}
return nil
}
// execTx executes a function within a transaction.
func (d *sqlDB[T]) execTx(ctx context.Context, writable bool, f func(tx T) error) error {
if writable {
// only one writable transaction at a time
d.mu.Lock()
defer d.mu.Unlock()
}
dtx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = dtx.Rollback() }()
tx := d.newT(dtx)
err = f(tx)
if err != nil {
return err
}
return dtx.Commit()
}

210
stringdb.go Normal file
View File

@@ -0,0 +1,210 @@
// Redis-like string repository in SQLite.
package redka
import (
"database/sql"
"time"
)
// Strings is a string repository.
type Strings interface {
Get(key string) (Value, error)
GetMany(keys ...string) ([]Value, error)
Set(key string, value any) error
SetExpires(key string, value any, ttl time.Duration) error
SetNotExists(key string, value any, ttl time.Duration) (bool, error)
SetExists(key string, value any, ttl time.Duration) (bool, error)
GetSet(key string, value any, ttl time.Duration) (Value, error)
SetMany(kvals ...KeyValue) error
SetManyNX(kvals ...KeyValue) (bool, error)
Length(key string) (int, error)
GetRange(key string, start, end int) (Value, error)
SetRange(key string, offset int, value string) (int, error)
Append(key, value string) (int, error)
Incr(key string, delta int) (int, error)
IncrFloat(key string, delta float64) (float64, error)
Delete(keys ...string) (int, error)
}
// StringDB is a database-backed string repository.
type StringDB struct {
*sqlDB[*StringTx]
}
// NewStringDB connects to the string repository.
// Does not create the database schema.
func NewStringDB(db *sql.DB) *StringDB {
d := newSqlDB(db, newStringTx)
return &StringDB{d}
}
// Get returns the value of the key.
func (d *StringDB) Get(key string) (Value, error) {
var val Value
err := d.View(func(tx *StringTx) error {
var err error
val, err = tx.Get(key)
return err
})
return val, err
}
// GetMany returns the values of multiple keys.
func (d *StringDB) GetMany(keys ...string) ([]Value, error) {
var vals []Value
err := d.View(func(tx *StringTx) error {
var err error
vals, err = tx.GetMany(keys...)
return err
})
return vals, err
}
// Set sets the key value. The key does not expire.
func (d *StringDB) Set(key string, value any) error {
err := d.Update(func(tx *StringTx) error {
return tx.Set(key, value)
})
return err
}
// SetExpires sets the key value with an optional expiration time (if ttl > 0).
func (d *StringDB) SetExpires(key string, value any, ttl time.Duration) error {
err := d.Update(func(tx *StringTx) error {
return tx.SetExpires(key, value, ttl)
})
return err
}
// SetNotExists sets the key value if the key does not exist.
// Optionally sets the expiration time (if ttl > 0).
func (d *StringDB) SetNotExists(key string, value any, ttl time.Duration) (bool, error) {
var ok bool
err := d.Update(func(tx *StringTx) error {
var err error
ok, err = tx.SetNotExists(key, value, ttl)
return err
})
return ok, err
}
// SetExists sets the key value if the key exists.
func (d *StringDB) SetExists(key string, value any, ttl time.Duration) (bool, error) {
var ok bool
err := d.Update(func(tx *StringTx) error {
var err error
ok, err = tx.SetExists(key, value, ttl)
return err
})
return ok, err
}
// GetSet returns the previous value of a key after setting it to a new value.
// Optionally sets the expiration time (if ttl > 0).
func (d *StringDB) GetSet(key string, value any, ttl time.Duration) (Value, error) {
var val Value
err := d.Update(func(tx *StringTx) error {
var err error
val, err = tx.GetSet(key, value, ttl)
return err
})
return val, err
}
// SetMany sets the values of multiple keys.
func (d *StringDB) SetMany(kvals ...KeyValue) error {
err := d.Update(func(tx *StringTx) error {
return tx.SetMany(kvals...)
})
return err
}
// SetManyNX sets the values of multiple keys,
// but only if none of them yet exist.
func (d *StringDB) SetManyNX(kvals ...KeyValue) (bool, error) {
var ok bool
err := d.Update(func(tx *StringTx) error {
var err error
ok, err = tx.SetManyNX(kvals...)
return err
})
return ok, err
}
// Length returns the length of the key value.
func (d *StringDB) Length(key string) (int, error) {
var n int
err := d.View(func(tx *StringTx) error {
var err error
n, err = tx.Length(key)
return err
})
return n, err
}
// GetRange returns the substring of the key value.
func (d *StringDB) GetRange(key string, start, end int) (Value, error) {
var val Value
err := d.View(func(tx *StringTx) error {
var err error
val, err = tx.GetRange(key, start, end)
return err
})
return val, err
}
// SetRange overwrites part of the key value.
func (d *StringDB) SetRange(key string, offset int, value string) (int, error) {
var n int
err := d.Update(func(tx *StringTx) error {
var err error
n, err = tx.SetRange(key, offset, value)
return err
})
return n, err
}
// Append appends the value to the key.
func (d *StringDB) Append(key, value string) (int, error) {
var n int
err := d.Update(func(tx *StringTx) error {
var err error
n, err = tx.Append(key, value)
return err
})
return n, err
}
// Incr increments the key value by the specified amount.
func (d *StringDB) Incr(key string, delta int) (int, error) {
var val int
err := d.Update(func(tx *StringTx) error {
var err error
val, err = tx.Incr(key, delta)
return err
})
return val, err
}
// IncrFloat increments the key value by the specified amount.
func (d *StringDB) IncrFloat(key string, delta float64) (float64, error) {
var val float64
err := d.Update(func(tx *StringTx) error {
var err error
val, err = tx.IncrFloat(key, delta)
return err
})
return val, err
}
// Delete deletes keys and their values.
// Returns the number of deleted keys. Non-existing keys are ignored.
func (d *StringDB) Delete(keys ...string) (int, error) {
var count int
err := d.Update(func(tx *StringTx) error {
var err error
count, err = tx.Delete(keys...)
return err
})
return count, err
}

571
stringdb_test.go Normal file
View File

@@ -0,0 +1,571 @@
package redka_test
import (
"testing"
"time"
"github.com/nalgeon/redka"
)
func TestStringGet(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
tests := []struct {
name string
key string
want any
}{
{"key found", "name", redka.Value("alice")},
{"key not found", "key1", redka.Value(nil)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := db.Get(tt.key)
assertNoErr(t, err)
assertEqual(t, val, tt.want)
})
}
}
func TestStringGetMany(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
_ = db.Set("age", 25)
tests := []struct {
name string
keys []string
want []redka.Value
}{
{"all found", []string{"name", "age"},
[]redka.Value{redka.Value("alice"), redka.Value("25")},
},
{"some found", []string{"name", "key1"},
[]redka.Value{redka.Value("alice"), redka.Value(nil)},
},
{"none found", []string{"key1", "key2"},
[]redka.Value{redka.Value(nil), redka.Value(nil)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vals, err := db.GetMany(tt.keys...)
assertNoErr(t, err)
assertEqual(t, vals, tt.want)
})
}
}
func TestStringSet(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
key string
value any
want any
}{
{"string", "name", "alice", redka.Value("alice")},
{"empty string", "empty", "", redka.Value("")},
{"int", "age", 25, redka.Value("25")},
{"float", "pi", 3.14, redka.Value("3.14")},
{"bool true", "ok", true, redka.Value("1")},
{"bool false", "ok", false, redka.Value("0")},
{"bytes", "bytes", []byte("hello"), redka.Value("hello")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := db.Set(tt.key, tt.value)
assertNoErr(t, err)
val, _ := db.Get(tt.key)
assertEqual(t, val, tt.want)
key, _ := red.Key().Get(tt.key)
assertEqual(t, key.ETime, (*int64)(nil))
})
}
t.Run("struct", func(t *testing.T) {
err := db.Set("struct", struct{ Name string }{"alice"})
assertErr(t, err, redka.ErrInvalidType)
})
t.Run("nil", func(t *testing.T) {
err := db.Set("nil", nil)
assertErr(t, err, redka.ErrInvalidType)
})
t.Run("update", func(t *testing.T) {
_ = db.Set("name", "alice")
err := db.Set("name", "bob")
assertNoErr(t, err)
val, _ := db.Get("name")
assertEqual(t, val, redka.Value("bob"))
})
t.Run("change type", func(t *testing.T) {
_ = db.Set("name", "alice")
err := db.Set("name", true)
assertNoErr(t, err)
val, _ := db.Get("name")
assertEqual(t, val, redka.Value("1"))
})
t.Run("not changed", func(t *testing.T) {
_ = db.Set("name", "alice")
err := db.Set("name", "alice")
assertNoErr(t, err)
val, _ := db.Get("name")
assertEqual(t, val, redka.Value("alice"))
})
}
func TestStringSetExpires(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
t.Run("no ttl", func(t *testing.T) {
err := db.SetExpires("name", "alice", 0)
assertNoErr(t, err)
val, _ := db.Get("name")
assertEqual(t, val, redka.Value("alice"))
key, _ := red.Key().Get("name")
assertEqual(t, key.ETime, (*int64)(nil))
})
t.Run("with ttl", func(t *testing.T) {
now := time.Now()
ttl := time.Second
err := db.SetExpires("name", "alice", ttl)
assertNoErr(t, err)
val, _ := db.Get("name")
assertEqual(t, val, redka.Value("alice"))
key, _ := red.Key().Get("name")
got := (*key.ETime) / 1000
want := now.Add(ttl).UnixMilli() / 1000
assertEqual(t, got, want)
})
}
func TestStringSetNotExists(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
tests := []struct {
name string
key string
value any
want bool
}{
{"new key", "age", 25, true},
{"existing key", "name", "bob", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ok, err := db.SetNotExists(tt.key, tt.value, 0)
assertNoErr(t, err)
assertEqual(t, ok, tt.want)
key, _ := red.Key().Get(tt.key)
assertEqual(t, key.ETime, (*int64)(nil))
})
}
t.Run("with ttl", func(t *testing.T) {
now := time.Now()
ttl := time.Second
ok, err := db.SetNotExists("city", "paris", ttl)
assertNoErr(t, err)
assertEqual(t, ok, true)
key, _ := red.Key().Get("city")
got := (*key.ETime) / 1000
want := now.Add(ttl).UnixMilli() / 1000
assertEqual(t, got, want)
})
}
func TestStringSetExists(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
tests := []struct {
name string
key string
value any
want bool
}{
{"new key", "age", 25, false},
{"existing key", "name", "bob", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ok, err := db.SetExists(tt.key, tt.value, 0)
assertNoErr(t, err)
assertEqual(t, ok, tt.want)
key, _ := red.Key().Get(tt.key)
assertEqual(t, key.ETime, (*int64)(nil))
})
}
t.Run("with ttl", func(t *testing.T) {
now := time.Now()
ttl := time.Second
ok, err := db.SetExists("name", "cindy", ttl)
assertNoErr(t, err)
assertEqual(t, ok, true)
key, _ := red.Key().Get("name")
got := (*key.ETime) / 1000
want := now.Add(ttl).UnixMilli() / 1000
assertEqual(t, got, want)
})
}
func TestStringGetSet(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
t.Run("create key", func(t *testing.T) {
val, err := db.GetSet("name", "alice", 0)
assertNoErr(t, err)
assertEqual(t, val, redka.Value(nil))
key, _ := red.Key().Get("name")
assertEqual(t, key.ETime, (*int64)(nil))
})
t.Run("update key", func(t *testing.T) {
_ = db.Set("name", "alice")
val, err := db.GetSet("name", "bob", 0)
assertNoErr(t, err)
assertEqual(t, val, redka.Value("alice"))
key, _ := red.Key().Get("name")
assertEqual(t, key.ETime, (*int64)(nil))
})
t.Run("not changed", func(t *testing.T) {
_ = db.Set("name", "alice")
val, err := db.GetSet("name", "alice", 0)
assertNoErr(t, err)
assertEqual(t, val, redka.Value("alice"))
key, _ := red.Key().Get("name")
assertEqual(t, key.ETime, (*int64)(nil))
})
t.Run("with ttl", func(t *testing.T) {
_ = db.Set("name", "alice")
now := time.Now()
ttl := time.Second
val, err := db.GetSet("name", "bob", ttl)
assertNoErr(t, err)
assertEqual(t, val, redka.Value("alice"))
key, _ := red.Key().Get("name")
got := (*key.ETime) / 1000
want := now.Add(ttl).UnixMilli() / 1000
assertEqual(t, got, want)
})
}
func TestStringSetMany(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
t.Run("create", func(t *testing.T) {
err := db.SetMany(
redka.KeyValue{Key: "name", Value: "alice"},
redka.KeyValue{Key: "age", Value: 25},
)
assertNoErr(t, err)
name, _ := db.Get("name")
assertEqual(t, name, redka.Value("alice"))
age, _ := db.Get("age")
assertEqual(t, age, redka.Value("25"))
})
t.Run("update", func(t *testing.T) {
_ = db.Set("name", "alice")
_ = db.Set("age", 25)
err := db.SetMany(
redka.KeyValue{Key: "name", Value: "bob"},
redka.KeyValue{Key: "age", Value: 50},
)
assertNoErr(t, err)
name, _ := db.Get("name")
assertEqual(t, name, redka.Value("bob"))
age, _ := db.Get("age")
assertEqual(t, age, redka.Value("50"))
})
t.Run("invalid type", func(t *testing.T) {
err := db.SetMany(
redka.KeyValue{Key: "name", Value: "alice"},
redka.KeyValue{Key: "age", Value: struct{ Name string }{"alice"}},
)
assertErr(t, err, redka.ErrInvalidType)
})
}
func TestStringSetManyNX(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
t.Run("create", func(t *testing.T) {
ok, err := db.SetManyNX(
redka.KeyValue{Key: "age", Value: 25},
redka.KeyValue{Key: "city", Value: "wonderland"},
)
assertNoErr(t, err)
assertEqual(t, ok, true)
age, _ := db.Get("age")
assertEqual(t, age, redka.Value("25"))
city, _ := db.Get("city")
assertEqual(t, city, redka.Value("wonderland"))
})
t.Run("update", func(t *testing.T) {
_ = db.Set("age", 25)
_ = db.Set("city", "wonderland")
ok, err := db.SetManyNX(
redka.KeyValue{Key: "age", Value: 50},
redka.KeyValue{Key: "city", Value: "wonderland"},
)
assertNoErr(t, err)
assertEqual(t, ok, false)
age, _ := db.Get("age")
assertEqual(t, age, redka.Value("25"))
city, _ := db.Get("city")
assertEqual(t, city, redka.Value("wonderland"))
})
t.Run("invalid type", func(t *testing.T) {
ok, err := db.SetManyNX(
redka.KeyValue{Key: "name", Value: "alice"},
redka.KeyValue{Key: "age", Value: struct{ Name string }{"alice"}},
)
assertErr(t, err, redka.ErrInvalidType)
assertEqual(t, ok, false)
})
}
func TestStringLength(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name1", "alice")
_ = db.Set("name2", "bobby tables")
_ = db.Set("name3", "")
_ = db.Set("age", 25)
tests := []struct {
name string
key string
want int
}{
{"length1", "name1", 5},
{"length2", "name2", 12},
{"empty", "name3", 0},
{"not found", "other", 0},
{"int", "age", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n, err := db.Length(tt.key)
assertNoErr(t, err)
assertEqual(t, n, tt.want)
})
}
}
func TestStringGetRange(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
_ = db.Set("name", "alice")
tests := []struct {
name string
key string
start int
end int
want string
}{
{"all", "name", 0, -1, "alice"},
{"partial", "name", 0, 2, "ali"},
{"empty", "name", 10, 20, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := db.GetRange(tt.key, tt.start, tt.end)
assertNoErr(t, err)
assertEqual(t, val.String(), tt.want)
})
}
}
func TestStringSetRange(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
key string
start int
value string
want []byte
}{
{"create", "city", 0, "paris", []byte("paris")},
{"replace", "name", 1, "xxx", []byte("axxxe")},
{"append", "name", 5, " and charlie", []byte("alice and charlie")},
{"empty", "name", 8, "x", []byte{'a', 'l', 'i', 'c', 'e', 0, 0, 0, 'x'}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = db.Set("name", "alice")
n, err := db.SetRange(tt.key, tt.start, tt.value)
assertNoErr(t, err)
assertEqual(t, n, len(tt.want))
val, _ := db.Get(tt.key)
assertEqual(t, val.Bytes(), tt.want)
})
}
}
func TestStringAppend(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
key string
value string
want []byte
}{
{"create", "city", "paris", []byte("paris")},
{"append", "name", " and charlie", []byte("alice and charlie")},
{"empty", "name", "", []byte("alice")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = db.Set("name", "alice")
n, err := db.Append(tt.key, tt.value)
assertNoErr(t, err)
assertEqual(t, n, len(tt.want))
val, _ := db.Get(tt.key)
assertEqual(t, val, redka.Value(tt.want))
})
}
}
func TestStringIncr(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
key string
value int
want int
}{
{"create", "age", 10, 10},
{"increment", "age", 15, 25},
{"decrement", "age", -5, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := db.Incr(tt.key, tt.value)
assertNoErr(t, err)
assertEqual(t, val, tt.want)
})
}
t.Run("invalid int", func(t *testing.T) {
_ = db.Set("name", "alice")
val, err := db.Incr("name", 1)
assertErr(t, err, redka.ErrInvalidInt)
assertEqual(t, val, 0)
})
}
func TestStringIncrFloat(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
key string
value float64
want float64
}{
{"create", "pi", 3.14, 3.14},
{"increment", "pi", 1.86, 5},
{"decrement", "pi", -1.5, 3.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
val, err := db.IncrFloat(tt.key, tt.value)
assertNoErr(t, err)
assertEqual(t, val, tt.want)
})
}
t.Run("invalid float", func(t *testing.T) {
_ = db.Set("name", "alice")
val, err := db.IncrFloat("name", 1.5)
assertErr(t, err, redka.ErrInvalidFloat)
assertEqual(t, val, 0.0)
})
}
func TestStringDelete(t *testing.T) {
red := getDB(t)
defer red.Close()
db := red.Str()
tests := []struct {
name string
keys []string
want int
}{
{"delete one", []string{"name"}, 1},
{"delete some", []string{"name", "city"}, 1},
{"delete many", []string{"name", "age"}, 2},
{"delete none", []string{"city"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_ = db.Set("name", "alice")
_ = db.Set("age", 25)
count, err := db.Delete(tt.keys...)
assertNoErr(t, err)
assertEqual(t, count, tt.want)
for _, key := range tt.keys {
val, _ := db.Get(key)
assertEqual(t, val.IsEmpty(), true)
}
})
}
}

407
stringtx.go Normal file
View File

@@ -0,0 +1,407 @@
// Redis-like string repository in SQLite.
package redka
import (
"database/sql"
"slices"
"time"
)
const sqlStringGet = `
select key, value
from rstring
join rkey on key_id = rkey.id
where key = ? and (etime is null or etime > ?);
`
const sqlStringLen = `
select length(value)
from rstring
where key_id = (
select id from rkey
where key = ? and (etime is null or etime > ?)
);
`
const sqlStringGetMany = `
select key, value
from rstring
join rkey on key_id = rkey.id
where key in (:keys) and (etime is null or etime > :now);
`
var sqlStringSet = []string{
`insert into rkey (key, type, version, etime, mtime)
values (:key, :type, :version, :etime, :mtime)
on conflict (key) do update set
version = version+1,
etime = excluded.etime,
mtime = excluded.mtime
;`,
`insert into rstring (key_id, value)
values ((select id from rkey where key = :key), :value)
on conflict (key_id) do update
set value = excluded.value;`,
}
var sqlStringUpdate = []string{
`insert into rkey (key, type, version, etime, mtime)
values (:key, :type, :version, null, :mtime)
on conflict (key) do update set
version = version+1,
-- not changing etime
mtime = excluded.mtime
;`,
`insert into rstring (key_id, value)
values ((select id from rkey where key = :key), :value)
on conflict (key_id) do update
set value = excluded.value;`,
}
// StringTx is a string repository transaction.
type StringTx struct {
tx *sql.Tx
}
// newStringTx creates a string repository transaction
// from a generic database transaction.
func newStringTx(tx *sql.Tx) *StringTx {
return &StringTx{tx}
}
// Get returns the value of the key.
func (tx *StringTx) Get(key string) (Value, error) {
now := time.Now().UnixMilli()
row := tx.tx.QueryRow(sqlStringGet, key, now)
_, val, err := scanValue(row)
return val, err
}
// GetMany returns the values of multiple keys.
func (tx *StringTx) GetMany(keys ...string) ([]Value, error) {
now := time.Now().UnixMilli()
query, keyArgs := sqlExpandIn(sqlStringGetMany, ":keys", keys)
args := slices.Concat(keyArgs, []any{sql.Named("now", now)})
var rows *sql.Rows
rows, err := tx.tx.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Build a map of known keys.
// It will be used to fill in the missing keys.
known := make(map[string]Value, len(keys))
for rows.Next() {
key, val, err := scanValue(rows)
if err != nil {
return nil, err
}
known[key] = val
}
if rows.Err() != nil {
return nil, rows.Err()
}
// Build the result slice.
// It will contain all values in the order of keys.
// Missing keys will have nil values.
vals := make([]Value, 0, len(keys))
for _, key := range keys {
vals = append(vals, known[key])
}
return vals, nil
}
// Set sets the key value. The key does not expire.
func (tx *StringTx) Set(key string, value any) error {
return tx.SetExpires(key, value, 0)
}
// SetExpires sets the key value with an optional expiration time (if ttl > 0).
func (tx *StringTx) SetExpires(key string, value any, ttl time.Duration) error {
if !isValueType(value) {
return ErrInvalidType
}
err := tx.set(key, value, ttl)
return err
}
// SetNotExists sets the key value if the key does not exist.
// Optionally sets the expiration time (if ttl > 0).
func (tx *StringTx) SetNotExists(key string, value any, ttl time.Duration) (bool, error) {
if !isValueType(value) {
return false, ErrInvalidType
}
k, err := txKeyGet(tx.tx, key)
if err != nil {
return false, err
}
if k.Exists() {
return false, nil
}
err = tx.set(key, value, ttl)
return err == nil, err
}
// SetExists sets the key value if the key exists.
// Optionally sets the expiration time (if ttl > 0).
func (tx *StringTx) SetExists(key string, value any, ttl time.Duration) (bool, error) {
if !isValueType(value) {
return false, ErrInvalidType
}
k, err := txKeyGet(tx.tx, key)
if err != nil {
return false, err
}
if !k.Exists() {
return false, nil
}
err = tx.set(key, value, ttl)
return err == nil, err
}
// GetSet returns the previous value of a key after setting it to a new value.
// Optionally sets the expiration time (if ttl > 0).
func (tx *StringTx) GetSet(key string, value any, ttl time.Duration) (Value, error) {
if !isValueType(value) {
return nil, ErrInvalidType
}
prev, err := tx.Get(key)
if err != nil {
return nil, err
}
err = tx.set(key, value, ttl)
return prev, err
}
// SetMany sets the values of multiple keys.
func (tx *StringTx) SetMany(kvals ...KeyValue) error {
for _, kv := range kvals {
if !isValueType(kv.Value) {
return ErrInvalidType
}
}
for _, kv := range kvals {
err := tx.set(kv.Key, kv.Value, 0)
if err != nil {
return err
}
}
return nil
}
// SetManyNX sets the values of multiple keys,
// but only if none of them exist yet.
func (tx *StringTx) SetManyNX(kvals ...KeyValue) (bool, error) {
for _, kv := range kvals {
if !isValueType(kv.Value) {
return false, ErrInvalidType
}
}
// extract keys
keys := make([]string, 0, len(kvals))
for _, kv := range kvals {
keys = append(keys, kv.Key)
}
// check if any of the keys exist
count := 0
now := time.Now().UnixMilli()
query, keyArgs := sqlExpandIn(sqlKeyCount, ":keys", keys)
args := slices.Concat(keyArgs, []any{sql.Named("now", now)})
err := tx.tx.QueryRow(query, args...).Scan(&count)
if err != nil {
return false, err
}
// do not proceed if any of the keys exist
if count != 0 {
return false, nil
}
// set the keys
for _, kv := range kvals {
err = tx.set(kv.Key, kv.Value, 0)
if err != nil {
return false, err
}
}
return true, nil
}
// Length returns the length of the key value.
func (tx *StringTx) Length(key string) (int, error) {
now := time.Now().UnixMilli()
var n int
// err := tx.tx.Get(&n, sqlStringLen, key, now)
err := tx.tx.QueryRow(sqlStringLen, key, now).Scan(&n)
if err == sql.ErrNoRows {
return 0, nil
}
return n, err
}
// GetRange returns the substring of the key value.
func (tx *StringTx) GetRange(key string, start, end int) (Value, error) {
val, err := tx.Get(key)
if err != nil {
return nil, err
}
if val.IsEmpty() {
// return empty value if the key does not exist
return val, nil
}
s := val.String()
start, end = rangeToSlice(len(s), start, end)
return Value(s[start:end]), nil
}
// SetRange overwrites part of the key value.
func (tx *StringTx) SetRange(key string, offset int, value string) (int, error) {
val, err := tx.Get(key)
if err != nil {
return 0, err
}
newVal := setRange(val.String(), offset, value)
err = tx.update(key, newVal)
if err != nil {
return 0, err
}
return len(newVal), nil
}
// Append appends the value to the key.
func (tx *StringTx) Append(key, value string) (int, error) {
val, err := tx.Get(key)
if err != nil {
return 0, err
}
newVal := val.String() + value
err = tx.update(key, newVal)
if err != nil {
return 0, err
}
return len(newVal), nil
}
// Incr increments the key value by the specified amount.
func (tx *StringTx) Incr(key string, delta int) (int, error) {
// get the current value
val, err := tx.Get(key)
if err != nil {
return 0, err
}
// check if the value is a valid integer
isFound := !val.IsEmpty()
valInt, err := val.Int()
if isFound && err != nil {
return 0, ErrInvalidInt
}
// increment the value
newVal := valInt + delta
err = tx.update(key, newVal)
if err != nil {
return 0, err
}
return newVal, nil
}
// IncrFloat increments the key value by the specified amount.
func (tx *StringTx) IncrFloat(key string, delta float64) (float64, error) {
// get the current value
val, err := tx.Get(key)
if err != nil {
return 0, err
}
// check if the value is a valid float
isFound := !val.IsEmpty()
valFloat, err := val.Float()
if isFound && err != nil {
return 0, ErrInvalidFloat
}
// increment the value
newVal := valFloat + delta
err = tx.update(key, newVal)
if err != nil {
return 0, err
}
return newVal, nil
}
// Delete deletes keys and their values.
// Returns the number of deleted keys. Non-existing keys are ignored.
func (tx *StringTx) Delete(keys ...string) (int, error) {
return txKeyDelete(tx.tx, keys...)
}
// set sets the key value and (optionally) its expiration time.
func (tx StringTx) set(key string, value any, ttl time.Duration) error {
now := time.Now()
var etime *int64
if ttl > 0 {
etime = new(int64)
*etime = now.Add(ttl).UnixMilli()
}
args := []any{
sql.Named("key", key),
sql.Named("type", typeString),
sql.Named("version", initialVersion),
sql.Named("value", value),
sql.Named("etime", etime),
sql.Named("mtime", now.UnixMilli()),
}
_, err := tx.tx.Exec(sqlStringSet[0], args...)
if err != nil {
return err
}
_, err = tx.tx.Exec(sqlStringSet[1], args...)
return err
}
// update updates the value of the existing key without changing its
// expiration time. If the key does not exist, creates a new key with
// the specified value and no expiration time.
func (tx StringTx) update(key string, value any) error {
now := time.Now().UnixMilli()
args := []any{
sql.Named("key", key),
sql.Named("type", typeString),
sql.Named("version", initialVersion),
sql.Named("value", value),
sql.Named("mtime", now),
}
_, err := tx.tx.Exec(sqlStringUpdate[0], args...)
if err != nil {
return err
}
_, err = tx.tx.Exec(sqlStringUpdate[1], args...)
return err
}

44
test_test.go Normal file
View File

@@ -0,0 +1,44 @@
package redka_test
import (
"reflect"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/nalgeon/redka"
)
func getDB(tb testing.TB) *redka.DB {
tb.Helper()
db, err := redka.Open(":memory:")
if err != nil {
tb.Fatal(err)
}
return db
}
func assertEqual(tb testing.TB, got, want any) {
tb.Helper()
if !reflect.DeepEqual(got, want) {
tb.Errorf("want %#v, got %#v", want, got)
}
}
func assertErr(tb testing.TB, got, want error) {
tb.Helper()
if got == nil {
tb.Errorf("want %T (%v) error, got nil", want, want)
return
}
if got != want {
tb.Errorf("want %T (%v) error, got %T (%v)", want, want, got, got)
return
}
}
func assertNoErr(tb testing.TB, got error) {
tb.Helper()
if got != nil {
tb.Errorf("unexpected error %T (%v)", got, got)
}
}

125
types.go Normal file
View File

@@ -0,0 +1,125 @@
// Common types and functions.
package redka
import (
"errors"
"strconv"
)
// type identifiers
type typeID int
const (
typeString = typeID(1)
typeList = typeID(2)
typeSet = typeID(3)
typeHash = typeID(4)
typeSortedSet = typeID(5)
)
// initial version of the key
const initialVersion = 1
// ErrInvalidInt is when the value is not a valid integer.
var ErrInvalidInt = errors.New("invalid int")
// ErrInvalidFloat is when the value is not a valid float.
var ErrInvalidFloat = errors.New("invalid float")
// ErrKeyNotFound is when the key is not found.
var ErrKeyNotFound = errors.New("key not found")
// ErrInvalidType is when the value does not have a valid type.
var ErrInvalidType = errors.New("invalid type")
// KeyValue represents a key-value pair.
type KeyValue struct {
Key string
Value any
}
// Key represents a key data structure.
type Key struct {
ID int
Key string
Type typeID
Version int
ETime *int64
MTime int64
}
// Exists returns true if the key exists.
func (k Key) Exists() bool {
return k.Key != ""
}
// TypeName returns the name of the key type.
func (k Key) TypeName() string {
switch k.Type {
case typeString:
return "string"
case typeList:
return "list"
case typeSet:
return "set"
case typeHash:
return "hash"
case typeSortedSet:
return "zset"
}
return "unknown"
}
// Value represents a key value (a byte slice).
// It can be converted to other scalar types.
type Value []byte
func (v Value) String() string {
return string(v)
}
func (v Value) Bytes() []byte {
return []byte(v)
}
func (v Value) Bool() (bool, error) {
return strconv.ParseBool(string(v))
}
func (v Value) MustBool() bool {
b, err := v.Bool()
if err != nil {
panic(err)
}
return b
}
func (v Value) Int() (int, error) {
return strconv.Atoi(string(v))
}
func (v Value) MustInt() int {
i, err := v.Int()
if err != nil {
panic(err)
}
return i
}
func (v Value) Float() (float64, error) {
return strconv.ParseFloat(string(v), 64)
}
func (v Value) MustFloat() float64 {
f, err := v.Float()
if err != nil {
panic(err)
}
return f
}
func (v Value) IsEmpty() bool {
return len(v) == 0
}
// isValueType returns true if the value has a valid type
// to be persisted in the database.
func isValueType(v any) bool {
switch v.(type) {
case string, int, float64, bool, []byte:
return true
}
return false
}