altcodepro-polydb-python 2.1.0__py3-none-any.whl
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.
- altcodepro_polydb_python-2.1.0.dist-info/METADATA +378 -0
- altcodepro_polydb_python-2.1.0.dist-info/RECORD +51 -0
- altcodepro_polydb_python-2.1.0.dist-info/WHEEL +5 -0
- altcodepro_polydb_python-2.1.0.dist-info/licenses/LICENSE +21 -0
- altcodepro_polydb_python-2.1.0.dist-info/top_level.txt +1 -0
- polydb/__init__.py +64 -0
- polydb/adapters/AzureBlobStorageAdapter.py +77 -0
- polydb/adapters/AzureFileStorageAdapter.py +79 -0
- polydb/adapters/AzureQueueAdapter.py +61 -0
- polydb/adapters/AzureTableStorageAdapter.py +182 -0
- polydb/adapters/DynamoDBAdapter.py +216 -0
- polydb/adapters/EFSAdapter.py +50 -0
- polydb/adapters/FirestoreAdapter.py +193 -0
- polydb/adapters/GCPStorageAdapter.py +81 -0
- polydb/adapters/MongoDBAdapter.py +136 -0
- polydb/adapters/PostgreSQLAdapter.py +453 -0
- polydb/adapters/PubSubAdapter.py +83 -0
- polydb/adapters/S3Adapter.py +86 -0
- polydb/adapters/S3CompatibleAdapter.py +90 -0
- polydb/adapters/SQSAdapter.py +84 -0
- polydb/adapters/VercelKVAdapter.py +327 -0
- polydb/adapters/__init__.py +0 -0
- polydb/advanced_query.py +147 -0
- polydb/audit/AuditStorage.py +136 -0
- polydb/audit/__init__.py +7 -0
- polydb/audit/context.py +53 -0
- polydb/audit/manager.py +47 -0
- polydb/audit/models.py +86 -0
- polydb/base/NoSQLKVAdapter.py +301 -0
- polydb/base/ObjectStorageAdapter.py +42 -0
- polydb/base/QueueAdapter.py +27 -0
- polydb/base/SharedFilesAdapter.py +32 -0
- polydb/base/__init__.py +0 -0
- polydb/batch.py +163 -0
- polydb/cache.py +204 -0
- polydb/databaseFactory.py +748 -0
- polydb/decorators.py +21 -0
- polydb/errors.py +82 -0
- polydb/factory.py +107 -0
- polydb/models.py +39 -0
- polydb/monitoring.py +313 -0
- polydb/multitenancy.py +197 -0
- polydb/py.typed +0 -0
- polydb/query.py +150 -0
- polydb/registry.py +71 -0
- polydb/retry.py +76 -0
- polydb/schema.py +205 -0
- polydb/security.py +458 -0
- polydb/types.py +127 -0
- polydb/utils.py +61 -0
- polydb/validation.py +131 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# src/polydb/audit/AuditStorage.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
|
|
7
|
+
from .models import AuditRecord
|
|
8
|
+
from ..factory import CloudDatabaseFactory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuditStorage:
|
|
12
|
+
"""Audit log with distributed-safe hash chaining"""
|
|
13
|
+
|
|
14
|
+
_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.factory = CloudDatabaseFactory()
|
|
18
|
+
self.sql = self.factory.get_sql()
|
|
19
|
+
self._ensure_table()
|
|
20
|
+
|
|
21
|
+
def _ensure_table(self):
|
|
22
|
+
"""Create audit table if not exists"""
|
|
23
|
+
try:
|
|
24
|
+
schema = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS polydb_audit_log (
|
|
26
|
+
audit_id UUID PRIMARY KEY,
|
|
27
|
+
timestamp TIMESTAMP NOT NULL,
|
|
28
|
+
tenant_id VARCHAR(255),
|
|
29
|
+
actor_id VARCHAR(255),
|
|
30
|
+
roles TEXT[],
|
|
31
|
+
action VARCHAR(50) NOT NULL,
|
|
32
|
+
model VARCHAR(255) NOT NULL,
|
|
33
|
+
entity_id VARCHAR(255),
|
|
34
|
+
storage_type VARCHAR(20) NOT NULL,
|
|
35
|
+
provider VARCHAR(50) NOT NULL,
|
|
36
|
+
success BOOLEAN NOT NULL,
|
|
37
|
+
before JSONB,
|
|
38
|
+
after JSONB,
|
|
39
|
+
changed_fields TEXT[],
|
|
40
|
+
trace_id VARCHAR(255),
|
|
41
|
+
request_id VARCHAR(255),
|
|
42
|
+
ip_address VARCHAR(45),
|
|
43
|
+
user_agent TEXT,
|
|
44
|
+
error TEXT,
|
|
45
|
+
hash VARCHAR(64) NOT NULL,
|
|
46
|
+
previous_hash VARCHAR(64),
|
|
47
|
+
created_at TIMESTAMP DEFAULT NOW()
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_audit_tenant_timestamp
|
|
51
|
+
ON polydb_audit_log(tenant_id, timestamp DESC);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_audit_model_entity
|
|
53
|
+
ON polydb_audit_log(model, entity_id);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_audit_actor
|
|
55
|
+
ON polydb_audit_log(actor_id, timestamp DESC);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
|
|
57
|
+
ON polydb_audit_log(tenant_id, timestamp DESC, previous_hash);
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
self.sql.execute(schema)
|
|
61
|
+
except Exception:
|
|
62
|
+
# Table may already exist
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def get_last_hash(self, tenant_id: Optional[str]) -> Optional[str]:
|
|
66
|
+
"""Get most recent hash with strict ordering (distributed-safe)"""
|
|
67
|
+
with self._lock:
|
|
68
|
+
try:
|
|
69
|
+
from ..query import QueryBuilder, Operator
|
|
70
|
+
|
|
71
|
+
builder = QueryBuilder()
|
|
72
|
+
|
|
73
|
+
if tenant_id is not None:
|
|
74
|
+
builder.where('tenant_id', Operator.EQ, tenant_id)
|
|
75
|
+
|
|
76
|
+
builder.order_by('timestamp', descending=True).take(1)
|
|
77
|
+
|
|
78
|
+
results = self.sql.query_linq('polydb_audit_log', builder)
|
|
79
|
+
|
|
80
|
+
if results and len(results) > 0:
|
|
81
|
+
return results[0].get('hash')
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def persist(self, record: AuditRecord) -> None:
|
|
88
|
+
"""Persist with lock to ensure chain integrity"""
|
|
89
|
+
with self._lock:
|
|
90
|
+
self.sql.insert('polydb_audit_log', {
|
|
91
|
+
'audit_id': record.audit_id,
|
|
92
|
+
'timestamp': record.timestamp,
|
|
93
|
+
'tenant_id': record.tenant_id,
|
|
94
|
+
'actor_id': record.actor_id,
|
|
95
|
+
'roles': record.roles,
|
|
96
|
+
'action': record.action,
|
|
97
|
+
'model': record.model,
|
|
98
|
+
'entity_id': record.entity_id,
|
|
99
|
+
'storage_type': record.storage_type,
|
|
100
|
+
'provider': record.provider,
|
|
101
|
+
'success': record.success,
|
|
102
|
+
'before': record.before,
|
|
103
|
+
'after': record.after,
|
|
104
|
+
'changed_fields': record.changed_fields,
|
|
105
|
+
'trace_id': record.trace_id,
|
|
106
|
+
'request_id': record.request_id,
|
|
107
|
+
'ip_address': record.ip_address,
|
|
108
|
+
'user_agent': record.user_agent,
|
|
109
|
+
'error': record.error,
|
|
110
|
+
'hash': record.hash,
|
|
111
|
+
'previous_hash': record.previous_hash,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
def verify_chain(self, tenant_id: Optional[str] = None) -> bool:
|
|
115
|
+
"""Verify hash chain integrity"""
|
|
116
|
+
from ..query import QueryBuilder, Operator
|
|
117
|
+
|
|
118
|
+
builder = QueryBuilder()
|
|
119
|
+
|
|
120
|
+
if tenant_id is not None:
|
|
121
|
+
builder.where('tenant_id', Operator.EQ, tenant_id)
|
|
122
|
+
|
|
123
|
+
builder.order_by('timestamp', descending=False)
|
|
124
|
+
|
|
125
|
+
records = self.sql.query_linq('polydb_audit_log', builder)
|
|
126
|
+
|
|
127
|
+
if not records:
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
prev_hash = None
|
|
131
|
+
for record in records:
|
|
132
|
+
if record.get('previous_hash') != prev_hash:
|
|
133
|
+
return False
|
|
134
|
+
prev_hash = record.get('hash')
|
|
135
|
+
|
|
136
|
+
return True
|
polydb/audit/__init__.py
ADDED
polydb/audit/context.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# src/polydb/audit/AuditContext.py
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
|
|
5
|
+
class AuditContext:
|
|
6
|
+
"""Context variables for audit trail"""
|
|
7
|
+
|
|
8
|
+
actor_id: ContextVar[Optional[str]] = ContextVar("actor_id", default=None)
|
|
9
|
+
roles: ContextVar[List[str]] = ContextVar("roles", default=[])
|
|
10
|
+
tenant_id: ContextVar[Optional[str]] = ContextVar("tenant_id", default=None)
|
|
11
|
+
trace_id: ContextVar[Optional[str]] = ContextVar("trace_id", default=None)
|
|
12
|
+
request_id: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
|
|
13
|
+
ip_address: ContextVar[Optional[str]] = ContextVar("ip_address", default=None)
|
|
14
|
+
user_agent: ContextVar[Optional[str]] = ContextVar("user_agent", default=None)
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def set(
|
|
18
|
+
cls,
|
|
19
|
+
*,
|
|
20
|
+
actor_id: Optional[str] = None,
|
|
21
|
+
roles: Optional[List[str]] = None,
|
|
22
|
+
tenant_id: Optional[str] = None,
|
|
23
|
+
trace_id: Optional[str] = None,
|
|
24
|
+
request_id: Optional[str] = None,
|
|
25
|
+
ip_address: Optional[str] = None,
|
|
26
|
+
user_agent: Optional[str] = None,
|
|
27
|
+
):
|
|
28
|
+
"""Set audit context for current request"""
|
|
29
|
+
if actor_id is not None:
|
|
30
|
+
cls.actor_id.set(actor_id)
|
|
31
|
+
if roles is not None:
|
|
32
|
+
cls.roles.set(roles)
|
|
33
|
+
if tenant_id is not None:
|
|
34
|
+
cls.tenant_id.set(tenant_id)
|
|
35
|
+
if trace_id is not None:
|
|
36
|
+
cls.trace_id.set(trace_id)
|
|
37
|
+
if request_id is not None:
|
|
38
|
+
cls.request_id.set(request_id)
|
|
39
|
+
if ip_address is not None:
|
|
40
|
+
cls.ip_address.set(ip_address)
|
|
41
|
+
if user_agent is not None:
|
|
42
|
+
cls.user_agent.set(user_agent)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def clear(cls):
|
|
46
|
+
"""Clear all context variables"""
|
|
47
|
+
cls.actor_id.set(None)
|
|
48
|
+
cls.roles.set([])
|
|
49
|
+
cls.tenant_id.set(None)
|
|
50
|
+
cls.trace_id.set(None)
|
|
51
|
+
cls.request_id.set(None)
|
|
52
|
+
cls.ip_address.set(None)
|
|
53
|
+
cls.user_agent.set(None)
|
polydb/audit/manager.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# src/polydb/audit/manager.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional, Dict, Any, List
|
|
5
|
+
|
|
6
|
+
from .models import AuditRecord
|
|
7
|
+
from .AuditStorage import AuditStorage
|
|
8
|
+
from .context import AuditContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuditManager:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.storage = AuditStorage()
|
|
14
|
+
|
|
15
|
+
def record(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
action: str,
|
|
19
|
+
model: str,
|
|
20
|
+
entity_id: Optional[str],
|
|
21
|
+
storage_type: str,
|
|
22
|
+
provider: str,
|
|
23
|
+
success: bool,
|
|
24
|
+
before: Optional[Dict[str, Any]],
|
|
25
|
+
after: Optional[Dict[str, Any]],
|
|
26
|
+
error: Optional[str],
|
|
27
|
+
changed_fields: List[str] | None
|
|
28
|
+
) -> None:
|
|
29
|
+
tenant_id = AuditContext.tenant_id.get()
|
|
30
|
+
previous_hash = self.storage.get_last_hash(tenant_id)
|
|
31
|
+
|
|
32
|
+
record = AuditRecord.create(
|
|
33
|
+
action=action,
|
|
34
|
+
model=model,
|
|
35
|
+
entity_id=entity_id,
|
|
36
|
+
storage_type=storage_type,
|
|
37
|
+
provider=provider,
|
|
38
|
+
success=success,
|
|
39
|
+
before=before,
|
|
40
|
+
after=after,
|
|
41
|
+
changed_fields=changed_fields,
|
|
42
|
+
error=error,
|
|
43
|
+
context=AuditContext,
|
|
44
|
+
previous_hash=previous_hash,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.storage.persist(record)
|
polydb/audit/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# src/polydb/audit/models.py
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import uuid
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AuditRecord:
|
|
13
|
+
audit_id: str
|
|
14
|
+
timestamp: str
|
|
15
|
+
tenant_id: Optional[str]
|
|
16
|
+
actor_id: Optional[str]
|
|
17
|
+
roles: List[str]
|
|
18
|
+
action: str
|
|
19
|
+
model: str
|
|
20
|
+
entity_id: Optional[str]
|
|
21
|
+
storage_type: str
|
|
22
|
+
provider: str
|
|
23
|
+
success: bool
|
|
24
|
+
|
|
25
|
+
before: Optional[Dict[str, Any]]
|
|
26
|
+
after: Optional[Dict[str, Any]]
|
|
27
|
+
changed_fields: Optional[List[str]]
|
|
28
|
+
|
|
29
|
+
trace_id: Optional[str]
|
|
30
|
+
request_id: Optional[str]
|
|
31
|
+
ip_address: Optional[str]
|
|
32
|
+
user_agent: Optional[str]
|
|
33
|
+
|
|
34
|
+
error: Optional[str]
|
|
35
|
+
|
|
36
|
+
hash: Optional[str] = None
|
|
37
|
+
previous_hash: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def create(
|
|
41
|
+
cls,
|
|
42
|
+
*,
|
|
43
|
+
action: str,
|
|
44
|
+
model: str,
|
|
45
|
+
entity_id: Optional[str],
|
|
46
|
+
storage_type: str,
|
|
47
|
+
provider: str,
|
|
48
|
+
success: bool,
|
|
49
|
+
before: Optional[Dict[str, Any]],
|
|
50
|
+
after: Optional[Dict[str, Any]],
|
|
51
|
+
changed_fields: Optional[List[str]],
|
|
52
|
+
error: Optional[str],
|
|
53
|
+
context,
|
|
54
|
+
previous_hash: Optional[str] = None,
|
|
55
|
+
):
|
|
56
|
+
now = datetime.utcnow().isoformat()
|
|
57
|
+
audit_id = str(uuid.uuid4())
|
|
58
|
+
|
|
59
|
+
record = cls(
|
|
60
|
+
audit_id=audit_id,
|
|
61
|
+
timestamp=now,
|
|
62
|
+
tenant_id=context.tenant_id.get(),
|
|
63
|
+
actor_id=context.actor_id.get(),
|
|
64
|
+
roles=context.roles.get(),
|
|
65
|
+
action=action,
|
|
66
|
+
model=model,
|
|
67
|
+
entity_id=entity_id,
|
|
68
|
+
storage_type=storage_type,
|
|
69
|
+
provider=provider,
|
|
70
|
+
success=success,
|
|
71
|
+
before=before,
|
|
72
|
+
after=after,
|
|
73
|
+
changed_fields=changed_fields,
|
|
74
|
+
trace_id=context.trace_id.get(),
|
|
75
|
+
request_id=context.request_id.get(),
|
|
76
|
+
ip_address=context.ip_address.get(),
|
|
77
|
+
user_agent=context.user_agent.get(),
|
|
78
|
+
error=error,
|
|
79
|
+
previous_hash=previous_hash,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
record.hash = hashlib.sha256(
|
|
83
|
+
json.dumps(asdict(record), sort_keys=True).encode()
|
|
84
|
+
).hexdigest()
|
|
85
|
+
|
|
86
|
+
return record
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# src/polydb/adapters/NoSQLKVAdapter.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..errors import NoSQLError, StorageError
|
|
10
|
+
from ..retry import retry
|
|
11
|
+
from ..query import QueryBuilder, Operator
|
|
12
|
+
from ..types import JsonDict, Lookup
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..models import PartitionConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoSQLKVAdapter:
|
|
19
|
+
"""Base with auto-overflow and LINQ support"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, partition_config: Optional[PartitionConfig] = None):
|
|
22
|
+
from ..utils import setup_logger
|
|
23
|
+
self.logger = setup_logger(self.__class__.__name__)
|
|
24
|
+
self.partition_config = partition_config
|
|
25
|
+
self.object_storage = None
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self.max_size = 1024 * 1024 # 1MB
|
|
28
|
+
|
|
29
|
+
def _get_pk_rk(self, model: type, data: JsonDict) -> Tuple[str, str]:
|
|
30
|
+
"""Extract PK/RK from model metadata"""
|
|
31
|
+
meta = getattr(model, '__polydb__', {})
|
|
32
|
+
pk_field = meta.get('pk_field', 'id')
|
|
33
|
+
rk_field = meta.get('rk_field')
|
|
34
|
+
|
|
35
|
+
if self.partition_config:
|
|
36
|
+
try:
|
|
37
|
+
pk = self.partition_config.partition_key_template.format(**data)
|
|
38
|
+
except KeyError:
|
|
39
|
+
pk = f"default_{data.get(pk_field, hashlib.md5(json.dumps(data, sort_keys=True).encode()).hexdigest()[:8])}"
|
|
40
|
+
else:
|
|
41
|
+
pk = str(data.get(pk_field, 'default'))
|
|
42
|
+
|
|
43
|
+
if rk_field and rk_field in data:
|
|
44
|
+
rk = str(data[rk_field])
|
|
45
|
+
elif self.partition_config and self.partition_config.row_key_template:
|
|
46
|
+
try:
|
|
47
|
+
rk = self.partition_config.row_key_template.format(**data)
|
|
48
|
+
except KeyError:
|
|
49
|
+
rk = hashlib.md5(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
|
50
|
+
else:
|
|
51
|
+
rk = data.get('id', hashlib.md5(json.dumps(data, sort_keys=True).encode()).hexdigest())
|
|
52
|
+
|
|
53
|
+
return str(pk), str(rk)
|
|
54
|
+
|
|
55
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
|
|
56
|
+
def _check_overflow(self, data: JsonDict) -> Tuple[JsonDict, Optional[str]]:
|
|
57
|
+
"""Check size and store in blob if needed"""
|
|
58
|
+
data_bytes = json.dumps(data).encode()
|
|
59
|
+
data_size = len(data_bytes)
|
|
60
|
+
|
|
61
|
+
if data_size > self.max_size:
|
|
62
|
+
with self._lock:
|
|
63
|
+
if not self.object_storage:
|
|
64
|
+
from ..factory import CloudDatabaseFactory
|
|
65
|
+
factory = CloudDatabaseFactory()
|
|
66
|
+
self.object_storage = factory.get_object_storage()
|
|
67
|
+
|
|
68
|
+
blob_id = hashlib.md5(data_bytes).hexdigest()
|
|
69
|
+
blob_key = f"overflow/{blob_id}.json"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
self.object_storage.put(blob_key, data_bytes)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise StorageError(f"Overflow storage failed: {str(e)}")
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"_overflow": True,
|
|
78
|
+
"_blob_key": blob_key,
|
|
79
|
+
"_size": data_size,
|
|
80
|
+
"_checksum": blob_id
|
|
81
|
+
}, blob_key
|
|
82
|
+
|
|
83
|
+
return data, None
|
|
84
|
+
|
|
85
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(StorageError,))
|
|
86
|
+
def _retrieve_overflow(self, data: JsonDict) -> JsonDict:
|
|
87
|
+
"""Retrieve from blob if overflow"""
|
|
88
|
+
if not data.get("_overflow"):
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
with self._lock:
|
|
92
|
+
if not self.object_storage:
|
|
93
|
+
from ..factory import CloudDatabaseFactory
|
|
94
|
+
factory = CloudDatabaseFactory()
|
|
95
|
+
self.object_storage = factory.get_object_storage()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
blob_data = self.object_storage.get(data["_blob_key"])
|
|
99
|
+
retrieved = json.loads(blob_data.decode())
|
|
100
|
+
|
|
101
|
+
# Verify checksum
|
|
102
|
+
checksum = hashlib.md5(blob_data).hexdigest()
|
|
103
|
+
if checksum != data.get("_checksum"):
|
|
104
|
+
raise StorageError("Checksum mismatch on overflow retrieval")
|
|
105
|
+
|
|
106
|
+
return retrieved
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise StorageError(f"Overflow retrieval failed: {str(e)}")
|
|
109
|
+
|
|
110
|
+
def _apply_filters(self, results: List[JsonDict], builder: QueryBuilder) -> List[JsonDict]:
|
|
111
|
+
"""Apply filters in-memory for NoSQL"""
|
|
112
|
+
if not builder.filters:
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
filtered = []
|
|
116
|
+
for item in results:
|
|
117
|
+
match = True
|
|
118
|
+
for f in builder.filters:
|
|
119
|
+
value = item.get(f.field)
|
|
120
|
+
|
|
121
|
+
if f.operator == Operator.EQ and value != f.value:
|
|
122
|
+
match = False
|
|
123
|
+
elif f.operator == Operator.NE and value == f.value:
|
|
124
|
+
match = False
|
|
125
|
+
elif f.operator == Operator.GT and not (value and value > f.value):
|
|
126
|
+
match = False
|
|
127
|
+
elif f.operator == Operator.GTE and not (value and value >= f.value):
|
|
128
|
+
match = False
|
|
129
|
+
elif f.operator == Operator.LT and not (value and value < f.value):
|
|
130
|
+
match = False
|
|
131
|
+
elif f.operator == Operator.LTE and not (value and value <= f.value):
|
|
132
|
+
match = False
|
|
133
|
+
elif f.operator == Operator.IN and value not in f.value:
|
|
134
|
+
match = False
|
|
135
|
+
elif f.operator == Operator.NOT_IN and value in f.value:
|
|
136
|
+
match = False
|
|
137
|
+
elif f.operator == Operator.CONTAINS and (not value or f.value not in str(value)):
|
|
138
|
+
match = False
|
|
139
|
+
elif f.operator == Operator.STARTS_WITH and (not value or not str(value).startswith(f.value)):
|
|
140
|
+
match = False
|
|
141
|
+
elif f.operator == Operator.ENDS_WITH and (not value or not str(value).endswith(f.value)):
|
|
142
|
+
match = False
|
|
143
|
+
|
|
144
|
+
if not match:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
if match:
|
|
148
|
+
filtered.append(item)
|
|
149
|
+
|
|
150
|
+
return filtered
|
|
151
|
+
|
|
152
|
+
def _apply_ordering(self, results: List[JsonDict], builder: QueryBuilder) -> List[JsonDict]:
|
|
153
|
+
"""Apply ordering"""
|
|
154
|
+
if not builder.order_by_fields:
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
for field, desc in reversed(builder.order_by_fields):
|
|
158
|
+
results = sorted(
|
|
159
|
+
results,
|
|
160
|
+
key=lambda x: x.get(field, ''),
|
|
161
|
+
reverse=desc
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return results
|
|
165
|
+
|
|
166
|
+
def _apply_pagination(self, results: List[JsonDict], builder: QueryBuilder) -> List[JsonDict]:
|
|
167
|
+
"""Apply skip/take"""
|
|
168
|
+
if builder.skip_count:
|
|
169
|
+
results = results[builder.skip_count:]
|
|
170
|
+
|
|
171
|
+
if builder.take_count:
|
|
172
|
+
results = results[:builder.take_count]
|
|
173
|
+
|
|
174
|
+
return results
|
|
175
|
+
|
|
176
|
+
def _apply_projection(self, results: List[JsonDict], builder: QueryBuilder) -> List[JsonDict]:
|
|
177
|
+
"""Apply field selection"""
|
|
178
|
+
if not builder.select_fields:
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
return [
|
|
182
|
+
{k: v for k, v in item.items() if k in builder.select_fields}
|
|
183
|
+
for item in results
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
# Abstract methods to implement
|
|
187
|
+
def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
def _get_raw(self, model: type, pk: str, rk: str) -> Optional[JsonDict]:
|
|
191
|
+
raise NotImplementedError
|
|
192
|
+
|
|
193
|
+
def _query_raw(self, model: type, filters: Dict[str, Any], limit: Optional[int]) -> List[JsonDict]:
|
|
194
|
+
raise NotImplementedError
|
|
195
|
+
|
|
196
|
+
def _delete_raw(self, model: type, pk: str, rk: str, etag: Optional[str]) -> JsonDict:
|
|
197
|
+
raise NotImplementedError
|
|
198
|
+
|
|
199
|
+
# Protocol implementation
|
|
200
|
+
def put(self, model: type, data: JsonDict) -> JsonDict:
|
|
201
|
+
pk, rk = self._get_pk_rk(model, data)
|
|
202
|
+
store_data, _ = self._check_overflow(data)
|
|
203
|
+
return self._put_raw(model, pk, rk, store_data)
|
|
204
|
+
|
|
205
|
+
def query(
|
|
206
|
+
self,
|
|
207
|
+
model: type,
|
|
208
|
+
query: Optional[Lookup] = None,
|
|
209
|
+
limit: Optional[int] = None,
|
|
210
|
+
no_cache: bool = False,
|
|
211
|
+
cache_ttl: Optional[int] = None
|
|
212
|
+
) -> List[JsonDict]:
|
|
213
|
+
results = self._query_raw(model, query or {}, limit)
|
|
214
|
+
return [self._retrieve_overflow(r) for r in results]
|
|
215
|
+
|
|
216
|
+
def query_page(
|
|
217
|
+
self,
|
|
218
|
+
model: type,
|
|
219
|
+
query: Lookup,
|
|
220
|
+
page_size: int,
|
|
221
|
+
continuation_token: Optional[str] = None
|
|
222
|
+
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
223
|
+
# Basic implementation - override per provider
|
|
224
|
+
offset = int(continuation_token) if continuation_token else 0
|
|
225
|
+
results = self.query(model, query, limit=page_size + 1)
|
|
226
|
+
|
|
227
|
+
has_more = len(results) > page_size
|
|
228
|
+
if has_more:
|
|
229
|
+
results = results[:page_size]
|
|
230
|
+
|
|
231
|
+
next_token = str(offset + page_size) if has_more else None
|
|
232
|
+
return results, next_token
|
|
233
|
+
|
|
234
|
+
def patch(
|
|
235
|
+
self,
|
|
236
|
+
model: type,
|
|
237
|
+
entity_id: Union[Any, Lookup],
|
|
238
|
+
data: JsonDict,
|
|
239
|
+
*,
|
|
240
|
+
etag: Optional[str] = None,
|
|
241
|
+
replace: bool = False
|
|
242
|
+
) -> JsonDict:
|
|
243
|
+
if isinstance(entity_id, dict):
|
|
244
|
+
pk = entity_id.get('partition_key') or entity_id.get('pk')
|
|
245
|
+
rk = entity_id.get('row_key') or entity_id.get('rk') or entity_id.get('id')
|
|
246
|
+
else:
|
|
247
|
+
pk, rk = self._get_pk_rk(model, {'id': entity_id})
|
|
248
|
+
|
|
249
|
+
if not replace:
|
|
250
|
+
existing = self._get_raw(model, pk, rk) # type: ignore
|
|
251
|
+
if existing:
|
|
252
|
+
existing = self._retrieve_overflow(existing)
|
|
253
|
+
existing.update(data)
|
|
254
|
+
data = existing
|
|
255
|
+
|
|
256
|
+
store_data, _ = self._check_overflow(data)
|
|
257
|
+
return self._put_raw(model, pk, rk, store_data) # type: ignore
|
|
258
|
+
|
|
259
|
+
def upsert(self, model: type, data: JsonDict, *, replace: bool = False) -> JsonDict:
|
|
260
|
+
return self.put(model, data)
|
|
261
|
+
|
|
262
|
+
def delete(
|
|
263
|
+
self,
|
|
264
|
+
model: type,
|
|
265
|
+
entity_id: Union[Any, Lookup],
|
|
266
|
+
*,
|
|
267
|
+
etag: Optional[str] = None
|
|
268
|
+
) -> JsonDict:
|
|
269
|
+
if isinstance(entity_id, dict):
|
|
270
|
+
pk = entity_id.get('partition_key') or entity_id.get('pk')
|
|
271
|
+
rk = entity_id.get('row_key') or entity_id.get('rk') or entity_id.get('id')
|
|
272
|
+
else:
|
|
273
|
+
pk, rk = self._get_pk_rk(model, {'id': entity_id})
|
|
274
|
+
|
|
275
|
+
return self._delete_raw(model, pk, rk, etag) # type: ignore
|
|
276
|
+
|
|
277
|
+
def query_linq(self, model: type, builder: QueryBuilder) -> Union[List[JsonDict], int]:
|
|
278
|
+
"""LINQ-style query"""
|
|
279
|
+
results = self._query_raw(model, {}, None)
|
|
280
|
+
results = [self._retrieve_overflow(r) for r in results]
|
|
281
|
+
|
|
282
|
+
results = self._apply_filters(results, builder)
|
|
283
|
+
|
|
284
|
+
if builder.count_only:
|
|
285
|
+
return len(results)
|
|
286
|
+
|
|
287
|
+
results = self._apply_ordering(results, builder)
|
|
288
|
+
results = self._apply_pagination(results, builder)
|
|
289
|
+
results = self._apply_projection(results, builder)
|
|
290
|
+
|
|
291
|
+
if builder.distinct:
|
|
292
|
+
seen = set()
|
|
293
|
+
unique = []
|
|
294
|
+
for r in results:
|
|
295
|
+
key = json.dumps(r, sort_keys=True)
|
|
296
|
+
if key not in seen:
|
|
297
|
+
seen.add(key)
|
|
298
|
+
unique.append(r)
|
|
299
|
+
results = unique
|
|
300
|
+
|
|
301
|
+
return results
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from polydb.utils import setup_logger
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ObjectStorageAdapter(ABC):
|
|
7
|
+
"""Base class for Object Storage with automatic optimization"""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.logger = setup_logger(self.__class__.__name__)
|
|
11
|
+
|
|
12
|
+
def put(
|
|
13
|
+
self, key: str, data: bytes, optimize: bool = True, media_type: Optional[str] = None
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Store object with optional optimization"""
|
|
16
|
+
if optimize and media_type:
|
|
17
|
+
data = self._optimize_media(data, media_type)
|
|
18
|
+
return self._put_raw(key, data)
|
|
19
|
+
|
|
20
|
+
def _optimize_media(self, data: bytes, media_type: str) -> bytes:
|
|
21
|
+
"""Optimize images and videos - placeholder for implementation"""
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def _put_raw(self, key: str, data: bytes) -> str:
|
|
26
|
+
"""Provider-specific put"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get(self, key: str) -> bytes:
|
|
31
|
+
"""Get object"""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def delete(self, key: str) -> bool:
|
|
36
|
+
"""Delete object"""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def list(self, prefix: str = "") -> List[str]:
|
|
41
|
+
"""List objects with prefix"""
|
|
42
|
+
pass
|