mirror of
https://github.com/nalgeon/redka.git
synced 2025-09-26 20:11:14 +08:00
initial commit
This commit is contained in:
42
.github/workflows/build.yml
vendored
Normal file
42
.github/workflows/build.yml
vendored
Normal 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
36
.github/workflows/publish.yml
vendored
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build/
|
14
.goreleaser.yaml
Normal file
14
.goreleaser.yaml
Normal 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
27
LICENSE
Normal 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
25
Makefile
Normal 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
423
README.md
Normal 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
101
cmd/redka/main.go
Normal 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
27
example/go.mod
Normal 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
41
example/go.sum
Normal 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
30
example/modernc/main.go
Normal 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
34
example/simple/main.go
Normal 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
65
example/tx/main.go
Normal 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
13
go.mod
Normal 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
9
go.sum
Normal 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
92
internal/command/a.go
Normal 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
125
internal/command/a_test.go
Normal 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, ",")
|
||||
}
|
62
internal/command/config.go
Normal file
62
internal/command/config.go
Normal 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
34
internal/command/echo.go
Normal 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
|
||||
}
|
78
internal/command/echo_test.go
Normal file
78
internal/command/echo_test.go
Normal 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
38
internal/command/get.go
Normal 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
|
||||
}
|
85
internal/command/get_test.go
Normal file
85
internal/command/get_test.go
Normal 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
20
internal/command/ok.go
Normal 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
112
internal/command/set.go
Normal 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
|
||||
}
|
160
internal/command/set_test.go
Normal file
160
internal/command/set_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
22
internal/command/unknown.go
Normal file
22
internal/command/unknown.go
Normal 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
116
internal/server/handlers.go
Normal 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
|
||||
}
|
||||
|
||||
}
|
110
internal/server/handlers_test.go
Normal file
110
internal/server/handlers_test.go
Normal 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
71
internal/server/server.go
Normal 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
49
internal/server/state.go
Normal 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
185
keydb.go
Normal 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
58
keydb_internal_test.go
Normal 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
357
keydb_test.go
Normal 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
361
keytx.go
Normal 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
55
redis.go
Normal 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
169
redka.go
Normal 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
101
redka_test.go
Normal 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
119
sql.go
Normal 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
43
sql/schema.sql
Normal 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
84
sqldb.go
Normal 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
210
stringdb.go
Normal 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
571
stringdb_test.go
Normal 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
407
stringtx.go
Normal 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
44
test_test.go
Normal 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
125
types.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user