cinchdb 0.1.14__py3-none-any.whl → 0.1.17__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.
- cinchdb/__init__.py +5 -1
- cinchdb/cli/commands/__init__.py +2 -1
- cinchdb/cli/commands/data.py +350 -0
- cinchdb/cli/commands/index.py +2 -2
- cinchdb/cli/commands/tenant.py +47 -0
- cinchdb/cli/main.py +3 -6
- cinchdb/config.py +4 -13
- cinchdb/core/connection.py +14 -18
- cinchdb/core/database.py +224 -75
- cinchdb/core/maintenance_utils.py +43 -0
- cinchdb/core/path_utils.py +20 -22
- cinchdb/core/tenant_activation.py +216 -0
- cinchdb/infrastructure/metadata_connection_pool.py +0 -1
- cinchdb/infrastructure/metadata_db.py +108 -1
- cinchdb/managers/branch.py +1 -1
- cinchdb/managers/change_applier.py +21 -22
- cinchdb/managers/column.py +1 -1
- cinchdb/managers/data.py +190 -14
- cinchdb/managers/index.py +1 -2
- cinchdb/managers/query.py +0 -1
- cinchdb/managers/table.py +31 -6
- cinchdb/managers/tenant.py +90 -150
- cinchdb/managers/view.py +1 -1
- cinchdb/plugins/__init__.py +16 -0
- cinchdb/plugins/base.py +80 -0
- cinchdb/plugins/decorators.py +49 -0
- cinchdb/plugins/manager.py +210 -0
- {cinchdb-0.1.14.dist-info → cinchdb-0.1.17.dist-info}/METADATA +19 -24
- {cinchdb-0.1.14.dist-info → cinchdb-0.1.17.dist-info}/RECORD +32 -28
- cinchdb/core/maintenance.py +0 -73
- cinchdb/security/__init__.py +0 -1
- cinchdb/security/encryption.py +0 -108
- {cinchdb-0.1.14.dist-info → cinchdb-0.1.17.dist-info}/WHEEL +0 -0
- {cinchdb-0.1.14.dist-info → cinchdb-0.1.17.dist-info}/entry_points.txt +0 -0
- {cinchdb-0.1.14.dist-info → cinchdb-0.1.17.dist-info}/licenses/LICENSE +0 -0
cinchdb/managers/data.py
CHANGED
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|
9
9
|
|
10
10
|
from cinchdb.core.connection import DatabaseConnection
|
11
11
|
from cinchdb.core.path_utils import get_tenant_db_path
|
12
|
-
from cinchdb.core.
|
12
|
+
from cinchdb.core.maintenance_utils import check_maintenance_mode
|
13
13
|
from cinchdb.managers.table import TableManager
|
14
14
|
from cinchdb.managers.query import QueryManager
|
15
15
|
|
@@ -283,8 +283,8 @@ class DataManager:
|
|
283
283
|
conn.rollback()
|
284
284
|
raise
|
285
285
|
|
286
|
-
def
|
287
|
-
"""Delete a single record by ID.
|
286
|
+
def delete_model_by_id(self, model_class: Type[T], record_id: str) -> bool:
|
287
|
+
"""Delete a single record by ID using model class.
|
288
288
|
|
289
289
|
Args:
|
290
290
|
model_class: Pydantic model class representing the table
|
@@ -420,12 +420,13 @@ class DataManager:
|
|
420
420
|
return snake_case
|
421
421
|
|
422
422
|
def _build_where_clause(
|
423
|
-
self, filters: Dict[str, Any]
|
423
|
+
self, filters: Dict[str, Any], operator: str = "AND"
|
424
424
|
) -> tuple[str, Dict[str, Any]]:
|
425
425
|
"""Build WHERE clause and parameters from filters.
|
426
426
|
|
427
427
|
Args:
|
428
428
|
filters: Dictionary of column filters
|
429
|
+
operator: Logical operator to combine conditions - "AND" (default) or "OR"
|
429
430
|
|
430
431
|
Returns:
|
431
432
|
Tuple of (where_clause, parameters)
|
@@ -433,31 +434,34 @@ class DataManager:
|
|
433
434
|
if not filters:
|
434
435
|
return "", {}
|
435
436
|
|
437
|
+
if operator not in ("AND", "OR"):
|
438
|
+
raise ValueError(f"Operator must be 'AND' or 'OR', got: {operator}")
|
439
|
+
|
436
440
|
conditions = []
|
437
441
|
params = {}
|
438
442
|
|
439
443
|
for key, value in filters.items():
|
440
444
|
# Handle special operators (column__operator format)
|
441
445
|
if "__" in key:
|
442
|
-
column,
|
443
|
-
param_key = f"{column}_{
|
446
|
+
column, op = key.split("__", 1)
|
447
|
+
param_key = f"{column}_{op}"
|
444
448
|
|
445
|
-
if
|
449
|
+
if op == "gte":
|
446
450
|
conditions.append(f"{column} >= :{param_key}")
|
447
451
|
params[param_key] = value
|
448
|
-
elif
|
452
|
+
elif op == "lte":
|
449
453
|
conditions.append(f"{column} <= :{param_key}")
|
450
454
|
params[param_key] = value
|
451
|
-
elif
|
455
|
+
elif op == "gt":
|
452
456
|
conditions.append(f"{column} > :{param_key}")
|
453
457
|
params[param_key] = value
|
454
|
-
elif
|
458
|
+
elif op == "lt":
|
455
459
|
conditions.append(f"{column} < :{param_key}")
|
456
460
|
params[param_key] = value
|
457
|
-
elif
|
461
|
+
elif op == "like":
|
458
462
|
conditions.append(f"{column} LIKE :{param_key}")
|
459
463
|
params[param_key] = value
|
460
|
-
elif
|
464
|
+
elif op == "in":
|
461
465
|
if not isinstance(value, (list, tuple)):
|
462
466
|
raise ValueError(
|
463
467
|
f"'in' operator requires list or tuple, got {type(value)}"
|
@@ -467,10 +471,182 @@ class DataManager:
|
|
467
471
|
for i, v in enumerate(value):
|
468
472
|
params[f"{param_key}_{i}"] = v
|
469
473
|
else:
|
470
|
-
raise ValueError(f"Unsupported operator: {
|
474
|
+
raise ValueError(f"Unsupported operator: {op}")
|
471
475
|
else:
|
472
476
|
# Exact match
|
473
477
|
conditions.append(f"{key} = :{key}")
|
474
478
|
params[key] = value
|
475
479
|
|
476
|
-
return "
|
480
|
+
return f" {operator} ".join(conditions), params
|
481
|
+
|
482
|
+
def delete_where(self, table: str, operator: str = "AND", **filters) -> int:
|
483
|
+
"""Delete records from a table based on filter criteria.
|
484
|
+
|
485
|
+
Args:
|
486
|
+
table: Table name
|
487
|
+
operator: Logical operator to combine conditions - "AND" (default) or "OR"
|
488
|
+
**filters: Filter criteria (supports operators like __gt, __lt, __in, __like, __not)
|
489
|
+
Multiple conditions are combined with the specified operator
|
490
|
+
|
491
|
+
Returns:
|
492
|
+
Number of records deleted
|
493
|
+
|
494
|
+
Examples:
|
495
|
+
# Delete records where status = 'inactive'
|
496
|
+
count = dm.delete_where('users', status='inactive')
|
497
|
+
|
498
|
+
# Delete records where status = 'inactive' AND age > 65 (default AND)
|
499
|
+
count = dm.delete_where('users', status='inactive', age__gt=65)
|
500
|
+
|
501
|
+
# Delete records where status = 'inactive' OR age > 65
|
502
|
+
count = dm.delete_where('users', operator='OR', status='inactive', age__gt=65)
|
503
|
+
|
504
|
+
# Delete records where age > 65
|
505
|
+
count = dm.delete_where('users', age__gt=65)
|
506
|
+
|
507
|
+
# Delete records where id in [1, 2, 3]
|
508
|
+
count = dm.delete_where('users', id__in=[1, 2, 3])
|
509
|
+
|
510
|
+
# Delete records where name like 'test%'
|
511
|
+
count = dm.delete_where('users', name__like='test%')
|
512
|
+
"""
|
513
|
+
# Check maintenance mode
|
514
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
515
|
+
|
516
|
+
if not filters:
|
517
|
+
raise ValueError("delete_where requires at least one filter condition")
|
518
|
+
|
519
|
+
# Build WHERE clause
|
520
|
+
where_clause, params = self._build_where_clause(filters, operator)
|
521
|
+
|
522
|
+
sql = f"DELETE FROM {table} WHERE {where_clause}"
|
523
|
+
|
524
|
+
with DatabaseConnection(self.db_path) as conn:
|
525
|
+
cursor = conn.execute(sql, params)
|
526
|
+
conn.commit()
|
527
|
+
return cursor.rowcount
|
528
|
+
|
529
|
+
def update_where(self, table: str, data: Dict[str, Any], operator: str = "AND", **filters) -> int:
|
530
|
+
"""Update records in a table based on filter criteria.
|
531
|
+
|
532
|
+
Args:
|
533
|
+
table: Table name
|
534
|
+
data: Dictionary of column-value pairs to update
|
535
|
+
operator: Logical operator to combine conditions - "AND" (default) or "OR"
|
536
|
+
**filters: Filter criteria (supports operators like __gt, __lt, __in, __like, __not)
|
537
|
+
Multiple conditions are combined with the specified operator
|
538
|
+
|
539
|
+
Returns:
|
540
|
+
Number of records updated
|
541
|
+
|
542
|
+
Examples:
|
543
|
+
# Update status for all users with age > 65
|
544
|
+
count = dm.update_where('users', {'status': 'senior'}, age__gt=65)
|
545
|
+
|
546
|
+
# Update status where age > 65 AND status = 'active' (default AND)
|
547
|
+
count = dm.update_where('users', {'status': 'senior'}, age__gt=65, status='active')
|
548
|
+
|
549
|
+
# Update status where age > 65 OR status = 'pending'
|
550
|
+
count = dm.update_where('users', {'status': 'senior'}, operator='OR', age__gt=65, status='pending')
|
551
|
+
|
552
|
+
# Update multiple fields where id in specific list
|
553
|
+
count = dm.update_where(
|
554
|
+
'users',
|
555
|
+
{'status': 'inactive', 'updated_at': datetime.now()},
|
556
|
+
id__in=[1, 2, 3]
|
557
|
+
)
|
558
|
+
|
559
|
+
# Update where name like pattern
|
560
|
+
count = dm.update_where('users', {'category': 'test'}, name__like='test%')
|
561
|
+
"""
|
562
|
+
# Check maintenance mode
|
563
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
564
|
+
|
565
|
+
if not filters:
|
566
|
+
raise ValueError("update_where requires at least one filter condition")
|
567
|
+
|
568
|
+
if not data:
|
569
|
+
raise ValueError("update_where requires data to update")
|
570
|
+
|
571
|
+
# Build WHERE clause
|
572
|
+
where_clause, where_params = self._build_where_clause(filters, operator)
|
573
|
+
|
574
|
+
# Build SET clause
|
575
|
+
set_clauses = []
|
576
|
+
update_params = {}
|
577
|
+
|
578
|
+
for key, value in data.items():
|
579
|
+
param_key = f"update_{key}"
|
580
|
+
set_clauses.append(f"{key} = :{param_key}")
|
581
|
+
update_params[param_key] = value
|
582
|
+
|
583
|
+
# Combine parameters (update params first to avoid conflicts)
|
584
|
+
all_params = {**update_params, **where_params}
|
585
|
+
|
586
|
+
sql = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE {where_clause}"
|
587
|
+
|
588
|
+
with DatabaseConnection(self.db_path) as conn:
|
589
|
+
cursor = conn.execute(sql, all_params)
|
590
|
+
conn.commit()
|
591
|
+
return cursor.rowcount
|
592
|
+
|
593
|
+
def update_by_id(self, table: str, record_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
594
|
+
"""Update a single record by ID.
|
595
|
+
|
596
|
+
Args:
|
597
|
+
table: Table name
|
598
|
+
record_id: Record ID to update
|
599
|
+
data: Dictionary of column-value pairs to update
|
600
|
+
|
601
|
+
Returns:
|
602
|
+
Updated record
|
603
|
+
"""
|
604
|
+
# Build SET clause
|
605
|
+
set_parts = []
|
606
|
+
params = []
|
607
|
+
|
608
|
+
for key, value in data.items():
|
609
|
+
set_parts.append(f"{key} = ?")
|
610
|
+
params.append(value)
|
611
|
+
|
612
|
+
if not set_parts:
|
613
|
+
raise ValueError("No data provided for update")
|
614
|
+
|
615
|
+
set_clause = ", ".join(set_parts)
|
616
|
+
sql = f"UPDATE {table} SET {set_clause} WHERE id = ?"
|
617
|
+
params.append(record_id)
|
618
|
+
|
619
|
+
with DatabaseConnection(self.db_path) as conn:
|
620
|
+
cursor = conn.execute(sql, params)
|
621
|
+
conn.commit()
|
622
|
+
|
623
|
+
if cursor.rowcount == 0:
|
624
|
+
raise ValueError(f"No record found with id: {record_id}")
|
625
|
+
|
626
|
+
# Return the updated record
|
627
|
+
result = conn.execute(f"SELECT * FROM {table} WHERE id = ?", [record_id])
|
628
|
+
row = result.fetchone()
|
629
|
+
if row:
|
630
|
+
return dict(row)
|
631
|
+
else:
|
632
|
+
raise ValueError(f"Record not found after update: {record_id}")
|
633
|
+
|
634
|
+
def delete_by_id(self, table: str, record_id: str) -> bool:
|
635
|
+
"""Delete a single record by ID using table name.
|
636
|
+
|
637
|
+
This method is used by the high-level database API.
|
638
|
+
|
639
|
+
Args:
|
640
|
+
table: Table name
|
641
|
+
record_id: Record ID to delete
|
642
|
+
|
643
|
+
Returns:
|
644
|
+
True if record was deleted, False if not found
|
645
|
+
"""
|
646
|
+
sql = f"DELETE FROM {table} WHERE id = ?"
|
647
|
+
|
648
|
+
with DatabaseConnection(self.db_path) as conn:
|
649
|
+
cursor = conn.execute(sql, [record_id])
|
650
|
+
conn.commit()
|
651
|
+
return cursor.rowcount > 0
|
652
|
+
|
cinchdb/managers/index.py
CHANGED
@@ -3,7 +3,6 @@
|
|
3
3
|
from pathlib import Path
|
4
4
|
from typing import List, Dict, Any, Optional
|
5
5
|
import sqlite3
|
6
|
-
import json
|
7
6
|
from datetime import datetime, timezone
|
8
7
|
import uuid
|
9
8
|
|
@@ -263,7 +262,7 @@ class IndexManager:
|
|
263
262
|
|
264
263
|
# Get index statistics
|
265
264
|
xinfo_result = conn.execute(f"PRAGMA index_xinfo({index_name})")
|
266
|
-
|
265
|
+
xinfo_result.fetchall()
|
267
266
|
|
268
267
|
return {
|
269
268
|
"name": index_name,
|
cinchdb/managers/query.py
CHANGED
@@ -6,7 +6,6 @@ from typing import List, Dict, Any, Optional, Type, TypeVar, Union
|
|
6
6
|
from pydantic import BaseModel, ValidationError
|
7
7
|
|
8
8
|
from cinchdb.core.connection import DatabaseConnection
|
9
|
-
from cinchdb.core.path_utils import get_tenant_db_path
|
10
9
|
from cinchdb.utils import validate_query_safe
|
11
10
|
from cinchdb.managers.tenant import TenantManager
|
12
11
|
|
cinchdb/managers/table.py
CHANGED
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
|
|
9
9
|
from cinchdb.models import Index
|
10
10
|
from cinchdb.core.connection import DatabaseConnection
|
11
11
|
from cinchdb.core.path_utils import get_tenant_db_path
|
12
|
-
from cinchdb.core.
|
12
|
+
from cinchdb.core.maintenance_utils import check_maintenance_mode
|
13
13
|
from cinchdb.managers.change_tracker import ChangeTracker
|
14
14
|
from cinchdb.managers.tenant import TenantManager
|
15
15
|
|
@@ -19,6 +19,9 @@ class TableManager:
|
|
19
19
|
|
20
20
|
# Protected column names that users cannot use
|
21
21
|
PROTECTED_COLUMNS = {"id", "created_at", "updated_at"}
|
22
|
+
|
23
|
+
# Protected table name prefixes that users cannot use
|
24
|
+
PROTECTED_TABLE_PREFIXES = ("__", "sqlite_")
|
22
25
|
|
23
26
|
def __init__(
|
24
27
|
self, project_root: Path, database: str, branch: str, tenant: str = "main"
|
@@ -48,18 +51,24 @@ class TableManager:
|
|
48
51
|
tables = []
|
49
52
|
|
50
53
|
with DatabaseConnection(self.db_path) as conn:
|
51
|
-
# Get all
|
54
|
+
# Get all tables first, then filter in Python (more reliable than SQL LIKE)
|
52
55
|
cursor = conn.execute(
|
53
56
|
"""
|
54
57
|
SELECT name FROM sqlite_master
|
55
58
|
WHERE type='table'
|
56
|
-
AND name NOT LIKE 'sqlite_%'
|
57
59
|
ORDER BY name
|
58
60
|
"""
|
59
61
|
)
|
60
|
-
|
61
|
-
|
62
|
-
|
62
|
+
|
63
|
+
# Filter out system tables and protected tables using Python
|
64
|
+
all_table_names = [row["name"] for row in cursor.fetchall()]
|
65
|
+
user_table_names = [
|
66
|
+
name for name in all_table_names
|
67
|
+
if not name.startswith('sqlite_') and not name.startswith('__')
|
68
|
+
]
|
69
|
+
|
70
|
+
for table_name in user_table_names:
|
71
|
+
table = self.get_table(table_name)
|
63
72
|
tables.append(table)
|
64
73
|
|
65
74
|
return tables
|
@@ -82,6 +91,14 @@ class TableManager:
|
|
82
91
|
"""
|
83
92
|
# Check maintenance mode
|
84
93
|
check_maintenance_mode(self.project_root, self.database, self.branch)
|
94
|
+
|
95
|
+
# Validate table name doesn't use protected prefixes
|
96
|
+
for prefix in self.PROTECTED_TABLE_PREFIXES:
|
97
|
+
if table_name.startswith(prefix):
|
98
|
+
raise ValueError(
|
99
|
+
f"Table name '{table_name}' is not allowed. "
|
100
|
+
f"Table names cannot start with '{prefix}' as these are reserved for system use."
|
101
|
+
)
|
85
102
|
|
86
103
|
# Validate table doesn't exist
|
87
104
|
if self._table_exists(table_name):
|
@@ -325,6 +342,14 @@ class TableManager:
|
|
325
342
|
"""
|
326
343
|
# Check maintenance mode
|
327
344
|
check_maintenance_mode(self.project_root, self.database, self.branch)
|
345
|
+
|
346
|
+
# Validate target table name doesn't use protected prefixes
|
347
|
+
for prefix in self.PROTECTED_TABLE_PREFIXES:
|
348
|
+
if target_table.startswith(prefix):
|
349
|
+
raise ValueError(
|
350
|
+
f"Table name '{target_table}' is not allowed. "
|
351
|
+
f"Table names cannot start with '{prefix}' as these are reserved for system use."
|
352
|
+
)
|
328
353
|
|
329
354
|
if not self._table_exists(source_table):
|
330
355
|
raise ValueError(f"Source table '{source_table}' does not exist")
|
cinchdb/managers/tenant.py
CHANGED
@@ -12,11 +12,10 @@ from cinchdb.models import Tenant
|
|
12
12
|
from cinchdb.core.path_utils import (
|
13
13
|
get_branch_path,
|
14
14
|
get_tenant_db_path,
|
15
|
-
get_database_path,
|
16
15
|
list_tenants,
|
17
16
|
)
|
18
17
|
from cinchdb.core.connection import DatabaseConnection
|
19
|
-
from cinchdb.core.
|
18
|
+
from cinchdb.core.maintenance_utils import check_maintenance_mode
|
20
19
|
from cinchdb.utils.name_validator import validate_name
|
21
20
|
from cinchdb.infrastructure.metadata_db import MetadataDB
|
22
21
|
from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
|
@@ -279,18 +278,18 @@ class TenantManager:
|
|
279
278
|
with DatabaseConnection(empty_db_path):
|
280
279
|
pass # Just initialize with PRAGMAs
|
281
280
|
|
282
|
-
#
|
281
|
+
# Set reasonable default page size for template
|
283
282
|
# We need to rebuild the database with new page size
|
284
283
|
temp_path = empty_db_path.with_suffix('.tmp')
|
285
284
|
|
286
|
-
# Create new database with
|
285
|
+
# Create new database with 4KB pages (SQLite default, good balance for general use)
|
287
286
|
vacuum_conn = sqlite3.connect(str(empty_db_path))
|
288
287
|
vacuum_conn.isolation_level = None
|
289
|
-
vacuum_conn.execute("PRAGMA page_size =
|
288
|
+
vacuum_conn.execute("PRAGMA page_size = 4096")
|
290
289
|
vacuum_conn.execute(f"VACUUM INTO '{temp_path}'")
|
291
290
|
vacuum_conn.close()
|
292
291
|
|
293
|
-
# Replace original with optimized version
|
292
|
+
# Replace original with default optimized version
|
294
293
|
shutil.move(str(temp_path), str(empty_db_path))
|
295
294
|
|
296
295
|
# Mark as materialized now that the file exists
|
@@ -351,150 +350,6 @@ class TenantManager:
|
|
351
350
|
if shm_path.exists():
|
352
351
|
shm_path.unlink()
|
353
352
|
|
354
|
-
def optimize_all_tenants(self, force: bool = False) -> dict:
|
355
|
-
"""Optimize storage for all materialized tenants in the branch.
|
356
|
-
|
357
|
-
This is designed to be called periodically (e.g., every minute) to:
|
358
|
-
- Reclaim unused space with VACUUM
|
359
|
-
- Adjust page sizes as databases grow
|
360
|
-
- Keep small databases compact
|
361
|
-
|
362
|
-
Args:
|
363
|
-
force: If True, optimize all tenants regardless of size
|
364
|
-
|
365
|
-
Returns:
|
366
|
-
Dictionary with optimization results:
|
367
|
-
- optimized: List of tenant names that were optimized
|
368
|
-
- skipped: List of tenant names that were skipped
|
369
|
-
- errors: List of tuples (tenant_name, error_message)
|
370
|
-
"""
|
371
|
-
results = {
|
372
|
-
"optimized": [],
|
373
|
-
"skipped": [],
|
374
|
-
"errors": []
|
375
|
-
}
|
376
|
-
|
377
|
-
# Ensure initialization
|
378
|
-
self._ensure_initialized()
|
379
|
-
|
380
|
-
if not self.branch_id:
|
381
|
-
return results
|
382
|
-
|
383
|
-
# Get all materialized tenants for this branch
|
384
|
-
tenants = self.metadata_db.list_tenants(self.branch_id, materialized_only=True)
|
385
|
-
|
386
|
-
for tenant in tenants:
|
387
|
-
tenant_name = tenant['name']
|
388
|
-
|
389
|
-
# Skip system tenants unless forced
|
390
|
-
if not force and tenant_name in ["main", self._empty_tenant_name]:
|
391
|
-
results["skipped"].append(tenant_name)
|
392
|
-
continue
|
393
|
-
|
394
|
-
try:
|
395
|
-
optimized = self.optimize_tenant_storage(tenant_name, force=force)
|
396
|
-
if optimized:
|
397
|
-
results["optimized"].append(tenant_name)
|
398
|
-
else:
|
399
|
-
results["skipped"].append(tenant_name)
|
400
|
-
except Exception as e:
|
401
|
-
results["errors"].append((tenant_name, str(e)))
|
402
|
-
|
403
|
-
return results
|
404
|
-
|
405
|
-
def optimize_tenant_storage(self, tenant_name: str, force: bool = False) -> bool:
|
406
|
-
"""Optimize tenant database storage with VACUUM and optional page size adjustment.
|
407
|
-
|
408
|
-
This performs:
|
409
|
-
1. Always: VACUUM to reclaim unused space and defragment
|
410
|
-
2. If needed: Rebuild with optimal page size based on database size
|
411
|
-
|
412
|
-
Args:
|
413
|
-
tenant_name: Name of tenant to optimize
|
414
|
-
force: If True, always perform VACUUM even if page size is optimal
|
415
|
-
|
416
|
-
Returns:
|
417
|
-
True if optimization was performed, False if tenant doesn't exist
|
418
|
-
"""
|
419
|
-
# Ensure initialization
|
420
|
-
self._ensure_initialized()
|
421
|
-
|
422
|
-
if not self.branch_id:
|
423
|
-
return False
|
424
|
-
|
425
|
-
# Skip system tenants
|
426
|
-
if tenant_name in ["main", self._empty_tenant_name]:
|
427
|
-
return False
|
428
|
-
|
429
|
-
# Get tenant info
|
430
|
-
tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
|
431
|
-
if not tenant_info or not tenant_info['materialized']:
|
432
|
-
return False
|
433
|
-
|
434
|
-
db_path = get_tenant_db_path(
|
435
|
-
self.project_root, self.database, self.branch, tenant_name
|
436
|
-
)
|
437
|
-
|
438
|
-
if not db_path.exists():
|
439
|
-
return False
|
440
|
-
|
441
|
-
# Check current page size
|
442
|
-
conn = sqlite3.connect(str(db_path))
|
443
|
-
current_page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
444
|
-
conn.close()
|
445
|
-
|
446
|
-
# Determine optimal page size
|
447
|
-
optimal_page_size = self._get_optimal_page_size(db_path)
|
448
|
-
|
449
|
-
# Decide if we need to rebuild with new page size
|
450
|
-
needs_page_size_change = (current_page_size != optimal_page_size and
|
451
|
-
db_path.stat().st_size > 1024 * 1024) # Only if > 1MB
|
452
|
-
|
453
|
-
if needs_page_size_change:
|
454
|
-
# Rebuild with new page size using VACUUM INTO
|
455
|
-
temp_path = db_path.with_suffix('.tmp')
|
456
|
-
conn = sqlite3.connect(str(db_path))
|
457
|
-
conn.isolation_level = None
|
458
|
-
conn.execute(f"PRAGMA page_size = {optimal_page_size}")
|
459
|
-
conn.execute(f"VACUUM INTO '{temp_path}'")
|
460
|
-
conn.close()
|
461
|
-
|
462
|
-
# Replace original with optimized version
|
463
|
-
shutil.move(str(temp_path), str(db_path))
|
464
|
-
return True
|
465
|
-
elif force or current_page_size == 512:
|
466
|
-
# Just run regular VACUUM to defragment and reclaim space
|
467
|
-
# Always vacuum 512-byte page databases to keep them compact
|
468
|
-
conn = sqlite3.connect(str(db_path))
|
469
|
-
conn.isolation_level = None
|
470
|
-
conn.execute("VACUUM")
|
471
|
-
conn.close()
|
472
|
-
return True
|
473
|
-
|
474
|
-
return False
|
475
|
-
|
476
|
-
def _get_optimal_page_size(self, db_path: Path) -> int:
|
477
|
-
"""Determine optimal page size based on database file size.
|
478
|
-
|
479
|
-
Args:
|
480
|
-
db_path: Path to database file
|
481
|
-
|
482
|
-
Returns:
|
483
|
-
Optimal page size in bytes
|
484
|
-
"""
|
485
|
-
if not db_path.exists():
|
486
|
-
return 512 # Default for new/empty databases
|
487
|
-
|
488
|
-
size_mb = db_path.stat().st_size / (1024 * 1024)
|
489
|
-
|
490
|
-
if size_mb < 0.1: # < 100KB
|
491
|
-
return 512
|
492
|
-
elif size_mb < 10: # < 10MB
|
493
|
-
return 4096 # 4KB - good balance for small-medium DBs
|
494
|
-
elif size_mb < 100: # < 100MB
|
495
|
-
return 8192 # 8KB - better for larger rows
|
496
|
-
else: # >= 100MB
|
497
|
-
return 16384 # 16KB - optimal for bulk operations
|
498
353
|
|
499
354
|
def materialize_tenant(self, tenant_name: str) -> None:
|
500
355
|
"""Materialize a lazy tenant into an actual database file.
|
@@ -899,3 +754,88 @@ class TenantManager:
|
|
899
754
|
"""
|
900
755
|
db_path = self.get_tenant_db_path_for_operation(tenant_name, is_write)
|
901
756
|
return DatabaseConnection(db_path)
|
757
|
+
|
758
|
+
def vacuum_tenant(self, tenant_name: str) -> dict:
|
759
|
+
"""Run VACUUM operation on a specific tenant to reclaim space and optimize performance.
|
760
|
+
|
761
|
+
This performs SQLite's VACUUM command which:
|
762
|
+
- Reclaims space from deleted records
|
763
|
+
- Defragments the database file
|
764
|
+
- Can improve query performance
|
765
|
+
- Rebuilds database statistics
|
766
|
+
|
767
|
+
Args:
|
768
|
+
tenant_name: Name of the tenant to vacuum
|
769
|
+
|
770
|
+
Returns:
|
771
|
+
Dictionary with vacuum results:
|
772
|
+
- success: Whether vacuum completed successfully
|
773
|
+
- tenant: Name of the tenant
|
774
|
+
- size_before: Size in bytes before vacuum
|
775
|
+
- size_after: Size in bytes after vacuum
|
776
|
+
- space_reclaimed: Bytes reclaimed by vacuum
|
777
|
+
- duration_seconds: Time taken for vacuum operation
|
778
|
+
|
779
|
+
Raises:
|
780
|
+
ValueError: If tenant doesn't exist or is not materialized
|
781
|
+
"""
|
782
|
+
import time
|
783
|
+
|
784
|
+
# Ensure initialization
|
785
|
+
self._ensure_initialized()
|
786
|
+
|
787
|
+
# Check if tenant exists
|
788
|
+
if tenant_name != self._empty_tenant_name:
|
789
|
+
if not self.branch_id:
|
790
|
+
raise ValueError(f"Branch '{self.branch}' not found")
|
791
|
+
|
792
|
+
tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
|
793
|
+
if not tenant_info:
|
794
|
+
raise ValueError(f"Tenant '{tenant_name}' does not exist")
|
795
|
+
|
796
|
+
# Check if tenant is materialized
|
797
|
+
if self.is_tenant_lazy(tenant_name):
|
798
|
+
raise ValueError(f"Cannot vacuum lazy tenant '{tenant_name}'. Tenant must be materialized first.")
|
799
|
+
|
800
|
+
# Get database path
|
801
|
+
db_path = self._get_sharded_tenant_db_path(tenant_name)
|
802
|
+
|
803
|
+
if not db_path.exists():
|
804
|
+
raise ValueError(f"Database file for tenant '{tenant_name}' does not exist")
|
805
|
+
|
806
|
+
# Get size before vacuum
|
807
|
+
size_before = db_path.stat().st_size
|
808
|
+
|
809
|
+
# Perform vacuum operation
|
810
|
+
start_time = time.time()
|
811
|
+
success = False
|
812
|
+
error_message = None
|
813
|
+
|
814
|
+
try:
|
815
|
+
with DatabaseConnection(db_path) as conn:
|
816
|
+
# Run VACUUM command
|
817
|
+
conn.execute("VACUUM")
|
818
|
+
success = True
|
819
|
+
except Exception as e:
|
820
|
+
error_message = str(e)
|
821
|
+
|
822
|
+
duration = time.time() - start_time
|
823
|
+
|
824
|
+
# Get size after vacuum
|
825
|
+
size_after = db_path.stat().st_size if db_path.exists() else 0
|
826
|
+
space_reclaimed = max(0, size_before - size_after)
|
827
|
+
|
828
|
+
result = {
|
829
|
+
"success": success,
|
830
|
+
"tenant": tenant_name,
|
831
|
+
"size_before": size_before,
|
832
|
+
"size_after": size_after,
|
833
|
+
"space_reclaimed": space_reclaimed,
|
834
|
+
"space_reclaimed_mb": round(space_reclaimed / (1024 * 1024), 2),
|
835
|
+
"duration_seconds": round(duration, 2)
|
836
|
+
}
|
837
|
+
|
838
|
+
if not success:
|
839
|
+
result["error"] = error_message
|
840
|
+
|
841
|
+
return result
|
cinchdb/managers/view.py
CHANGED
@@ -6,7 +6,7 @@ from typing import List
|
|
6
6
|
from cinchdb.models import View, Change, ChangeType
|
7
7
|
from cinchdb.core.connection import DatabaseConnection
|
8
8
|
from cinchdb.core.path_utils import get_tenant_db_path
|
9
|
-
from cinchdb.core.
|
9
|
+
from cinchdb.core.maintenance_utils import check_maintenance_mode
|
10
10
|
from cinchdb.managers.change_tracker import ChangeTracker
|
11
11
|
|
12
12
|
|
@@ -0,0 +1,16 @@
|
|
1
|
+
"""
|
2
|
+
Simple Plugin System for CinchDB
|
3
|
+
|
4
|
+
Easy-to-use plugin architecture for CinchDB.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from .base import Plugin
|
8
|
+
from .manager import PluginManager
|
9
|
+
from .decorators import database_method, auto_extend
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
"Plugin",
|
13
|
+
"PluginManager",
|
14
|
+
"database_method",
|
15
|
+
"auto_extend",
|
16
|
+
]
|