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.
@@ -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
- dados = json.load(f)
33
+ data = json.load(f)
31
34
  except (FileNotFoundError, json.JSONDecodeError):
32
35
  raise DatabaseError(
33
- f"database '{self.name}' corrompido ou inexistente"
36
+ f"database '{self.name}' is corrupted or does not exist"
34
37
  )
35
- if str(Utils.encode(password)) != dados.get("password"):
36
- raise DatabaseError("senha incorreta")
37
- return dados
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
- return Collection(path)
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)