Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.22', '1.23' ]

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
go-version: '1.25'

- name: Run tests
run: make test
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: test integration lint build clean

test:
go test -v -race -cover ./...
go test -race -cover ./...

DS9_TEST_PROJECT ?= integration-testing-476513
DS9_TEST_DATABASE ?= ds9-test
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# ds9

Zero-dependency Google Cloud Datastore client for Go. Drop-in replacement for `cloud.google.com/go/datastore` basic operations. In-memory mock implementation. Comprehensive testing.
<img src="media/logo-small.png" alt="ds9 logo" align="right">

**Why?** The official client has 50+ dependencies. `ds9` uses only Go stdlib—ideal for lightweight services and minimizing supply chain risk.
[![Go Reference](https://pkg.go.dev/badge/github.com/codeGROOVE-dev/ds9.svg)](https://pkg.go.dev/github.com/codeGROOVE-dev/ds9)
[![Go Report Card](https://goreportcard.com/badge/github.com/codeGROOVE-dev/ds9)](https://goreportcard.com/report/github.com/codeGROOVE-dev/ds9)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/codeGROOVE-dev/ds9)](go.mod)

Zero-dependency Google Cloud Datastore client for Go. Drop-in replacement for `cloud.google.com/go/datastore` with CRUD, transactions, queries, cursors, and mutations. In-memory mock implementation. Comprehensive testing.

**Why?** The official client has 50+ dependencies. `ds9` has zero—ideal for lightweight services and minimizing supply chain risk.

## Installation

Expand Down Expand Up @@ -36,6 +43,7 @@ Just switch the import path from `cloud.google.com/go/datastore` to `github.com/
- **Cursors**: Start, End, DecodeCursor
- **Keys**: NameKey, IDKey, IncompleteKey, AllocateIDs, parent keys
- **Mutations**: NewInsert, NewUpdate, NewUpsert, NewDelete, Mutate
- **Errors**: ErrNoSuchEntity, ErrInvalidKey, ErrInvalidEntityType, ErrConcurrentTransaction, Done, MultiError
- **Types**: string, int, int64, int32, bool, float64, time.Time, slices ([]string, []int64, []int, []float64, []bool)

**Unsupported Features**
Expand All @@ -46,6 +54,6 @@ These features are unsupported just because we haven't found a use for the featu

## Testing

* Use `github.com/codeGROOVE-dev/ds9/pkg/mock` package for in-memory testing. It should work even if you choose not to use ds9.
* Use `datastore.NewMockClient(t)` for in-memory testing. Works even if you're still using the official client.
* See [TESTING.md](TESTING.md) for integration tests.
* We aim to maintain 85% test coverage - please don't send PRs without tests.
15 changes: 13 additions & 2 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,19 @@ func TestIntegrationBatchOperations(t *testing.T) {

var retrieved []integrationEntity
err = client.GetMulti(ctx, keys, &retrieved)
if !errors.Is(err, datastore.ErrNoSuchEntity) {
t.Errorf("expected datastore.ErrNoSuchEntity after DeleteMulti, got %v", err)
if err == nil {
t.Fatal("expected error after DeleteMulti, got nil")
}

var multiErr datastore.MultiError
if !errors.As(err, &multiErr) {
t.Fatalf("expected MultiError after DeleteMulti, got %T: %v", err, err)
}

for i, e := range multiErr {
if !errors.Is(e, datastore.ErrNoSuchEntity) {
t.Errorf("expected ErrNoSuchEntity at index %d, got %v", i, e)
}
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/datastore/cursor_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func TestCursorWithLimitedResults(t *testing.T) {
for {
var entity testEntity
_, err := it.Next(&entity)
if errors.Is(err, datastore.ErrDone) {
if errors.Is(err, datastore.Done) {
break
}
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion pkg/datastore/entity_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func TestIterator_Coverage(t *testing.T) {
Time time.Time
}
_, err := it.Next(&entity)
if errors.Is(err, ErrDone) {
if errors.Is(err, Done) {
break
}
if err != nil {
Expand Down
50 changes: 46 additions & 4 deletions pkg/datastore/errors.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
package datastore

import "errors"
import (
"errors"
"fmt"
)

var (
// ErrNoSuchEntity is returned when an entity is not found.
// ErrInvalidEntityType is returned when functions like Get or Next are
// passed a dst or src argument of invalid type.
ErrInvalidEntityType = errors.New("datastore: invalid entity type")

// ErrInvalidKey is returned when an invalid key is presented.
ErrInvalidKey = errors.New("datastore: invalid key")

// ErrNoSuchEntity is returned when no entity was found for a given key.
ErrNoSuchEntity = errors.New("datastore: no such entity")

// ErrDone is returned by Iterator.Next when no more results are available.
ErrDone = errors.New("datastore: no more results")
// ErrConcurrentTransaction is returned when a transaction is used concurrently.
ErrConcurrentTransaction = errors.New("datastore: concurrent transaction")

// Done is returned by Iterator.Next when no more results are available.
// This matches the official cloud.google.com/go/datastore API.
//
//nolint:revive,errname,staticcheck // Name must match official API (iterator.Done)
Done = errors.New("datastore: no more items in iterator")
)

// MultiError is returned by batch operations when there are errors with
// particular elements. Errors will be in a one-to-one correspondence with
// the input elements; successful elements will have a nil entry.
type MultiError []error

func (m MultiError) Error() string {
s, n := "", 0
for _, e := range m {
if e != nil {
if n == 0 {
s = e.Error()
}
n++
}
}
switch n {
case 0:
return "(0 errors)"
case 1:
return s
case 2:
return s + " (and 1 other error)"
}
return fmt.Sprintf("%s (and %d other errors)", s, n-1)
}
4 changes: 2 additions & 2 deletions pkg/datastore/iterator.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (it *Iterator) Next(dst any) (*Key, error) {
return nil, it.err
}
if !it.fetchNext {
return nil, ErrDone
return nil, Done
}

// Fetch next batch
Expand All @@ -51,7 +51,7 @@ func (it *Iterator) Next(dst any) (*Key, error) {
}

if len(it.results) == 0 {
return nil, ErrDone
return nil, Done
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/datastore/iterator_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func TestIteratorMultipleFetches(t *testing.T) {
Time time.Time
}
_, err := it.Next(&entity)
if errors.Is(err, ErrDone) {
if errors.Is(err, Done) {
break
}
if err != nil {
Expand Down Expand Up @@ -209,7 +209,7 @@ func TestIteratorKeysOnly(t *testing.T) {
Data string
}
key, err := it.Next(&entity)
if errors.Is(err, ErrDone) {
if errors.Is(err, Done) {
break
}
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions pkg/datastore/iterator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestIterator(t *testing.T) {
for {
var entity testEntity
key, err := it.Next(&entity)
if errors.Is(err, datastore.ErrDone) {
if errors.Is(err, datastore.Done) {
break
}
if err != nil {
Expand Down Expand Up @@ -89,8 +89,8 @@ func TestIterator(t *testing.T) {

var entity testEntity
_, err := it.Next(&entity)
if !errors.Is(err, datastore.ErrDone) {
t.Errorf("Expected datastore.ErrDone, got %v", err)
if !errors.Is(err, datastore.Done) {
t.Errorf("Expected datastore.Done, got %v", err)
}
})
}
Loading
Loading