pybasedb-json 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pybasedb_json-0.3.0/PKG-INFO +134 -0
- pybasedb_json-0.3.0/README.md +108 -0
- pybasedb_json-0.3.0/pybase/__init__.py +29 -0
- pybasedb_json-0.3.0/pybase/backup.py +99 -0
- pybasedb_json-0.3.0/pybase/collections.py +218 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybase/database.py +42 -8
- pybasedb_json-0.3.0/pybase/hooks.py +118 -0
- pybasedb_json-0.3.0/pybase/index.py +54 -0
- pybasedb_json-0.3.0/pybase/media.py +214 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybase/query.py +37 -21
- pybasedb_json-0.3.0/pybase/server.py +279 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybase/utils.py +0 -3
- pybasedb_json-0.3.0/pybase/validation.py +236 -0
- pybasedb_json-0.3.0/pybasedb_json.egg-info/PKG-INFO +134 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybasedb_json.egg-info/SOURCES.txt +6 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pyproject.toml +6 -3
- pybasedb_json-0.2.0/PKG-INFO +0 -92
- pybasedb_json-0.2.0/README.md +0 -69
- pybasedb_json-0.2.0/pybase/__init__.py +0 -13
- pybasedb_json-0.2.0/pybase/collections.py +0 -133
- pybasedb_json-0.2.0/pybasedb_json.egg-info/PKG-INFO +0 -92
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybase/exceptions.py +0 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybasedb_json.egg-info/dependency_links.txt +0 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/pybasedb_json.egg-info/top_level.txt +0 -0
- {pybasedb_json-0.2.0 → pybasedb_json-0.3.0}/setup.cfg +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybasedb-json
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Banco de dados local com JSON — schemas, server REST, media, zero dependencias
|
|
5
|
+
Author-email: Marcos Gomes <marcosgabrielgomes110@gmail.com>
|
|
6
|
+
Maintainer-email: Marcos Gomes <marcosgabrielgomes110@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/marcosgabrielgomes110-collab/Pybase
|
|
9
|
+
Project-URL: Documentation, https://github.com/marcosgabrielgomes110-collab/Pybase/tree/main/docs
|
|
10
|
+
Project-URL: Repository, https://github.com/marcosgabrielgomes110-collab/Pybase
|
|
11
|
+
Keywords: database,json,local,embedded,nosql,thread-safe,rest-api,schema,media
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="assets/logo/logo.png" width="120" alt="Pybase logo">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
# Pybase
|
|
32
|
+
|
|
33
|
+
Base para criacao de bancos de dados locais com JSON. Zero dependencias, 100% stdlib.
|
|
34
|
+
Operacoes de escrita atomicas, thread-safe.
|
|
35
|
+
|
|
36
|
+
Filosofia **Lego**: cada modulo e independente — voce monta o que precisa.
|
|
37
|
+
|
|
38
|
+
## Instalacao
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install pybasedb-json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Para o servidor REST (opcional):
|
|
45
|
+
```bash
|
|
46
|
+
pip install Flask
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Uso rapido
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from pybase import Database, Query, Server
|
|
53
|
+
from pybase.validation import Schema
|
|
54
|
+
|
|
55
|
+
# Banco de dados
|
|
56
|
+
db = Database("./data", "mydb", "senha").open("senha")
|
|
57
|
+
|
|
58
|
+
# Colecao com schema (opcional)
|
|
59
|
+
db.schema("users", Schema({
|
|
60
|
+
"id": str, "name": str, "email": str,
|
|
61
|
+
"age": (int, 0), "active": (bool, True),
|
|
62
|
+
}))
|
|
63
|
+
users = db.collection("users")
|
|
64
|
+
users.add({"id": "1", "name": "marcos", "email": "m@m.com"})
|
|
65
|
+
|
|
66
|
+
# SDK client
|
|
67
|
+
doc = users.doc("1").get()
|
|
68
|
+
users.where("age", "==", 30).get()
|
|
69
|
+
|
|
70
|
+
# Consultas
|
|
71
|
+
q = Query(users)
|
|
72
|
+
q.sort("name").limit(10).find(active=True)
|
|
73
|
+
q.find_one(name="ana")
|
|
74
|
+
q.exists(name="joao")
|
|
75
|
+
|
|
76
|
+
# Servidor REST (requer Flask)
|
|
77
|
+
Server(db).route("users").run(port=4560)
|
|
78
|
+
# Swagger UI: http://localhost:4560/mydb/docs/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Modulos
|
|
82
|
+
|
|
83
|
+
| Modulo | Arquivo | Descricao | Deps |
|
|
84
|
+
|--------|---------|-----------|------|
|
|
85
|
+
| Database | `pybase/database.py` | Gerencia diretorio de tabelas JSON | 0 |
|
|
86
|
+
| Collection | `pybase/collections.py` | CRUD atomico thread-safe | 0 |
|
|
87
|
+
| Query | `pybase/query.py` | Consultas com sort/limit/offset | 0 |
|
|
88
|
+
| Schema | `pybase/validation.py` | Validacao de documentos | 0 |
|
|
89
|
+
| Server | `pybase/server.py` | REST API automatica + Swagger UI | Flask |
|
|
90
|
+
| Media | `pybase/media.py` | Upload/download de imagens | 0 |
|
|
91
|
+
| Backup | `pybase/backup.py` | Snapshot e restore | 0 |
|
|
92
|
+
|
|
93
|
+
## Estrutura
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Pybase/
|
|
97
|
+
pybase/
|
|
98
|
+
__init__.py # Database, Collection, Query, Server, ...
|
|
99
|
+
database.py # Database: init, open, collection, schema, drop
|
|
100
|
+
collections.py # Collection, SchemaCollection, DocRef, QueryBuilder
|
|
101
|
+
query.py # Query: find/find_one/exists + sort/limit/offset
|
|
102
|
+
exceptions.py # PybaseError > DatabaseError, CollectionError
|
|
103
|
+
validation.py # Schema, ValidationError
|
|
104
|
+
server.py # Server REST (Flask)
|
|
105
|
+
media.py # MediaManager
|
|
106
|
+
index.py # Index (hash O(1))
|
|
107
|
+
hooks.py # HooksCollection
|
|
108
|
+
backup.py # take, restore
|
|
109
|
+
utils.py # Utils: sha256 encode
|
|
110
|
+
docs/ # Documentacao completa
|
|
111
|
+
pyproject.toml
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Novidades da versao (0.3+)
|
|
115
|
+
|
|
116
|
+
- **Schema validation** — defina schemas opcionais por colecao, persistidos em disco, validacao automatica em `add`/`add_many`/`actualize`
|
|
117
|
+
- **Server REST** — `Server(db).route("x").run()` gera CRUD automatico com Swagger UI e OpenAPI spec
|
|
118
|
+
- **MediaManager** — upload de imagens (path, bytes, base64), CRUD de metadados, serve files via HTTP
|
|
119
|
+
- **SDK client** — `doc("id").get()`, `where("f", "==", "v").get()`
|
|
120
|
+
- **SchemaCollection** — auto-wrap quando schema existe, validacao parcial em updates
|
|
121
|
+
- **Backup** — `take()`/`restore()` com snapshot gzip atomico
|
|
122
|
+
- **Escrita atomica** — `.tmp` + `os.replace()` protege contra corrupcao
|
|
123
|
+
- **Path traversal fix** — nomes de colecao sanitizados com regex
|
|
124
|
+
- **Sort seguro** — tipos mistos (int + str) nao quebram mais
|
|
125
|
+
- **Indices em memoria** — `Index(field)` para lookups O(1)
|
|
126
|
+
- **Lifecycle hooks** — `HooksCollection` com callbacks before/after
|
|
127
|
+
|
|
128
|
+
## Documentacao
|
|
129
|
+
|
|
130
|
+
Veja [docs/](docs/index.md) com referencia completa de cada classe e metodo.
|
|
131
|
+
|
|
132
|
+
## Licenca
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo/logo.png" width="120" alt="Pybase logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Pybase
|
|
6
|
+
|
|
7
|
+
Base para criacao de bancos de dados locais com JSON. Zero dependencias, 100% stdlib.
|
|
8
|
+
Operacoes de escrita atomicas, thread-safe.
|
|
9
|
+
|
|
10
|
+
Filosofia **Lego**: cada modulo e independente — voce monta o que precisa.
|
|
11
|
+
|
|
12
|
+
## Instalacao
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pybasedb-json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Para o servidor REST (opcional):
|
|
19
|
+
```bash
|
|
20
|
+
pip install Flask
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Uso rapido
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pybase import Database, Query, Server
|
|
27
|
+
from pybase.validation import Schema
|
|
28
|
+
|
|
29
|
+
# Banco de dados
|
|
30
|
+
db = Database("./data", "mydb", "senha").open("senha")
|
|
31
|
+
|
|
32
|
+
# Colecao com schema (opcional)
|
|
33
|
+
db.schema("users", Schema({
|
|
34
|
+
"id": str, "name": str, "email": str,
|
|
35
|
+
"age": (int, 0), "active": (bool, True),
|
|
36
|
+
}))
|
|
37
|
+
users = db.collection("users")
|
|
38
|
+
users.add({"id": "1", "name": "marcos", "email": "m@m.com"})
|
|
39
|
+
|
|
40
|
+
# SDK client
|
|
41
|
+
doc = users.doc("1").get()
|
|
42
|
+
users.where("age", "==", 30).get()
|
|
43
|
+
|
|
44
|
+
# Consultas
|
|
45
|
+
q = Query(users)
|
|
46
|
+
q.sort("name").limit(10).find(active=True)
|
|
47
|
+
q.find_one(name="ana")
|
|
48
|
+
q.exists(name="joao")
|
|
49
|
+
|
|
50
|
+
# Servidor REST (requer Flask)
|
|
51
|
+
Server(db).route("users").run(port=4560)
|
|
52
|
+
# Swagger UI: http://localhost:4560/mydb/docs/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Modulos
|
|
56
|
+
|
|
57
|
+
| Modulo | Arquivo | Descricao | Deps |
|
|
58
|
+
|--------|---------|-----------|------|
|
|
59
|
+
| Database | `pybase/database.py` | Gerencia diretorio de tabelas JSON | 0 |
|
|
60
|
+
| Collection | `pybase/collections.py` | CRUD atomico thread-safe | 0 |
|
|
61
|
+
| Query | `pybase/query.py` | Consultas com sort/limit/offset | 0 |
|
|
62
|
+
| Schema | `pybase/validation.py` | Validacao de documentos | 0 |
|
|
63
|
+
| Server | `pybase/server.py` | REST API automatica + Swagger UI | Flask |
|
|
64
|
+
| Media | `pybase/media.py` | Upload/download de imagens | 0 |
|
|
65
|
+
| Backup | `pybase/backup.py` | Snapshot e restore | 0 |
|
|
66
|
+
|
|
67
|
+
## Estrutura
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Pybase/
|
|
71
|
+
pybase/
|
|
72
|
+
__init__.py # Database, Collection, Query, Server, ...
|
|
73
|
+
database.py # Database: init, open, collection, schema, drop
|
|
74
|
+
collections.py # Collection, SchemaCollection, DocRef, QueryBuilder
|
|
75
|
+
query.py # Query: find/find_one/exists + sort/limit/offset
|
|
76
|
+
exceptions.py # PybaseError > DatabaseError, CollectionError
|
|
77
|
+
validation.py # Schema, ValidationError
|
|
78
|
+
server.py # Server REST (Flask)
|
|
79
|
+
media.py # MediaManager
|
|
80
|
+
index.py # Index (hash O(1))
|
|
81
|
+
hooks.py # HooksCollection
|
|
82
|
+
backup.py # take, restore
|
|
83
|
+
utils.py # Utils: sha256 encode
|
|
84
|
+
docs/ # Documentacao completa
|
|
85
|
+
pyproject.toml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Novidades da versao (0.3+)
|
|
89
|
+
|
|
90
|
+
- **Schema validation** — defina schemas opcionais por colecao, persistidos em disco, validacao automatica em `add`/`add_many`/`actualize`
|
|
91
|
+
- **Server REST** — `Server(db).route("x").run()` gera CRUD automatico com Swagger UI e OpenAPI spec
|
|
92
|
+
- **MediaManager** — upload de imagens (path, bytes, base64), CRUD de metadados, serve files via HTTP
|
|
93
|
+
- **SDK client** — `doc("id").get()`, `where("f", "==", "v").get()`
|
|
94
|
+
- **SchemaCollection** — auto-wrap quando schema existe, validacao parcial em updates
|
|
95
|
+
- **Backup** — `take()`/`restore()` com snapshot gzip atomico
|
|
96
|
+
- **Escrita atomica** — `.tmp` + `os.replace()` protege contra corrupcao
|
|
97
|
+
- **Path traversal fix** — nomes de colecao sanitizados com regex
|
|
98
|
+
- **Sort seguro** — tipos mistos (int + str) nao quebram mais
|
|
99
|
+
- **Indices em memoria** — `Index(field)` para lookups O(1)
|
|
100
|
+
- **Lifecycle hooks** — `HooksCollection` com callbacks before/after
|
|
101
|
+
|
|
102
|
+
## Documentacao
|
|
103
|
+
|
|
104
|
+
Veja [docs/](docs/index.md) com referencia completa de cada classe e metodo.
|
|
105
|
+
|
|
106
|
+
## Licenca
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from .server import Server
|
|
3
|
+
except ImportError:
|
|
4
|
+
Server = None # Flask not installed — server unavailable
|
|
5
|
+
|
|
6
|
+
from .collections import Collection, DocRef, QueryBuilder, SchemaCollection
|
|
7
|
+
from .database import Database
|
|
8
|
+
from .exceptions import CollectionError, DatabaseError, PybaseError
|
|
9
|
+
from .query import Query
|
|
10
|
+
|
|
11
|
+
# Optional Lego-piece modules (import explicitly):
|
|
12
|
+
# from pybase.validation import Schema, ValidationError
|
|
13
|
+
# from pybase.index import Index
|
|
14
|
+
# from pybase.hooks import HooksCollection
|
|
15
|
+
# from pybase.backup import take, restore
|
|
16
|
+
# from pybase.media import MediaManager, MediaError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Database",
|
|
20
|
+
"Collection",
|
|
21
|
+
"SchemaCollection",
|
|
22
|
+
"DocRef",
|
|
23
|
+
"QueryBuilder",
|
|
24
|
+
"Query",
|
|
25
|
+
"Server",
|
|
26
|
+
"PybaseError",
|
|
27
|
+
"DatabaseError",
|
|
28
|
+
"CollectionError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Lego piece: optional backup / restore for Pybase databases.
|
|
2
|
+
# from pybase.backup import take, restore, list_snapshots
|
|
3
|
+
# snap = take(db) # timestamped .json.gz
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import gzip
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .database import Database
|
|
16
|
+
|
|
17
|
+
_BACKUP_DIR = "backups"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def take(
|
|
21
|
+
db: Database,
|
|
22
|
+
dest: str | Path | None = None,
|
|
23
|
+
*,
|
|
24
|
+
compress: bool = True,
|
|
25
|
+
) -> Path:
|
|
26
|
+
# Snapshot every collection into one timestamped file
|
|
27
|
+
backup_dir = Path(dest) if dest else (db.path_dir / _BACKUP_DIR)
|
|
28
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
31
|
+
ext = ".json.gz" if compress else ".json"
|
|
32
|
+
out_path = backup_dir / f"{db.name}_{ts}{ext}"
|
|
33
|
+
|
|
34
|
+
snapshot: dict[str, list[dict]] = {}
|
|
35
|
+
for name in db.collections():
|
|
36
|
+
snapshot[name] = db.collection(name).all()
|
|
37
|
+
|
|
38
|
+
raw = json.dumps(snapshot, indent=2).encode("utf-8")
|
|
39
|
+
|
|
40
|
+
if compress:
|
|
41
|
+
# mtime=0 makes the gzip deterministic (same content → same bytes)
|
|
42
|
+
with gzip.GzipFile(out_path, "wb", mtime=0) as f:
|
|
43
|
+
f.write(raw)
|
|
44
|
+
else:
|
|
45
|
+
out_path.write_bytes(raw)
|
|
46
|
+
|
|
47
|
+
return out_path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def restore(
|
|
51
|
+
db_dir: str | Path,
|
|
52
|
+
snapshot: str | Path,
|
|
53
|
+
*,
|
|
54
|
+
merge: bool = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
# Restore collection data from a snapshot file (.json or .json.gz)
|
|
57
|
+
db_dir = Path(db_dir)
|
|
58
|
+
snap_path = Path(snapshot)
|
|
59
|
+
|
|
60
|
+
if snap_path.suffix == ".gz":
|
|
61
|
+
with gzip.GzipFile(snap_path, "rb") as f:
|
|
62
|
+
raw = f.read()
|
|
63
|
+
else:
|
|
64
|
+
raw = snap_path.read_bytes()
|
|
65
|
+
|
|
66
|
+
data: dict[str, list[dict]] = json.loads(raw)
|
|
67
|
+
|
|
68
|
+
for name, docs in data.items():
|
|
69
|
+
col_path = db_dir / f"{name}_table.json"
|
|
70
|
+
existing = []
|
|
71
|
+
if merge and col_path.exists():
|
|
72
|
+
existing = (json.loads(col_path.read_bytes()) if col_path.stat().st_size else [])
|
|
73
|
+
merged = existing + docs if merge else docs
|
|
74
|
+
|
|
75
|
+
# atomic write
|
|
76
|
+
tmp = col_path.with_suffix(".tmp")
|
|
77
|
+
tmp.write_text(json.dumps(merged, indent=2), encoding="utf-8")
|
|
78
|
+
os.replace(tmp, col_path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_snapshots(backup_dir: str | Path) -> list[Path]:
|
|
82
|
+
# Return snapshots sorted newest first
|
|
83
|
+
d = Path(backup_dir)
|
|
84
|
+
if not d.exists():
|
|
85
|
+
return []
|
|
86
|
+
files = sorted(p for p in d.iterdir() if p.suffix in (".json", ".gz") and p.stem != ".tmp")
|
|
87
|
+
return list(reversed(files))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clean(backup_dir: str | Path, *, keep: int = 10, dry_run: bool = False) -> list[Path]:
|
|
91
|
+
# Remove old snapshots, keeping the `keep` most recent
|
|
92
|
+
snaps = list_snapshots(backup_dir)
|
|
93
|
+
if len(snaps) <= keep:
|
|
94
|
+
return []
|
|
95
|
+
to_remove = snaps[keep:]
|
|
96
|
+
if not dry_run:
|
|
97
|
+
for p in to_remove:
|
|
98
|
+
p.unlink()
|
|
99
|
+
return to_remove
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .exceptions import CollectionError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Collection:
|
|
10
|
+
def __init__(self, file_path: str | Path) -> None:
|
|
11
|
+
self.file_path = Path(file_path)
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return str(self.file_path)
|
|
16
|
+
|
|
17
|
+
def __len__(self) -> int:
|
|
18
|
+
return len(self._load())
|
|
19
|
+
|
|
20
|
+
def __iter__(self):
|
|
21
|
+
return iter(self._load())
|
|
22
|
+
|
|
23
|
+
def __getitem__(self, index: int | slice) -> dict | list[dict]:
|
|
24
|
+
return self._load()[index]
|
|
25
|
+
|
|
26
|
+
# -- internal I/O (unsafe -- caller must hold lock) --
|
|
27
|
+
|
|
28
|
+
def _load_nolock(self) -> list:
|
|
29
|
+
if not self.file_path.exists():
|
|
30
|
+
return []
|
|
31
|
+
try:
|
|
32
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
raise CollectionError(f"corrupted file: {self.file_path}")
|
|
36
|
+
if not isinstance(data, list):
|
|
37
|
+
raise CollectionError(
|
|
38
|
+
f"expected a list, got {type(data).__name__}: {self.file_path}"
|
|
39
|
+
)
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
def _save_nolock(self, data: list) -> None:
|
|
43
|
+
# write to .tmp then rename — atomic on POSIX / Windows
|
|
44
|
+
tmp = self.file_path.with_suffix(".tmp")
|
|
45
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
46
|
+
json.dump(data, f, indent=4)
|
|
47
|
+
os.replace(tmp, self.file_path)
|
|
48
|
+
|
|
49
|
+
def _load(self) -> list:
|
|
50
|
+
with self._lock:
|
|
51
|
+
return self._load_nolock()
|
|
52
|
+
|
|
53
|
+
# -- reads (thread-safe via _load) --
|
|
54
|
+
|
|
55
|
+
def all(self) -> list[dict]:
|
|
56
|
+
return list(self._load())
|
|
57
|
+
|
|
58
|
+
def count(self, **match) -> int:
|
|
59
|
+
if not match:
|
|
60
|
+
return len(self._load())
|
|
61
|
+
docs = self._load()
|
|
62
|
+
return sum(
|
|
63
|
+
1
|
|
64
|
+
for doc in docs
|
|
65
|
+
if all(doc.get(k) == v for k, v in match.items())
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# .get() without args returns all docs; with args returns by id/key
|
|
69
|
+
def get(self, doc_id=None, key="id"):
|
|
70
|
+
if doc_id is None:
|
|
71
|
+
return self.all()
|
|
72
|
+
for doc in self._load():
|
|
73
|
+
if doc.get(key) == doc_id:
|
|
74
|
+
return doc
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# -- SDK-style helpers --
|
|
78
|
+
|
|
79
|
+
def doc(self, doc_id: str) -> "DocRef":
|
|
80
|
+
return DocRef(self, doc_id)
|
|
81
|
+
|
|
82
|
+
def where(self, field: str, op: str, value) -> "QueryBuilder":
|
|
83
|
+
return QueryBuilder(self).where(field, op, value)
|
|
84
|
+
|
|
85
|
+
# -- writes (atomic: single lock for read-modify-write) --
|
|
86
|
+
|
|
87
|
+
def add(self, data: dict) -> dict:
|
|
88
|
+
doc = dict(data)
|
|
89
|
+
with self._lock:
|
|
90
|
+
docs = self._load_nolock()
|
|
91
|
+
docs.append(doc)
|
|
92
|
+
self._save_nolock(docs)
|
|
93
|
+
return doc
|
|
94
|
+
|
|
95
|
+
def add_many(self, items: list[dict]) -> list[dict]:
|
|
96
|
+
if not isinstance(items, list):
|
|
97
|
+
raise TypeError("add_many() expects a list of dicts")
|
|
98
|
+
with self._lock:
|
|
99
|
+
docs = self._load_nolock()
|
|
100
|
+
added = [dict(item) for item in items]
|
|
101
|
+
docs.extend(added)
|
|
102
|
+
self._save_nolock(docs)
|
|
103
|
+
return added
|
|
104
|
+
|
|
105
|
+
def actualize(self, data: dict, **match) -> int:
|
|
106
|
+
if not match:
|
|
107
|
+
raise CollectionError(
|
|
108
|
+
"provide at least one filter field, "
|
|
109
|
+
"e.g. actualize({'age': 20}, name='marcos')"
|
|
110
|
+
)
|
|
111
|
+
with self._lock:
|
|
112
|
+
docs = self._load_nolock()
|
|
113
|
+
updated = 0
|
|
114
|
+
for i, doc in enumerate(docs):
|
|
115
|
+
if all(doc.get(k) == v for k, v in match.items()):
|
|
116
|
+
# defensive copy — don't mutate original dicts in memory
|
|
117
|
+
docs[i] = {**doc, **data}
|
|
118
|
+
updated += 1
|
|
119
|
+
if updated:
|
|
120
|
+
self._save_nolock(docs)
|
|
121
|
+
return updated
|
|
122
|
+
|
|
123
|
+
def rem(self, **match) -> int:
|
|
124
|
+
if not match:
|
|
125
|
+
raise CollectionError(
|
|
126
|
+
"provide at least one filter field, "
|
|
127
|
+
"e.g. rem(name='marcos')"
|
|
128
|
+
)
|
|
129
|
+
with self._lock:
|
|
130
|
+
docs = self._load_nolock()
|
|
131
|
+
kept = [
|
|
132
|
+
doc
|
|
133
|
+
for doc in docs
|
|
134
|
+
if not all(doc.get(k) == v for k, v in match.items())
|
|
135
|
+
]
|
|
136
|
+
removed = len(docs) - len(kept)
|
|
137
|
+
if removed:
|
|
138
|
+
self._save_nolock(kept)
|
|
139
|
+
return removed
|
|
140
|
+
|
|
141
|
+
def drop(self) -> None:
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._save_nolock([])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DocRef:
|
|
147
|
+
def __init__(self, collection: Collection, doc_id: str) -> None:
|
|
148
|
+
self._col = collection
|
|
149
|
+
self._id = doc_id
|
|
150
|
+
|
|
151
|
+
def get(self) -> dict | None:
|
|
152
|
+
return self._col.get(self._id)
|
|
153
|
+
|
|
154
|
+
def update(self, data: dict) -> int:
|
|
155
|
+
return self._col.actualize(data, id=self._id)
|
|
156
|
+
|
|
157
|
+
def delete(self) -> int:
|
|
158
|
+
return self._col.rem(id=self._id)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class QueryBuilder:
|
|
162
|
+
def __init__(self, collection: Collection) -> None:
|
|
163
|
+
self._col = collection
|
|
164
|
+
self._filters: list[tuple[str, str]] = []
|
|
165
|
+
|
|
166
|
+
def where(self, field: str, op: str, value) -> "QueryBuilder":
|
|
167
|
+
if op != "==":
|
|
168
|
+
raise ValueError(f"unsupported operator: {op!r} (only '==' for now)")
|
|
169
|
+
self._filters.append((field, value))
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def get(self) -> list[dict]:
|
|
173
|
+
docs = self._col.all()
|
|
174
|
+
for field, value in self._filters:
|
|
175
|
+
docs = [d for d in docs if d.get(field) == value]
|
|
176
|
+
return docs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SchemaCollection:
|
|
180
|
+
# Wraps a Collection + optional Schema.
|
|
181
|
+
# Validates all writes against the schema before delegating.
|
|
182
|
+
|
|
183
|
+
def __init__(self, collection: Collection, schema=None) -> None:
|
|
184
|
+
self._col = collection
|
|
185
|
+
self._schema = schema # None = passthrough, no validation
|
|
186
|
+
|
|
187
|
+
def set_schema(self, schema) -> None:
|
|
188
|
+
self._schema = schema
|
|
189
|
+
|
|
190
|
+
# -- schema-aware writes --
|
|
191
|
+
|
|
192
|
+
def add(self, data: dict) -> dict:
|
|
193
|
+
doc = self._schema.validate(data) if self._schema else dict(data)
|
|
194
|
+
return self._col.add(doc)
|
|
195
|
+
|
|
196
|
+
def add_many(self, items: list[dict]) -> list[dict]:
|
|
197
|
+
if self._schema:
|
|
198
|
+
items = [self._schema.validate(d) for d in items]
|
|
199
|
+
return self._col.add_many(items)
|
|
200
|
+
|
|
201
|
+
def actualize(self, data: dict, **match) -> int:
|
|
202
|
+
if self._schema:
|
|
203
|
+
self._schema.validate(data, partial=True) # partial update — only check types
|
|
204
|
+
return self._col.actualize(data, **match)
|
|
205
|
+
|
|
206
|
+
# -- delegation (reads + rem + drop + SDK helpers) --
|
|
207
|
+
|
|
208
|
+
def __getattr__(self, name: str):
|
|
209
|
+
return getattr(self._col, name)
|
|
210
|
+
|
|
211
|
+
def __str__(self) -> str:
|
|
212
|
+
return str(self._col)
|
|
213
|
+
def __len__(self) -> int:
|
|
214
|
+
return len(self._col)
|
|
215
|
+
def __iter__(self):
|
|
216
|
+
return iter(self._col)
|
|
217
|
+
def __getitem__(self, i):
|
|
218
|
+
return self._col[i]
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import shutil
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
|
-
from .collections import Collection
|
|
6
|
+
from .collections import Collection, SchemaCollection
|
|
6
7
|
from .exceptions import DatabaseError
|
|
7
8
|
from .utils import Utils
|
|
8
9
|
|
|
10
|
+
_COLLECTION_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class Database:
|
|
11
14
|
def __init__(self, local: str | Path, name: str, password: str) -> None:
|
|
@@ -27,14 +30,14 @@ class Database:
|
|
|
27
30
|
def _verify_password(self, password: str) -> dict:
|
|
28
31
|
try:
|
|
29
32
|
with open(self.path_conf, "r", encoding="utf-8") as f:
|
|
30
|
-
|
|
33
|
+
data = json.load(f)
|
|
31
34
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
32
35
|
raise DatabaseError(
|
|
33
|
-
f"database '{self.name}'
|
|
36
|
+
f"database '{self.name}' is corrupted or does not exist"
|
|
34
37
|
)
|
|
35
|
-
if str(Utils.encode(password)) !=
|
|
36
|
-
raise DatabaseError("
|
|
37
|
-
return
|
|
38
|
+
if str(Utils.encode(password)) != data.get("password"):
|
|
39
|
+
raise DatabaseError("incorrect password")
|
|
40
|
+
return data
|
|
38
41
|
|
|
39
42
|
def open(self, password: str) -> "Database":
|
|
40
43
|
self._verify_password(password)
|
|
@@ -59,12 +62,43 @@ class Database:
|
|
|
59
62
|
def collections(self) -> list[str]:
|
|
60
63
|
return [p.stem.removesuffix("_table") for p in self._table_paths()]
|
|
61
64
|
|
|
62
|
-
def collection(self, name: str) -> Collection:
|
|
65
|
+
def collection(self, name: str) -> SchemaCollection | Collection:
|
|
66
|
+
# Returns a SchemaCollection if a schema file exists, plain Collection otherwise.
|
|
67
|
+
if not _COLLECTION_NAME_RE.match(name):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"invalid collection name: {name!r}. "
|
|
70
|
+
"Use only letters, digits, hyphens, and underscores."
|
|
71
|
+
)
|
|
63
72
|
path = self.path_dir / f"{name}_table.json"
|
|
64
73
|
if not path.exists():
|
|
65
74
|
with open(path, "w", encoding="utf-8") as f:
|
|
66
75
|
json.dump([], f, indent=4)
|
|
67
|
-
|
|
76
|
+
|
|
77
|
+
col = Collection(path)
|
|
78
|
+
|
|
79
|
+
# Auto-wrap if a schema file exists
|
|
80
|
+
schema_path = self.path_dir / f"{name}_schema.json"
|
|
81
|
+
if schema_path.exists():
|
|
82
|
+
from .validation import Schema
|
|
83
|
+
data = json.loads(schema_path.read_text())
|
|
84
|
+
return SchemaCollection(col, Schema.from_dict(data))
|
|
85
|
+
|
|
86
|
+
return col
|
|
87
|
+
|
|
88
|
+
def schema(self, name: str, schema) -> "Database":
|
|
89
|
+
# Register a persistent schema for a collection.
|
|
90
|
+
# Returns self for fluent chaining.
|
|
91
|
+
if not _COLLECTION_NAME_RE.match(name):
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"invalid collection name: {name!r}. "
|
|
94
|
+
"Use only letters, digits, hyphens, and underscores."
|
|
95
|
+
)
|
|
96
|
+
schema_path = self.path_dir / f"{name}_schema.json"
|
|
97
|
+
schema_path.write_text(
|
|
98
|
+
json.dumps(schema.to_dict(), indent=2),
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
)
|
|
101
|
+
return self
|
|
68
102
|
|
|
69
103
|
def drop(self, password: str) -> None:
|
|
70
104
|
self._verify_password(password)
|