Metadata-Version: 2.4
Name: jsondb-client
Version: 0.1.0
Summary: Client for jsondb — a MongoDB-lite JSON document store on SQLite
Project-URL: Documentation, https://pmuston.github.io/jsondb
Project-URL: Homepage, https://github.com/pmuston/jsondb-client
Project-URL: Source, https://github.com/pmuston/jsondb-client
Author: Paul Muston
License-Expression: MIT
License-File: LICENSE
Keywords: database,document-store,json,jsondb,mongodb,sqlite
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Database :: Front-Ends
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: test
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# jsondb-client

A Python client for [**jsondb**](https://pmuston.github.io/jsondb) — a
MongoDB-lite JSON document store on SQLite.

Standard library only. No `requests`, no `httpx`, nothing transitive: it drops
into any environment that has Python.

```sh
pip install jsondb-client
```

```python
import jsondb_client

with jsondb_client.connect("jsondb://localhost:8080") as db:
    for user in db["users"].find({"tier": "gold"}).sort("-age").limit(10):
        print(user["name"], user["age"])
```

## The import name

The package installs as `jsondb-client` and imports as **`jsondb_client`** —
not `jsondb`, which on PyPI belongs to an unrelated project. Sharing the import
name would mean whichever was installed last silently shadowed the other, and
the failure would look like a bug in this one. If you want the short name:

```python
import jsondb_client as jsondb
```

## Status

**Complete** — all 21 collection methods, matching the
[Go driver](https://github.com/pmuston/jsondb-go)'s coverage of everything the
server exposes. Reads, writes, indexes, statistics, rename, drop and query
plans.

## Reading

```python
db = jsondb_client.connect("jsondb://localhost:8080")
users = db["users"]                       # two levels, not three

users.count_documents({"tier": "gold"})   # 231
users.find_one({"name": "Ada"})           # dict, or None
users.get_by_id(some_id)                  # dict, or raises NotFoundError
```

`find()` returns a **Cursor**, and both forms below are the same request:

```python
users.find({"age": {"$gte": 18}}, sort="-age", limit=10, skip=20)
users.find({"age": {"$gte": 18}}).sort("-age").skip(20).limit(10)
```

Sort takes **one path**, with the CLI's convention: `"age"` ascends, `"-age"`
descends. Not pymongo's list of `(field, direction)` pairs — jsondb sorts by a
single path, and accepting a list while honouring only its first entry would be
worse than not accepting it.

Filters are passed to the server untouched, so every operator the engine
supports works: `$eq $ne $gt $gte $lt $lte $in $nin $and $or $not $nor $exists
$type $all $size $elemMatch $regex $mod $like`. Matching an array member is
implicit, as in MongoDB — `{"tags": "x"}` matches both `"x"` and `["x", "y"]`.

### The cursor is not a server-side cursor

jsondb has none, deliberately: the engine serialises through a single SQLite
connection, so a held cursor would block every other query. The server answers
a find with one complete JSON array.

What is lazy is the **request**. Chaining sends nothing; the fetch happens once,
when you first iterate — which is how `.sort().limit()` avoids fetching the
unsorted, unlimited result first. Two consequences worth knowing:

- Iterating twice replays the held list rather than re-querying. The result is
  a snapshot, not a live view.
- **`skip` is O(skip) on the server.** It finds and discards every skipped row.
  Page a large collection with a filter on an indexed path, not a large offset.

## Writing

```python
users.insert_one({"name": "Ada", "tier": "gold"}).inserted_id
users.insert_many([{"name": "Bob"}, {"name": "Cy"}]).inserted_ids

users.replace_by_id(doc_id, {"name": "Ada Lovelace"})   # whole body
users.patch_by_id(doc_id, {"age": 37})                  # shallow merge
users.delete_by_id(doc_id)
```

By filter, with MongoDB's update operators:

```python
users.update_one({"tier": "gold"}, {"$set": {"vip": True}})
users.update_many({"age": {"$lt": 18}}, {"$unset": {"vip": ""}})
users.replace_one({"name": "Ada"}, {"name": "Ada Lovelace"})
users.delete_many({"tier": "bronze"}).deleted_count
```

Three things worth knowing, all inherited from the engine rather than invented
here:

- **`update_*` requires operators.** `{"$set": {"tier": "gold"}}`, not
  `{"tier": "gold"}` — a bare document raises `InvalidUpdateError` rather than
  being silently reinterpreted as a replacement. Use `replace_one` when you
  mean to swap the whole body.
- **`matched_count` and `modified_count` differ** when a document already held
  the values being written. A zero `modified_count` is information, not a
  failure.
- **`upsert=True` seeds only from the filter's equality fields.** Given
  `{"tier": "plat", "age": {"$gt": 40}}`, an inserted document gets `tier` but
  not `age` — an operator has no single value to seed with.

`insert_many` is one request and one transaction: if any document is rejected,
none are stored.

## Indexes

```python
users.create_index("email", unique=True)
users.create_index(["tier", "age"])          # compound, led by tier
users.list_indexes()                          # [IndexInfo(...), ...]
users.drop_index("idx_docs_users_a1b2c3d4")
```

A **unique** index is *sparse*, as MongoDB's are: documents missing the path
never collide with one another. `create_index` returns `None` rather than the
index name — the server does not send one back, and a second round trip
pretending to be part of the first would be dishonest about the cost. Names
come from `list_indexes()`.

## Is my index actually being used?

An index that is not used returns exactly the same documents as one that is,
so the only place the difference shows is the plan:

```python
plan = users.explain({"tier": "gold"})
plan.seeks_index        # True  — narrows the search
plan.sorts_in_memory    # False — read in index order
print(plan)             # verdict first, then the raw steps
```

`seeks_index` and `sorts_in_memory` arrive from the server rather than being
derived here. The plan markers that produce them depend on how the storage
engine answers a query, which has already changed once — a client grepping the
step text itself would go quietly wrong the next time it does.

`explain` does not run the query.

## Collections

```python
users.stats()               # count, size, avg_obj_size, num_indexes
users.drop()                # documents and indexes
people = users.rename("people")
```

`rename` returns a handle for the new name and **does not update the one you
called it on** — that still points at the old name, now an empty collection, as
pymongo leaves it. Use the returned handle.

`drop()` on a collection that never existed is not an error: a collection is a
column value, so "empty" and "never existed" are the same state.

## Errors

```
JsondbError
├── ConnectionFailure          transport: refused, DNS, timeout
└── ServerError                .status  .code  .message
    ├── NotFoundError               not_found
    ├── DuplicateKeyError           duplicate_key
    ├── InvalidFilterError          invalid_filter
    ├── InvalidUpdateError          invalid_update
    ├── InvalidJSONError            invalid_json
    └── InvalidNameError            invalid_name
```

Mapping is by the server's machine-readable `code`, not by message text — two
errors can share a status, as `invalid_filter` and `invalid_update` both do at
400. New codes may appear without an API version bump, so an unrecognised one
raises plain `ServerError` and stays catchable.

`find_one` returns `None` when nothing matches; `get_by_id` raises
`NotFoundError`. The asymmetry is deliberate, and matches MongoDB's: a filter
matching nothing is an ordinary outcome, but fetching an id you already hold
means you expected it to exist.

## Connecting

```python
jsondb_client.connect("jsondb://host:8080")    # http
jsondb_client.connect("jsondbs://host:8080")   # https
jsondb_client.connect("http://host:8080")      # also accepted
```

`connect()` checks the server's API version before returning, and fails naming
both numbers. A mismatch that surfaced later would look like a 404 on a route
that had moved, long after the context needed to explain it had gone.

A URI carrying credentials is **refused**, not ignored — jsondb has no
authentication yet, and quietly dropping a password would look like it had
worked. For the same reason there is no `password=` argument.

## Types

jsondb stores plain JSON. There is no date type and no decimal type, so a
`datetime` raises rather than being written as some string you did not choose
and read back as a string you did not expect. Convert at your boundary.

`_id` is a UUIDv7 **string**, not an `ObjectId`. It is time-ordered, so sorting
by `_id` is insertion order.

## How this stays in step with the server

jsondb enforces parity between its storage library and the reference
[Go driver](https://github.com/pmuston/jsondb-go) by reflecting over both
method sets. Python cannot join that test, so a method added upstream would
land here as silence.

[`surface.json`](https://pmuston.github.io/jsondb/surface.json) closes that
gap: it is the reference driver's method set, regenerated by a test that fails
when it is stale. This package vendors it at `tests/surface.json` and asserts
every name maps to one of its methods — and **fails on a name it has never
seen** rather than skipping it, which is the whole point. Refresh with:

```sh
curl -o tests/surface.json https://pmuston.github.io/jsondb/surface.json
```

## Tests

```sh
pip install -e ".[test]"
pytest
```

They run against a **real jsondb**, not a mock: the binary is spawned on an
ephemeral port with an in-memory database, so a wrong assumption about filter
semantics or sort order fails instead of being agreed with. `jsondb` must be on
PATH, or set `JSONDB_BIN`; the server-backed tests skip if it is absent.

## Requirements

Python 3.10+ and a jsondb server speaking API version 1.

## Licence

MIT. The [server](https://pmuston.github.io/jsondb) is distributed as a binary
under the same licence.
