oj-persistence 0.0.1__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.
- oj_persistence-0.0.1/LICENSE +21 -0
- oj_persistence-0.0.1/PKG-INFO +108 -0
- oj_persistence-0.0.1/README.md +95 -0
- oj_persistence-0.0.1/oj_persistence/__init__.py +17 -0
- oj_persistence-0.0.1/oj_persistence/manager.py +194 -0
- oj_persistence-0.0.1/oj_persistence/store/__init__.py +15 -0
- oj_persistence-0.0.1/oj_persistence/store/base.py +44 -0
- oj_persistence-0.0.1/oj_persistence/store/csv_file.py +195 -0
- oj_persistence-0.0.1/oj_persistence/store/flat_file.py +126 -0
- oj_persistence-0.0.1/oj_persistence/store/ijson_file.py +137 -0
- oj_persistence-0.0.1/oj_persistence/store/in_memory.py +49 -0
- oj_persistence-0.0.1/oj_persistence/store/ndjson_file.py +120 -0
- oj_persistence-0.0.1/oj_persistence/utils/__init__.py +0 -0
- oj_persistence-0.0.1/oj_persistence/utils/rwlock.py +60 -0
- oj_persistence-0.0.1/oj_persistence.egg-info/PKG-INFO +108 -0
- oj_persistence-0.0.1/oj_persistence.egg-info/SOURCES.txt +19 -0
- oj_persistence-0.0.1/oj_persistence.egg-info/dependency_links.txt +1 -0
- oj_persistence-0.0.1/oj_persistence.egg-info/requires.txt +1 -0
- oj_persistence-0.0.1/oj_persistence.egg-info/top_level.txt +1 -0
- oj_persistence-0.0.1/pyproject.toml +25 -0
- oj_persistence-0.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ownjoo.org
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oj-persistence
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Thread-safe persistence management with pluggable storage backends
|
|
5
|
+
Author-email: Speedy <speedy@ownjoo.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ownjoo-org/persistence
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: ijson
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# oj-persistence
|
|
15
|
+
|
|
16
|
+
Thread-safe persistence management with pluggable storage backends.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install oj-persistence
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Overview
|
|
25
|
+
|
|
26
|
+
`oj-persistence` provides a singleton `PersistenceManager` that acts as the single I/O interface for all data operations. Callers register named stores with the manager and interact exclusively through it — stores are never touched directly for data operations.
|
|
27
|
+
|
|
28
|
+
Designed for use with multi-threaded applications and async pipelines (e.g. [`io_chains`](https://github.com/ownjoo-org/io_chains)).
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from oj_persistence import PersistenceManager, InMemoryStore, NdjsonFileStore
|
|
34
|
+
|
|
35
|
+
pm = PersistenceManager()
|
|
36
|
+
|
|
37
|
+
# Register stores by name
|
|
38
|
+
pm.get_or_create('cache', lambda: InMemoryStore())
|
|
39
|
+
pm.get_or_create('events', lambda: NdjsonFileStore('events.ndjson'))
|
|
40
|
+
|
|
41
|
+
# All data ops go through the manager
|
|
42
|
+
pm.create('cache', 'user:1', {'name': 'Alice'})
|
|
43
|
+
pm.upsert('events', 'evt:1', {'type': 'login', 'user': 'user:1'})
|
|
44
|
+
|
|
45
|
+
value = pm.read('cache', 'user:1') # {'name': 'Alice'}
|
|
46
|
+
pm.update('cache', 'user:1', {'name': 'Alice', 'role': 'admin'})
|
|
47
|
+
pm.delete('cache', 'user:1')
|
|
48
|
+
|
|
49
|
+
results = pm.list('cache', predicate=lambda v: v.get('role') == 'admin')
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## CRUDL semantics
|
|
53
|
+
|
|
54
|
+
| Method | Behaviour |
|
|
55
|
+
|------------|------------------------------------------------|
|
|
56
|
+
| `create` | Raises `KeyError` if key already exists |
|
|
57
|
+
| `read` | Returns `None` if key is missing |
|
|
58
|
+
| `update` | Raises `KeyError` if key does not exist |
|
|
59
|
+
| `upsert` | Creates or overwrites — never raises |
|
|
60
|
+
| `delete` | No-op if key is missing |
|
|
61
|
+
| `list` | Returns all values, optionally filtered |
|
|
62
|
+
|
|
63
|
+
## Relational joins
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
ON = lambda user, order: user['id'] == order['user_id']
|
|
67
|
+
|
|
68
|
+
results = pm.join('users', 'orders', on=ON, how='left',
|
|
69
|
+
where=lambda u, o: o['total'] > 100)
|
|
70
|
+
# returns list of (left, right) tuples; unmatched rows have None on the missing side
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Supported `how` values: `inner` (default), `left`, `right`, `outer`.
|
|
74
|
+
|
|
75
|
+
## Storage backends
|
|
76
|
+
|
|
77
|
+
| Class | Format | Notes |
|
|
78
|
+
|-----------------|---------------------------|------------------------------------|
|
|
79
|
+
| `InMemoryStore` | In-process dict | Fastest; not persistent |
|
|
80
|
+
| `FlatFileStore` | JSON (full load/save) | Simple; loads entire file per op |
|
|
81
|
+
| `NdjsonFileStore` | NDJSON (one obj/line) | Append-only creates; O(1) writes |
|
|
82
|
+
| `IjsonFileStore` | Standard JSON (streaming) | Streaming reads via `ijson` |
|
|
83
|
+
| `CsvFileStore` | CSV | Fieldnames inferred or pre-specified |
|
|
84
|
+
|
|
85
|
+
All file-backed stores are thread-safe via a writer-preferring `ReadWriteLock`.
|
|
86
|
+
|
|
87
|
+
## Custom stores
|
|
88
|
+
|
|
89
|
+
Implement `AbstractStore`:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from oj_persistence.store.base import AbstractStore
|
|
93
|
+
|
|
94
|
+
class MyStore(AbstractStore):
|
|
95
|
+
def create(self, key, value): ...
|
|
96
|
+
def read(self, key): ...
|
|
97
|
+
def update(self, key, value): ...
|
|
98
|
+
def upsert(self, key, value): ...
|
|
99
|
+
def delete(self, key): ...
|
|
100
|
+
def list(self, predicate=None): ...
|
|
101
|
+
|
|
102
|
+
pm.register('custom', MyStore())
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Requirements
|
|
106
|
+
|
|
107
|
+
- Python >= 3.11
|
|
108
|
+
- `ijson` (for `IjsonFileStore`)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# oj-persistence
|
|
2
|
+
|
|
3
|
+
Thread-safe persistence management with pluggable storage backends.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install oj-persistence
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
`oj-persistence` provides a singleton `PersistenceManager` that acts as the single I/O interface for all data operations. Callers register named stores with the manager and interact exclusively through it — stores are never touched directly for data operations.
|
|
14
|
+
|
|
15
|
+
Designed for use with multi-threaded applications and async pipelines (e.g. [`io_chains`](https://github.com/ownjoo-org/io_chains)).
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from oj_persistence import PersistenceManager, InMemoryStore, NdjsonFileStore
|
|
21
|
+
|
|
22
|
+
pm = PersistenceManager()
|
|
23
|
+
|
|
24
|
+
# Register stores by name
|
|
25
|
+
pm.get_or_create('cache', lambda: InMemoryStore())
|
|
26
|
+
pm.get_or_create('events', lambda: NdjsonFileStore('events.ndjson'))
|
|
27
|
+
|
|
28
|
+
# All data ops go through the manager
|
|
29
|
+
pm.create('cache', 'user:1', {'name': 'Alice'})
|
|
30
|
+
pm.upsert('events', 'evt:1', {'type': 'login', 'user': 'user:1'})
|
|
31
|
+
|
|
32
|
+
value = pm.read('cache', 'user:1') # {'name': 'Alice'}
|
|
33
|
+
pm.update('cache', 'user:1', {'name': 'Alice', 'role': 'admin'})
|
|
34
|
+
pm.delete('cache', 'user:1')
|
|
35
|
+
|
|
36
|
+
results = pm.list('cache', predicate=lambda v: v.get('role') == 'admin')
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CRUDL semantics
|
|
40
|
+
|
|
41
|
+
| Method | Behaviour |
|
|
42
|
+
|------------|------------------------------------------------|
|
|
43
|
+
| `create` | Raises `KeyError` if key already exists |
|
|
44
|
+
| `read` | Returns `None` if key is missing |
|
|
45
|
+
| `update` | Raises `KeyError` if key does not exist |
|
|
46
|
+
| `upsert` | Creates or overwrites — never raises |
|
|
47
|
+
| `delete` | No-op if key is missing |
|
|
48
|
+
| `list` | Returns all values, optionally filtered |
|
|
49
|
+
|
|
50
|
+
## Relational joins
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
ON = lambda user, order: user['id'] == order['user_id']
|
|
54
|
+
|
|
55
|
+
results = pm.join('users', 'orders', on=ON, how='left',
|
|
56
|
+
where=lambda u, o: o['total'] > 100)
|
|
57
|
+
# returns list of (left, right) tuples; unmatched rows have None on the missing side
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Supported `how` values: `inner` (default), `left`, `right`, `outer`.
|
|
61
|
+
|
|
62
|
+
## Storage backends
|
|
63
|
+
|
|
64
|
+
| Class | Format | Notes |
|
|
65
|
+
|-----------------|---------------------------|------------------------------------|
|
|
66
|
+
| `InMemoryStore` | In-process dict | Fastest; not persistent |
|
|
67
|
+
| `FlatFileStore` | JSON (full load/save) | Simple; loads entire file per op |
|
|
68
|
+
| `NdjsonFileStore` | NDJSON (one obj/line) | Append-only creates; O(1) writes |
|
|
69
|
+
| `IjsonFileStore` | Standard JSON (streaming) | Streaming reads via `ijson` |
|
|
70
|
+
| `CsvFileStore` | CSV | Fieldnames inferred or pre-specified |
|
|
71
|
+
|
|
72
|
+
All file-backed stores are thread-safe via a writer-preferring `ReadWriteLock`.
|
|
73
|
+
|
|
74
|
+
## Custom stores
|
|
75
|
+
|
|
76
|
+
Implement `AbstractStore`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from oj_persistence.store.base import AbstractStore
|
|
80
|
+
|
|
81
|
+
class MyStore(AbstractStore):
|
|
82
|
+
def create(self, key, value): ...
|
|
83
|
+
def read(self, key): ...
|
|
84
|
+
def update(self, key, value): ...
|
|
85
|
+
def upsert(self, key, value): ...
|
|
86
|
+
def delete(self, key): ...
|
|
87
|
+
def list(self, predicate=None): ...
|
|
88
|
+
|
|
89
|
+
pm.register('custom', MyStore())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- Python >= 3.11
|
|
95
|
+
- `ijson` (for `IjsonFileStore`)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from oj_persistence.manager import PersistenceManager
|
|
2
|
+
from oj_persistence.store.base import AbstractStore
|
|
3
|
+
from oj_persistence.store.csv_file import CsvFileStore
|
|
4
|
+
from oj_persistence.store.flat_file import FlatFileStore
|
|
5
|
+
from oj_persistence.store.ijson_file import IjsonFileStore
|
|
6
|
+
from oj_persistence.store.in_memory import InMemoryStore
|
|
7
|
+
from oj_persistence.store.ndjson_file import NdjsonFileStore
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'PersistenceManager',
|
|
11
|
+
'AbstractStore',
|
|
12
|
+
'CsvFileStore',
|
|
13
|
+
'FlatFileStore',
|
|
14
|
+
'IjsonFileStore',
|
|
15
|
+
'InMemoryStore',
|
|
16
|
+
'NdjsonFileStore',
|
|
17
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from oj_persistence.store.base import AbstractStore
|
|
7
|
+
|
|
8
|
+
_VALID_JOIN_TYPES = {'inner', 'left', 'right', 'outer'}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PersistenceManager:
|
|
12
|
+
"""
|
|
13
|
+
Singleton registry of named AbstractStore instances.
|
|
14
|
+
|
|
15
|
+
One instance per process — all threads share the same registry.
|
|
16
|
+
Thread safety is guaranteed by a class-level lock during construction
|
|
17
|
+
and an instance-level lock for registry mutations.
|
|
18
|
+
|
|
19
|
+
Correct usage — all data operations go through the manager:
|
|
20
|
+
|
|
21
|
+
pm = PersistenceManager()
|
|
22
|
+
pm.get_or_create('users', lambda: InMemoryStore()) # register once
|
|
23
|
+
pm.create('users', 'u1', {'name': 'Alice'})
|
|
24
|
+
pm.read('users', 'u1')
|
|
25
|
+
pm.update('users', 'u1', {'name': 'Bob'})
|
|
26
|
+
pm.upsert('users', 'u1', {'name': 'Charlie'})
|
|
27
|
+
pm.delete('users', 'u1')
|
|
28
|
+
pm.list('users')
|
|
29
|
+
pm.join('users', 'orders', on=lambda u, o: u['id'] == o['user_id'])
|
|
30
|
+
|
|
31
|
+
Stores are thread-safe independently (RWLock), but the correct usage model
|
|
32
|
+
is that threads share only the manager singleton — they never hold a
|
|
33
|
+
reference to a store and call its methods directly.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_instance: Optional['PersistenceManager'] = None
|
|
37
|
+
_init_lock: threading.Lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
def __new__(cls) -> 'PersistenceManager':
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
with cls._init_lock:
|
|
42
|
+
if cls._instance is None:
|
|
43
|
+
instance = super().__new__(cls)
|
|
44
|
+
instance._stores: dict[str, AbstractStore] = {}
|
|
45
|
+
instance._registry_lock = threading.Lock()
|
|
46
|
+
cls._instance = instance
|
|
47
|
+
return cls._instance
|
|
48
|
+
|
|
49
|
+
def get_or_create(self, name: str, factory: Callable[[], AbstractStore]) -> AbstractStore:
|
|
50
|
+
"""
|
|
51
|
+
Return the store registered under name, creating it via factory if absent.
|
|
52
|
+
|
|
53
|
+
The factory is called at most once per name. Subsequent calls with the
|
|
54
|
+
same name return the existing store regardless of the factory provided.
|
|
55
|
+
Thread-safe: factory is never called concurrently for the same name.
|
|
56
|
+
"""
|
|
57
|
+
store = self._stores.get(name)
|
|
58
|
+
if store is None:
|
|
59
|
+
with self._registry_lock:
|
|
60
|
+
store = self._stores.get(name)
|
|
61
|
+
if store is None:
|
|
62
|
+
store = factory()
|
|
63
|
+
self._stores[name] = store
|
|
64
|
+
return store
|
|
65
|
+
|
|
66
|
+
def register(self, name: str, store: AbstractStore) -> None:
|
|
67
|
+
"""Register a store under name, replacing any existing entry."""
|
|
68
|
+
with self._registry_lock:
|
|
69
|
+
self._stores[name] = store
|
|
70
|
+
|
|
71
|
+
def get_store(self, name: str) -> Optional[AbstractStore]:
|
|
72
|
+
"""Return the store registered under name, or None."""
|
|
73
|
+
return self._stores.get(name)
|
|
74
|
+
|
|
75
|
+
def unregister(self, name: str) -> None:
|
|
76
|
+
"""Remove the store registered under name. No-op if not found."""
|
|
77
|
+
with self._registry_lock:
|
|
78
|
+
self._stores.pop(name, None)
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# CRUDL — the primary data interface for callers
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _get_required(self, name: str) -> AbstractStore:
|
|
85
|
+
store = self._stores.get(name)
|
|
86
|
+
if store is None:
|
|
87
|
+
raise KeyError(name)
|
|
88
|
+
return store
|
|
89
|
+
|
|
90
|
+
def create(self, store_name: str, key: str, value: Any) -> None:
|
|
91
|
+
"""Create a new entry in the named store. Raises KeyError if store or key already exists."""
|
|
92
|
+
self._get_required(store_name).create(key, value)
|
|
93
|
+
|
|
94
|
+
def read(self, store_name: str, key: str) -> Any:
|
|
95
|
+
"""Return the value for key from the named store, or None if not found."""
|
|
96
|
+
return self._get_required(store_name).read(key)
|
|
97
|
+
|
|
98
|
+
def update(self, store_name: str, key: str, value: Any) -> None:
|
|
99
|
+
"""Update an existing entry. Raises KeyError if store or key not found."""
|
|
100
|
+
self._get_required(store_name).update(key, value)
|
|
101
|
+
|
|
102
|
+
def upsert(self, store_name: str, key: str, value: Any) -> None:
|
|
103
|
+
"""Create or overwrite an entry in the named store."""
|
|
104
|
+
self._get_required(store_name).upsert(key, value)
|
|
105
|
+
|
|
106
|
+
def delete(self, store_name: str, key: str) -> None:
|
|
107
|
+
"""Remove an entry from the named store. No-op if key not found."""
|
|
108
|
+
self._get_required(store_name).delete(key)
|
|
109
|
+
|
|
110
|
+
def list(self, store_name: str, predicate: Optional[Callable[[Any], bool]] = None) -> list[Any]:
|
|
111
|
+
"""Return all values from the named store, optionally filtered by predicate."""
|
|
112
|
+
return self._get_required(store_name).list(predicate)
|
|
113
|
+
|
|
114
|
+
def join(
|
|
115
|
+
self,
|
|
116
|
+
left: str,
|
|
117
|
+
right: str,
|
|
118
|
+
on: Callable[[Any, Any], bool],
|
|
119
|
+
how: str = 'inner',
|
|
120
|
+
where: Optional[Callable[[Any, Any], bool]] = None,
|
|
121
|
+
) -> list[tuple[Any, Any]]:
|
|
122
|
+
"""
|
|
123
|
+
Relational join across two registered stores.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
left, right : store names (must be registered)
|
|
128
|
+
on : join predicate — called as on(left_val, right_val)
|
|
129
|
+
how : 'inner' | 'left' | 'right' | 'outer'
|
|
130
|
+
where : optional filter applied only to matched pairs (both non-None);
|
|
131
|
+
unmatched rows produced by left/right/outer are always included
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
list of (left_val, right_val) tuples. Unmatched sides are None.
|
|
136
|
+
|
|
137
|
+
Complexity
|
|
138
|
+
----------
|
|
139
|
+
O(m × n) — naive nested loop over store.list() on both sides.
|
|
140
|
+
DB-backed stores (SQLite, Postgres, SQLAlchemy, …) should override
|
|
141
|
+
with a delegated query rather than relying on this implementation.
|
|
142
|
+
"""
|
|
143
|
+
if how not in _VALID_JOIN_TYPES:
|
|
144
|
+
raise ValueError(f"Invalid how='{how}'. Expected one of: {sorted(_VALID_JOIN_TYPES)}")
|
|
145
|
+
|
|
146
|
+
left_vals = self._get_required(left).list()
|
|
147
|
+
right_vals = self._get_required(right).list()
|
|
148
|
+
results: list[tuple[Any, Any]] = []
|
|
149
|
+
|
|
150
|
+
if how == 'inner':
|
|
151
|
+
for lv in left_vals:
|
|
152
|
+
for rv in right_vals:
|
|
153
|
+
if on(lv, rv) and (where is None or where(lv, rv)):
|
|
154
|
+
results.append((lv, rv))
|
|
155
|
+
|
|
156
|
+
elif how == 'left':
|
|
157
|
+
for lv in left_vals:
|
|
158
|
+
has_on_match = False
|
|
159
|
+
for rv in right_vals:
|
|
160
|
+
if on(lv, rv):
|
|
161
|
+
has_on_match = True
|
|
162
|
+
if where is None or where(lv, rv):
|
|
163
|
+
results.append((lv, rv))
|
|
164
|
+
if not has_on_match:
|
|
165
|
+
results.append((lv, None))
|
|
166
|
+
|
|
167
|
+
elif how == 'right':
|
|
168
|
+
for rv in right_vals:
|
|
169
|
+
has_on_match = False
|
|
170
|
+
for lv in left_vals:
|
|
171
|
+
if on(lv, rv):
|
|
172
|
+
has_on_match = True
|
|
173
|
+
if where is None or where(lv, rv):
|
|
174
|
+
results.append((lv, rv))
|
|
175
|
+
if not has_on_match:
|
|
176
|
+
results.append((None, rv))
|
|
177
|
+
|
|
178
|
+
elif how == 'outer':
|
|
179
|
+
matched_right: set[int] = set()
|
|
180
|
+
for lv in left_vals:
|
|
181
|
+
has_on_match = False
|
|
182
|
+
for i, rv in enumerate(right_vals):
|
|
183
|
+
if on(lv, rv):
|
|
184
|
+
has_on_match = True
|
|
185
|
+
matched_right.add(i)
|
|
186
|
+
if where is None or where(lv, rv):
|
|
187
|
+
results.append((lv, rv))
|
|
188
|
+
if not has_on_match:
|
|
189
|
+
results.append((lv, None))
|
|
190
|
+
for i, rv in enumerate(right_vals):
|
|
191
|
+
if i not in matched_right:
|
|
192
|
+
results.append((None, rv))
|
|
193
|
+
|
|
194
|
+
return results
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from oj_persistence.store.base import AbstractStore
|
|
2
|
+
from oj_persistence.store.csv_file import CsvFileStore
|
|
3
|
+
from oj_persistence.store.flat_file import FlatFileStore
|
|
4
|
+
from oj_persistence.store.ijson_file import IjsonFileStore
|
|
5
|
+
from oj_persistence.store.in_memory import InMemoryStore
|
|
6
|
+
from oj_persistence.store.ndjson_file import NdjsonFileStore
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'AbstractStore',
|
|
10
|
+
'CsvFileStore',
|
|
11
|
+
'FlatFileStore',
|
|
12
|
+
'IjsonFileStore',
|
|
13
|
+
'InMemoryStore',
|
|
14
|
+
'NdjsonFileStore',
|
|
15
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Callable, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AbstractStore(ABC):
|
|
6
|
+
"""
|
|
7
|
+
CRUDL interface for all persistence backends.
|
|
8
|
+
|
|
9
|
+
Strict create/update semantics:
|
|
10
|
+
- create() raises KeyError if the key already exists
|
|
11
|
+
- update() raises KeyError if the key does not exist
|
|
12
|
+
- upsert() always succeeds (create or update)
|
|
13
|
+
- read() returns None for missing keys (no raise)
|
|
14
|
+
- delete() is a no-op for missing keys (no raise)
|
|
15
|
+
- list() returns all values, optionally filtered by predicate
|
|
16
|
+
|
|
17
|
+
Implementations must be thread-safe. Async variants are planned for a
|
|
18
|
+
future release to support io_chains integration as an async source or
|
|
19
|
+
subscriber.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def create(self, key: str, value: Any) -> None:
|
|
24
|
+
"""Store value under key. Raises KeyError if key already exists."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def read(self, key: str) -> Any:
|
|
28
|
+
"""Return the value for key, or None if not found."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def update(self, key: str, value: Any) -> None:
|
|
32
|
+
"""Update value for an existing key. Raises KeyError if not found."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def upsert(self, key: str, value: Any) -> None:
|
|
36
|
+
"""Store value under key, creating or overwriting as needed."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def delete(self, key: str) -> None:
|
|
40
|
+
"""Remove the entry for key. No-op if key does not exist."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def list(self, predicate: Optional[Callable[[Any], bool]] = None) -> list[Any]:
|
|
44
|
+
"""Return all values, or only those for which predicate(value) is True."""
|