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.
Files changed (51) hide show
  1. altcodepro_polydb_python-2.1.0.dist-info/METADATA +378 -0
  2. altcodepro_polydb_python-2.1.0.dist-info/RECORD +51 -0
  3. altcodepro_polydb_python-2.1.0.dist-info/WHEEL +5 -0
  4. altcodepro_polydb_python-2.1.0.dist-info/licenses/LICENSE +21 -0
  5. altcodepro_polydb_python-2.1.0.dist-info/top_level.txt +1 -0
  6. polydb/__init__.py +64 -0
  7. polydb/adapters/AzureBlobStorageAdapter.py +77 -0
  8. polydb/adapters/AzureFileStorageAdapter.py +79 -0
  9. polydb/adapters/AzureQueueAdapter.py +61 -0
  10. polydb/adapters/AzureTableStorageAdapter.py +182 -0
  11. polydb/adapters/DynamoDBAdapter.py +216 -0
  12. polydb/adapters/EFSAdapter.py +50 -0
  13. polydb/adapters/FirestoreAdapter.py +193 -0
  14. polydb/adapters/GCPStorageAdapter.py +81 -0
  15. polydb/adapters/MongoDBAdapter.py +136 -0
  16. polydb/adapters/PostgreSQLAdapter.py +453 -0
  17. polydb/adapters/PubSubAdapter.py +83 -0
  18. polydb/adapters/S3Adapter.py +86 -0
  19. polydb/adapters/S3CompatibleAdapter.py +90 -0
  20. polydb/adapters/SQSAdapter.py +84 -0
  21. polydb/adapters/VercelKVAdapter.py +327 -0
  22. polydb/adapters/__init__.py +0 -0
  23. polydb/advanced_query.py +147 -0
  24. polydb/audit/AuditStorage.py +136 -0
  25. polydb/audit/__init__.py +7 -0
  26. polydb/audit/context.py +53 -0
  27. polydb/audit/manager.py +47 -0
  28. polydb/audit/models.py +86 -0
  29. polydb/base/NoSQLKVAdapter.py +301 -0
  30. polydb/base/ObjectStorageAdapter.py +42 -0
  31. polydb/base/QueueAdapter.py +27 -0
  32. polydb/base/SharedFilesAdapter.py +32 -0
  33. polydb/base/__init__.py +0 -0
  34. polydb/batch.py +163 -0
  35. polydb/cache.py +204 -0
  36. polydb/databaseFactory.py +748 -0
  37. polydb/decorators.py +21 -0
  38. polydb/errors.py +82 -0
  39. polydb/factory.py +107 -0
  40. polydb/models.py +39 -0
  41. polydb/monitoring.py +313 -0
  42. polydb/multitenancy.py +197 -0
  43. polydb/py.typed +0 -0
  44. polydb/query.py +150 -0
  45. polydb/registry.py +71 -0
  46. polydb/retry.py +76 -0
  47. polydb/schema.py +205 -0
  48. polydb/security.py +458 -0
  49. polydb/types.py +127 -0
  50. polydb/utils.py +61 -0
  51. polydb/validation.py +131 -0
polydb/security.py ADDED
@@ -0,0 +1,458 @@
1
+ # src/polydb/security.py
2
+ """
3
+ Security features: encryption, masking, row-level security
4
+ """
5
+ from typing import Dict, Any, List, Optional, Callable, Union
6
+ from dataclasses import dataclass
7
+ import hashlib
8
+ import base64
9
+ import os
10
+ import json
11
+ from functools import wraps
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class EncryptionConfig:
19
+ """Field-level encryption configuration"""
20
+
21
+ encrypted_fields: List[str]
22
+ key: bytes
23
+ algorithm: str = "AES-256-GCM"
24
+
25
+
26
+ class FieldEncryption:
27
+ """Field-level encryption for sensitive data"""
28
+
29
+ def __init__(self, encryption_key: Optional[bytes] = None):
30
+ self.encryption_key = encryption_key or self._generate_key()
31
+
32
+ @staticmethod
33
+ def _generate_key() -> bytes:
34
+ """Generate encryption key from environment or create new"""
35
+ key_str = os.getenv("POLYDB_ENCRYPTION_KEY")
36
+ if key_str:
37
+ return base64.b64decode(key_str)
38
+
39
+ # Generate new key (should be saved securely)
40
+ key = os.urandom(32)
41
+ # For production, log or store this key securely; here we just warn
42
+ logger.warning(
43
+ "Generated new encryption key. Store it securely in POLYDB_ENCRYPTION_KEY env var."
44
+ )
45
+ return key
46
+
47
+ def _encrypt_value(self, value: Any) -> str:
48
+ """Encrypt arbitrary value (serialize if non-str)"""
49
+ if value is None:
50
+ return ""
51
+ data = json.dumps(value) if not isinstance(value, str) else value
52
+ try:
53
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
54
+
55
+ aesgcm = AESGCM(self.encryption_key)
56
+ nonce = os.urandom(12)
57
+
58
+ ciphertext = aesgcm.encrypt(nonce, data.encode("utf-8"), None)
59
+
60
+ # Combine nonce and ciphertext
61
+ encrypted = base64.b64encode(nonce + ciphertext).decode("utf-8")
62
+ return f"encrypted:{encrypted}"
63
+ except ImportError:
64
+ raise ImportError("cryptography not installed. Install with: pip install cryptography")
65
+
66
+ def _decrypt_value(self, encrypted_data: Any) -> Any:
67
+ """Decrypt field data (deserialize if needed)"""
68
+ if not isinstance(encrypted_data, str) or not encrypted_data.startswith("encrypted:"):
69
+ return encrypted_data
70
+
71
+ try:
72
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
73
+
74
+ encrypted_data = encrypted_data[10:] # Remove prefix
75
+ combined = base64.b64decode(encrypted_data)
76
+
77
+ nonce = combined[:12]
78
+ ciphertext = combined[12:]
79
+
80
+ aesgcm = AESGCM(self.encryption_key)
81
+ plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
82
+ plaintext = plaintext_bytes.decode("utf-8")
83
+
84
+ # Attempt to parse as JSON if it looks like JSON
85
+ try:
86
+ return json.loads(plaintext)
87
+ except json.JSONDecodeError:
88
+ return plaintext
89
+ except ImportError:
90
+ raise ImportError("cryptography not installed")
91
+ except Exception as e:
92
+ # On decrypt failure, return masked or original to avoid crashes
93
+ logger.warning(f"Decryption failed: {e}. Returning original value.")
94
+ return encrypted_data
95
+
96
+ def encrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
97
+ """Encrypt specified fields in data dict"""
98
+ result = dict(data)
99
+
100
+ for field in fields:
101
+ if field in result and result[field] is not None:
102
+ result[field] = self._encrypt_value(result[field])
103
+
104
+ return result
105
+
106
+ def decrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
107
+ """Decrypt specified fields in data dict"""
108
+ result = dict(data)
109
+
110
+ for field in fields:
111
+ if field in result and result[field] is not None:
112
+ result[field] = self._decrypt_value(result[field])
113
+
114
+ return result
115
+
116
+
117
+ class DataMasking:
118
+ """Data masking for sensitive information"""
119
+
120
+ def __init__(self):
121
+ # Model-specific masking configs: {model: {field: mask_type}}
122
+ self._configs: Dict[str, Dict[str, str]] = {}
123
+ # Global field-based rules (fallback)
124
+ self._global_rules = {
125
+ "email": self._mask_email,
126
+ "phone": self._mask_phone,
127
+ "ssn": self._mask_ssn,
128
+ "credit_card": self._mask_credit_card,
129
+ "password": lambda x: "[REDACTED]",
130
+ "ssn_*": self._mask_ssn, # Pattern match
131
+ "card_*": self._mask_credit_card,
132
+ }
133
+
134
+ def register_model_config(self, model: str, config: Dict[str, str]):
135
+ """Register masking config for a model: {field: mask_type}"""
136
+ self._configs[model] = config
137
+
138
+ def _infer_mask_type(self, field: str) -> Optional[Callable]:
139
+ """Infer masker based on field name (global fallback)"""
140
+ for pattern, masker in self._global_rules.items():
141
+ if pattern.endswith("_*") and field.startswith(pattern[:-2]):
142
+ return masker
143
+ if pattern == field:
144
+ return masker
145
+ return None
146
+
147
+ @staticmethod
148
+ def _mask_email(email: str) -> str:
149
+ """Mask email address"""
150
+ if "@" not in email:
151
+ return email
152
+
153
+ local, domain = email.split("@", 1)
154
+ if len(local) <= 2:
155
+ masked_local = "*" * len(local)
156
+ else:
157
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
158
+
159
+ return f"{masked_local}@{domain}"
160
+
161
+ @staticmethod
162
+ def _mask_phone(phone: str) -> str:
163
+ """Mask phone number"""
164
+ phone = "".join(filter(str.isdigit, str(phone)))
165
+ if len(phone) <= 4:
166
+ return "*" * len(phone)
167
+ return "*" * (len(phone) - 4) + phone[-4:]
168
+
169
+ @staticmethod
170
+ def _mask_ssn(ssn: str) -> str:
171
+ """Mask SSN"""
172
+ ssn = "".join(filter(str.isdigit, str(ssn)))
173
+ if len(ssn) >= 4:
174
+ return "***-**-" + ssn[-4:]
175
+ return "*" * len(ssn)
176
+
177
+ @staticmethod
178
+ def _mask_credit_card(cc: str) -> str:
179
+ """Mask credit card"""
180
+ cc = "".join(filter(str.isdigit, str(cc).replace("-", "").replace(" ", "")))
181
+ if len(cc) <= 4:
182
+ return "*" * len(cc)
183
+ return "**** **** **** " + cc[-4:]
184
+
185
+ def mask(
186
+ self,
187
+ data: Dict[str, Any],
188
+ model: str,
189
+ actor_id: Optional[str] = None,
190
+ tenant_id: Optional[str] = None,
191
+ ) -> Dict[str, Any]:
192
+ """
193
+ Apply masking to data based on model, actor, and tenant context.
194
+
195
+ Uses model-specific config if available, falls back to global rules.
196
+ Context can be used for dynamic masking (e.g., actor-specific).
197
+ """
198
+ result = dict(data)
199
+ config = self._configs.get(model, {})
200
+
201
+ for field, value in list(result.items()):
202
+ if value is None:
203
+ continue
204
+
205
+ # Model-specific
206
+ mask_type = config.get(field)
207
+ if mask_type:
208
+ masker = self._get_masker(mask_type)
209
+ if masker:
210
+ result[field] = masker(str(value))
211
+ continue
212
+
213
+ # Global inference
214
+ inferred_masker = self._infer_mask_type(field)
215
+ if inferred_masker:
216
+ result[field] = inferred_masker(str(value))
217
+ continue
218
+
219
+ # Context-based dynamic masking (e.g., hide all if not owner)
220
+ if actor_id and tenant_id:
221
+ result = self._apply_context_masking(result, actor_id, tenant_id, model)
222
+
223
+ return result
224
+
225
+ def _get_masker(self, mask_type: str) -> Optional[Callable]:
226
+ """Get masker function by type"""
227
+ maskers = {
228
+ "email": self._mask_email,
229
+ "phone": self._mask_phone,
230
+ "ssn": self._mask_ssn,
231
+ "credit_card": self._mask_credit_card,
232
+ "redact": lambda x: "[REDACTED]",
233
+ }
234
+ return maskers.get(mask_type)
235
+
236
+ def _apply_context_masking(
237
+ self, data: Dict[str, Any], actor_id: str, tenant_id: str, model: str
238
+ ) -> Dict[str, Any]:
239
+ """Apply additional masking based on context (override in subclasses)"""
240
+ # Example: Mask sensitive fields if not tenant owner
241
+ # This is a placeholder; customize per app
242
+ if "owner_id" in data and data["owner_id"] != actor_id:
243
+ for sensitive in ["salary", "health_info"]:
244
+ if sensitive in data:
245
+ data[sensitive] = "[RESTRICTED]"
246
+ return data
247
+
248
+
249
+ @dataclass
250
+ class Policy:
251
+ """RLS Policy entry"""
252
+
253
+ name: str
254
+ func: Callable[[Dict[str, Any], Dict[str, Any]], bool]
255
+ apply_to: str # 'read', 'write', or 'both'
256
+
257
+
258
+ class RowLevelSecurity:
259
+ """Row-level security policies"""
260
+
261
+ def __init__(self):
262
+ self.policies: Dict[str, List[Policy]] = {} # {model: [Policy instances]}
263
+ self._read_filters: Dict[str, Dict[str, Any]] = {} # {model: default_query_filters}
264
+ self._write_filters: Dict[str, Dict[str, Any]] = {} # {model: write_constraints}
265
+
266
+ def add_policy(
267
+ self,
268
+ model: str,
269
+ name: str,
270
+ policy_func: Callable[[Dict[str, Any], Dict[str, Any]], bool],
271
+ apply_to: str = "read", # 'read', 'write', or 'both'
272
+ ):
273
+ """
274
+ Add RLS policy
275
+
276
+ Args:
277
+ model: Model name
278
+ name: Policy name (for logging/debug)
279
+ policy_func: Function(row_or_data, context) -> bool
280
+ apply_to: 'read' (post-filter), 'write' (pre-check), or 'both'
281
+ """
282
+ if model not in self.policies:
283
+ self.policies[model] = []
284
+
285
+ # Ensure unique names
286
+ existing_names = {p.name for p in self.policies[model]}
287
+ if name in existing_names:
288
+ raise ValueError(f"Policy '{name}' already exists for model '{model}'")
289
+
290
+ policy = Policy(name=name, func=policy_func, apply_to=apply_to)
291
+ self.policies[model].append(policy)
292
+
293
+ def _get_context(
294
+ self,
295
+ actor_id: Optional[str] = None,
296
+ tenant_id: Optional[str] = None,
297
+ roles: Optional[List[str]] = None,
298
+ ) -> Dict[str, Any]:
299
+ """Get current security context"""
300
+ return {
301
+ "actor_id": actor_id,
302
+ "tenant_id": tenant_id,
303
+ "roles": roles or [],
304
+ "timestamp": os.getenv("REQUEST_TIMESTAMP", ""), # Optional: from request
305
+ }
306
+
307
+ def check_access(
308
+ self,
309
+ model: str,
310
+ item: Union[Dict[str, Any], Any], # row for read, data for write
311
+ context: Optional[Dict[str, Any]] = None,
312
+ operation: str = "read", # 'read' or 'write'
313
+ ) -> bool:
314
+ """Check if access allowed for item under context and operation"""
315
+ if model not in self.policies:
316
+ return True
317
+
318
+ ctx = context or self._get_context()
319
+ for policy in self.policies[model]:
320
+ if operation in policy.apply_to or policy.apply_to == "both":
321
+ if not policy.func(item, ctx):
322
+ # Log denial (in production, use logger)
323
+ logger.info(f"RLS denied: {policy.name} for {model}:{operation}")
324
+ return False
325
+
326
+ return True
327
+
328
+ def enforce_read(
329
+ self, model: str, query: Dict[str, Any], context: Optional[Dict[str, Any]] = None
330
+ ) -> Dict[str, Any]:
331
+ """
332
+ Enforce RLS on read query: add pre-filters if possible, else return original (post-filter later)
333
+
334
+ For simplicity, adds default filters from _read_filters; complex policies use post-filter.
335
+ """
336
+ result = dict(query or {})
337
+
338
+ # Add static filters (e.g., tenant_id)
339
+ if model in self._read_filters:
340
+ for k, v in self._read_filters[model].items():
341
+ if k not in result:
342
+ result[k] = v
343
+
344
+ # Dynamic: if context provided, inject (e.g., tenant_id)
345
+ ctx = context or self._get_context()
346
+ if "tenant_id" in ctx and ctx["tenant_id"] and "tenant_id" not in result:
347
+ result["tenant_id"] = ctx["tenant_id"]
348
+
349
+ return result
350
+
351
+ def filter_results(
352
+ self, model: str, rows: List[Dict[str, Any]], context: Optional[Dict[str, Any]] = None
353
+ ) -> List[Dict[str, Any]]:
354
+ """Post-query filter based on RLS policies"""
355
+ if model not in self.policies:
356
+ return rows
357
+
358
+ ctx = context or self._get_context()
359
+ return [row for row in rows if self.check_access(model, row, ctx, operation="read")]
360
+
361
+ def enforce_write(
362
+ self, model: str, data: Dict[str, Any], context: Optional[Dict[str, Any]] = None
363
+ ) -> Dict[str, Any]:
364
+ """
365
+ Enforce RLS on write: validate data against policies, inject constraints
366
+ """
367
+ # First, check if write is allowed
368
+ ctx = context or self._get_context()
369
+ if not self.check_access(model, data, ctx, operation="write"):
370
+ raise PermissionError(f"RLS write denied for model '{model}'")
371
+
372
+ result = dict(data)
373
+
374
+ # Inject constraints (e.g., set tenant_id if not present)
375
+ if model in self._write_filters:
376
+ for k, v in self._write_filters[model].items():
377
+ if k not in result:
378
+ result[k] = v
379
+
380
+ # Dynamic injection
381
+ if "tenant_id" in ctx and ctx["tenant_id"] and "tenant_id" not in result:
382
+ result["tenant_id"] = ctx["tenant_id"]
383
+
384
+ return result
385
+
386
+ def set_default_filters(
387
+ self, model: str, read_filters: Dict[str, Any], write_filters: Dict[str, Any]
388
+ ):
389
+ """Set default query filters/constraints for model"""
390
+ if read_filters:
391
+ self._read_filters[model] = read_filters
392
+ if write_filters:
393
+ self._write_filters[model] = write_filters
394
+
395
+
396
+ # Example RLS policies
397
+ def tenant_isolation_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
398
+ """Ensure users only access their tenant's data"""
399
+ tenant_id = context.get("tenant_id")
400
+ if not tenant_id:
401
+ return False
402
+ return item.get("tenant_id") == tenant_id
403
+
404
+
405
+ def role_based_policy(row: Dict[str, Any], context: Dict[str, Any]) -> bool:
406
+ """Check role-based access"""
407
+ required_role = row.get("required_role")
408
+ user_roles = context.get("roles", [])
409
+
410
+ if not required_role:
411
+ return True
412
+
413
+ return required_role in user_roles
414
+
415
+
416
+ def ownership_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
417
+ """Ensure users only access/modify their owned data"""
418
+ actor_id = context.get("actor_id")
419
+ if not actor_id:
420
+ return False
421
+ return item.get("owner_id") == actor_id or item.get("created_by") == actor_id
422
+
423
+
424
+ def sensitivity_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
425
+ """Restrict access to sensitive data based on role"""
426
+ sensitivity_level = item.get("sensitivity_level", "low")
427
+ user_roles = context.get("roles", [])
428
+ if sensitivity_level == "high" and "admin" not in user_roles:
429
+ return False
430
+ if sensitivity_level == "medium" and "admin" not in user_roles and "editor" not in user_roles:
431
+ return False
432
+ return True
433
+
434
+
435
+ def time_based_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
436
+ """Restrict access based on data age (e.g., archive old data)"""
437
+ from datetime import datetime, timedelta
438
+
439
+ created_at = item.get("created_at")
440
+ if not created_at:
441
+ return True
442
+ try:
443
+ created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
444
+ if datetime.now(created_dt.tzinfo) - created_dt > timedelta(days=365):
445
+ return "archivist" in context.get("roles", [])
446
+ except ValueError:
447
+ pass
448
+ return True
449
+
450
+
451
+ # Example usage (in app init):
452
+ # rls = RowLevelSecurity()
453
+ # rls.add_policy('User', 'tenant_isolation', tenant_isolation_policy, apply_to='both')
454
+ # rls.add_policy('User', 'ownership', ownership_policy, apply_to='both')
455
+ # rls.add_policy('User', 'role_based', role_based_policy, apply_to='read')
456
+ # rls.add_policy('Document', 'sensitivity', sensitivity_policy, apply_to='read')
457
+ # rls.add_policy('Log', 'time_based', time_based_policy, apply_to='read')
458
+ # rls.set_default_filters('User', read_filters={'status': 'active'}, write_filters={'status': 'active'})
polydb/types.py ADDED
@@ -0,0 +1,127 @@
1
+ # src/polydb/types.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, List, Optional, Protocol, Tuple, Union, runtime_checkable, Literal
6
+
7
+ from .query import QueryBuilder
8
+
9
+ StorageType = Literal["sql", "nosql"]
10
+
11
+ JsonDict = Dict[str, Any]
12
+ Lookup = Dict[str, Any]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelMeta:
17
+ """Model metadata"""
18
+ storage: StorageType
19
+ table: Optional[str] = None
20
+ collection: Optional[str] = None
21
+ pk_field: Optional[str] = None
22
+ rk_field: Optional[str] = None
23
+ provider: Optional[str] = None
24
+ cache: bool = False
25
+ cache_ttl: Optional[int] = None
26
+
27
+
28
+ @runtime_checkable
29
+ class SQLAdapter(Protocol):
30
+ """SQL adapter contract"""
31
+
32
+ def insert(self, table: str, data: JsonDict) -> JsonDict: ...
33
+
34
+ def select(
35
+ self,
36
+ table: str,
37
+ query: Optional[Lookup] = None,
38
+ limit: Optional[int] = None,
39
+ offset: Optional[int] = None
40
+ ) -> List[JsonDict]: ...
41
+
42
+ def select_page(
43
+ self,
44
+ table: str,
45
+ query: Lookup,
46
+ page_size: int,
47
+ continuation_token: Optional[str] = None
48
+ ) -> Tuple[List[JsonDict], Optional[str]]: ...
49
+
50
+ def update(
51
+ self,
52
+ table: str,
53
+ entity_id: Union[Any, Lookup],
54
+ data: JsonDict
55
+ ) -> JsonDict: ...
56
+
57
+ def upsert(self, table: str, data: JsonDict) -> JsonDict: ...
58
+
59
+ def delete(
60
+ self,
61
+ table: str,
62
+ entity_id: Union[Any, Lookup]
63
+ ) -> JsonDict: ...
64
+
65
+ def query_linq(
66
+ self,
67
+ table: str,
68
+ builder: QueryBuilder
69
+ ) -> Union[List[JsonDict], int]: ...
70
+
71
+ def execute(self, sql: str, params: Optional[List[Any]] = None) -> None: ...
72
+
73
+
74
+ @runtime_checkable
75
+ class NoSQLKVAdapter(Protocol):
76
+ """NoSQL KV adapter contract"""
77
+
78
+ def put(self, model: type, data: JsonDict) -> JsonDict: ...
79
+
80
+ def query(
81
+ self,
82
+ model: type,
83
+ query: Optional[Lookup] = None,
84
+ limit: Optional[int] = None,
85
+ no_cache: bool = False,
86
+ cache_ttl: Optional[int] = None
87
+ ) -> List[JsonDict]: ...
88
+
89
+ def query_page(
90
+ self,
91
+ model: type,
92
+ query: Lookup,
93
+ page_size: int,
94
+ continuation_token: Optional[str] = None
95
+ ) -> Tuple[List[JsonDict], Optional[str]]: ...
96
+
97
+ def patch(
98
+ self,
99
+ model: type,
100
+ entity_id: Union[Any, Lookup],
101
+ data: JsonDict,
102
+ *,
103
+ etag: Optional[str] = None,
104
+ replace: bool = False
105
+ ) -> JsonDict: ...
106
+
107
+ def upsert(
108
+ self,
109
+ model: type,
110
+ data: JsonDict,
111
+ *,
112
+ replace: bool = False
113
+ ) -> JsonDict: ...
114
+
115
+ def delete(
116
+ self,
117
+ model: type,
118
+ entity_id: Union[Any, Lookup],
119
+ *,
120
+ etag: Optional[str] = None
121
+ ) -> JsonDict: ...
122
+
123
+ def query_linq(
124
+ self,
125
+ model: type,
126
+ builder: QueryBuilder
127
+ ) -> Union[List[JsonDict], int]: ...
polydb/utils.py ADDED
@@ -0,0 +1,61 @@
1
+ # src/polydb/utils.py
2
+ """
3
+ Utility functions for validation and logging
4
+ """
5
+
6
+ import re
7
+ import logging
8
+ from typing import Dict, Any
9
+ from .errors import ValidationError
10
+
11
+
12
+ def validate_table_name(table: str) -> str:
13
+ """
14
+ Validate table name to prevent SQL injection
15
+ Only allows alphanumeric, underscore, and hyphen
16
+ """
17
+ if not re.match(r'^[a-zA-Z0-9_-]+$', table):
18
+ raise ValidationError(
19
+ f"Invalid table name: '{table}'. Only alphanumeric, underscore, and hyphen allowed."
20
+ )
21
+ return table
22
+
23
+
24
+ def validate_column_name(column: str) -> str:
25
+ """
26
+ Validate column name to prevent SQL injection
27
+ Only allows alphanumeric and underscore
28
+ """
29
+ if not re.match(r'^[a-zA-Z0-9_]+$', column):
30
+ raise ValidationError(
31
+ f"Invalid column name: '{column}'. Only alphanumeric and underscore allowed."
32
+ )
33
+ return column
34
+
35
+
36
+ def validate_columns(data: Dict[str, Any]) -> Dict[str, Any]:
37
+ """
38
+ Validate all column names in data dictionary
39
+ """
40
+ for key in data.keys():
41
+ validate_column_name(key)
42
+ return data
43
+
44
+
45
+ def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
46
+ """Setup logger with consistent format"""
47
+ logger = logging.getLogger(name)
48
+ logger.setLevel(level)
49
+
50
+ # Clear existing handlers to avoid duplication in multiprocess scenarios
51
+ if logger.hasHandlers():
52
+ logger.handlers.clear()
53
+
54
+ handler = logging.StreamHandler()
55
+ formatter = logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
57
+ )
58
+ handler.setFormatter(formatter)
59
+ logger.addHandler(handler)
60
+
61
+ return logger