From d10e3dcd4a7f338bb81ddc2805005166a4f570d7 Mon Sep 17 00:00:00 2001 From: jolheiser Date: Mon, 14 Nov 2022 23:29:40 -0600 Subject: [PATCH] initial commit Signed-off-by: jolheiser --- LICENSE | 19 ++++++++++ README.md | 7 ++++ bucket.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 28 ++++++++++++++ go.sum | 64 +++++++++++++++++++++++++++++++ sqkv.go | 62 ++++++++++++++++++++++++++++++ sqkv_test.go | 67 +++++++++++++++++++++++++++++++++ 7 files changed, 351 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bucket.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 sqkv.go create mode 100644 sqkv_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec2045e --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 John Olheiser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b50a82d --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# SQKV + +SQLite3 K/V database + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/bucket.go b/bucket.go new file mode 100644 index 0000000..3111c2a --- /dev/null +++ b/bucket.go @@ -0,0 +1,104 @@ +package sqkv + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +const DefaultBucket = "SQKV" + +var ( + ErrBucketNameEmpty = errors.New("bucket name cannot be empty") + ErrBucketDoesNotExist = errors.New("bucket does not exist") + ErrBucketExists = errors.New("bucket already exists") + ErrKeyDoesNotExist = errors.New("key does not exist in bucket") +) + +// Bucket is a KV bucket +type Bucket struct { + db *sql.DB + table string +} + +// Bucket gets a bucket by name, or returns nil if it doesn't exist +func (d *DB) Bucket(name string) (*Bucket, error) { + name = escapeIdent(name) + if !d.bucketExists(name) { + return nil, ErrBucketDoesNotExist + } + return &Bucket{ + db: d.db, + table: name, + }, nil +} + +// CreateBucket creates a new bucket and returns it. Returns an error if the bucket already exists or name is empty. +func (d *DB) CreateBucket(name string) (*Bucket, error) { + name = escapeIdent(name) + if strings.TrimSpace(name) == "" { + return nil, ErrBucketNameEmpty + } + if ok := d.bucketExists(name); ok { + return nil, ErrBucketExists + } + if _, err := d.db.Exec(fmt.Sprintf(`CREATE TABLE %s ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + KEY TEXT UNIQUE, + VALUE TEXT +)`, name)); err != nil { + return nil, err + } + return &Bucket{ + db: d.db, + table: name, + }, nil +} + +// CreateBucketIfNotExist creates a new bucket (if it does not exist) and returns it. Returns an error if name is empty. +func (d *DB) CreateBucketIfNotExist(name string) (*Bucket, error) { + name = escapeIdent(name) + b, err := d.CreateBucket(name) + if err != nil { + if errors.Is(err, ErrBucketExists) { + return d.Bucket(name) + } + return nil, err + } + return b, nil +} + +func (d *DB) bucketExists(name string) bool { + res, err := d.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?;", name) + if err != nil { + return false + } + defer res.Close() + return res.Next() +} + +// Get returns a value from the bucket +func (b *Bucket) Get(key string) (string, error) { + row := b.db.QueryRow(fmt.Sprintf("SELECT value FROM %s WHERE key = ?", b.table), key) + var value string + if err := row.Scan(&value); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrKeyDoesNotExist + } + return "", err + } + return value, row.Err() +} + +// Put sets a value in the bucket +func (b *Bucket) Put(key, value string) error { + _, err := b.db.Exec(fmt.Sprintf("INSERT INTO %s ( key, value ) VALUES( ?, ? ) ON CONFLICT(key) DO UPDATE SET value=excluded.value", b.table), key, value) + return err +} + +// Delete removes a value from the bucket +func (b *Bucket) Delete(key string) error { + _, err := b.db.Exec(fmt.Sprintf("DELETE FROM %s WHERE key = ?", b.table), key) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7171df3 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module go.jolheiser.com/sqkv + +go 1.19 + +require ( + github.com/matryer/is v1.4.0 + modernc.org/sqlite v1.19.4 +) + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + golang.org/x/mod v0.3.0 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect + golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/ccgo/v3 v3.16.13 // indirect + modernc.org/libc v1.21.4 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.4.0 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c3aacdf --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +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.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/libc v1.21.4 h1:CzTlumWeIbPV5/HVIMzYHNPCRP8uiU/CWiN2gtd/Qu8= +modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.19.4 h1:nlPIDqumn6/mSvs7T5C8MNYEuN73sISzPdKtMdURpUI= +modernc.org/sqlite v1.19.4/go.mod h1:x/yZNb3h5+I3zGQSlwIv4REL5eJhiRkUH5MReogAeIc= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34= +modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= diff --git a/sqkv.go b/sqkv.go new file mode 100644 index 0000000..fb7e7c4 --- /dev/null +++ b/sqkv.go @@ -0,0 +1,62 @@ +package sqkv + +import ( + "database/sql" + "fmt" + "regexp" + + _ "modernc.org/sqlite" +) + +// DB is an SQKV database +type DB struct { + db *sql.DB +} + +// Open opens (or creates) a new SQKV database +func Open(path string) (*DB, error) { + dsn := fmt.Sprintf("file:%s?cache=shared&mode=rwc", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + + sqkv := &DB{ + db: db, + } + _, err = sqkv.CreateBucketIfNotExist(DefaultBucket) + return sqkv, err +} + +// Get returns a value from the default bucket +func (d *DB) Get(key string) (string, error) { + b, err := d.Bucket(DefaultBucket) + if err != nil { + return "", nil + } + return b.Get(key) +} + +// Put sets a value in the default bucket +func (d *DB) Put(key, value string) error { + b, err := d.Bucket(DefaultBucket) + if err != nil { + return nil + } + return b.Put(key, value) +} + +// Delete removes a value from the default bucket +func (d *DB) Delete(key string) error { + b, err := d.Bucket(DefaultBucket) + if err != nil { + return nil + } + return b.Delete(key) +} + +var escapeIdentRe = regexp.MustCompile(`[^A-Za-z]`) + +func escapeIdent(in string) string { + return escapeIdentRe.ReplaceAllString(in, "_") +} diff --git a/sqkv_test.go b/sqkv_test.go new file mode 100644 index 0000000..09a597e --- /dev/null +++ b/sqkv_test.go @@ -0,0 +1,67 @@ +package sqkv + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/matryer/is" +) + +func TestDefault(t *testing.T) { + assert := is.New(t) + + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "sqkv.db") + + db, err := Open(dbPath) + assert.NoErr(err) // Should create database + + err = db.Put("foo", "bar") + assert.NoErr(err) // Should put kv + + val, err := db.Get("foo") + assert.NoErr(err) // Should get kv + assert.Equal(val, "bar") // key(foo) == value(bar) + + err = db.Delete("foo") + assert.NoErr(err) // Should delete kv + + _, err = db.Get("foo") + assert.True(errors.Is(err, ErrKeyDoesNotExist)) +} + +func TestBucket(t *testing.T) { + assert := is.New(t) + + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "sqkv.db") + + db, err := Open(dbPath) + assert.NoErr(err) // Should create database + + _, err = db.Bucket("honk") + assert.True(errors.Is(err, ErrBucketDoesNotExist)) // Bucket does not exist; should error + + _, err = db.CreateBucket("honk") + assert.NoErr(err) // Should create bucket + + _, err = db.Bucket("honk") + assert.NoErr(err) // Bucket should exist + + b, err := db.CreateBucketIfNotExist("honk") + assert.NoErr(err) // Bucket should exist and be returned + + err = b.Put("foo", "bar") + assert.NoErr(err) // Should put kv + + val, err := b.Get("foo") + assert.NoErr(err) // Should get kv + assert.Equal(val, "bar") // key(foo) == value(bar) + + err = b.Delete("foo") + assert.NoErr(err) // Should delete kv + + _, err = b.Get("foo") + assert.True(errors.Is(err, ErrKeyDoesNotExist)) +}