python-flashapi 0.1.2__py3-none-any.whl → 0.2.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 (48) hide show
  1. flashapi/__init__.py +8 -7
  2. flashapi/adapters/base.py +13 -12
  3. flashapi/adapters/django.py +771 -165
  4. flashapi/adapters/fastapi.py +879 -293
  5. flashapi/adapters/flask.py +728 -232
  6. flashapi/core/__init__.py +11 -3
  7. flashapi/core/custom_routes.py +5 -5
  8. flashapi/core/pluralize.py +80 -80
  9. flashapi/core/relations.py +59 -61
  10. flashapi/core/response.py +36 -33
  11. flashapi/core/schema.py +145 -83
  12. flashapi/core/visibility.py +46 -0
  13. flashapi/django.py +5 -5
  14. flashapi/docs/openapi.py +298 -223
  15. flashapi/fastapi.py +5 -5
  16. flashapi/features/__init__.py +6 -6
  17. flashapi/features/audit.py +84 -0
  18. flashapi/features/auth.py +134 -0
  19. flashapi/features/dashboard.py +412 -0
  20. flashapi/features/export.py +143 -0
  21. flashapi/features/filtering.py +124 -33
  22. flashapi/features/pagination.py +20 -20
  23. flashapi/features/rate_limit.py +45 -0
  24. flashapi/features/search.py +25 -25
  25. flashapi/features/sorting.py +23 -21
  26. flashapi/features/webhooks.py +84 -0
  27. flashapi/features/websocket.py +129 -0
  28. flashapi/flask.py +5 -5
  29. flashapi/inspectors/__init__.py +3 -3
  30. flashapi/inspectors/base.py +13 -11
  31. flashapi/inspectors/dataclass.py +50 -39
  32. flashapi/inspectors/detect.py +54 -48
  33. flashapi/inspectors/django.py +93 -83
  34. flashapi/inspectors/pydantic.py +99 -84
  35. flashapi/inspectors/sqlalchemy.py +83 -75
  36. flashapi/storage/__init__.py +4 -4
  37. flashapi/storage/auto.py +189 -106
  38. flashapi/storage/base.py +32 -26
  39. flashapi/storage/orm.py +125 -85
  40. flashapi/storage/sqlalchemy.py +56 -13
  41. python_flashapi-0.2.0.dist-info/METADATA +314 -0
  42. python_flashapi-0.2.0.dist-info/RECORD +48 -0
  43. python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
  44. python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
  45. python_flashapi-0.1.2.dist-info/METADATA +0 -259
  46. python_flashapi-0.1.2.dist-info/RECORD +0 -39
  47. python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
  48. {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
@@ -1,84 +1,99 @@
1
- from __future__ import annotations
2
-
3
- from datetime import date, datetime, time
4
- from decimal import Decimal
5
- from typing import Any, get_origin, get_args
6
- from uuid import UUID
7
-
8
- from flashapi.core.schema import FieldSchema, FieldType, ModelSchema
9
- from flashapi.core.pluralize import pluralize
10
- from flashapi.inspectors.base import Inspector
11
-
12
- TYPE_MAP: dict[type, FieldType] = {
13
- str: FieldType.STRING,
14
- int: FieldType.INTEGER,
15
- float: FieldType.FLOAT,
16
- bool: FieldType.BOOLEAN,
17
- datetime: FieldType.DATETIME,
18
- date: FieldType.DATE,
19
- time: FieldType.TIME,
20
- UUID: FieldType.UUID,
21
- Decimal: FieldType.FLOAT,
22
- bytes: FieldType.BINARY,
23
- dict: FieldType.JSON,
24
- list: FieldType.JSON,
25
- }
26
-
27
-
28
- class PydanticInspector(Inspector):
29
- def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
30
- from pydantic import BaseModel
31
-
32
- if not issubclass(model_class, BaseModel):
33
- raise TypeError(f"{model_class} is not a Pydantic model")
34
-
35
- fields: list[FieldSchema] = []
36
- fields.append(FieldSchema(name="id", type=FieldType.INTEGER, required=False, primary_key=True, auto_generated=True))
37
-
38
- for name, field_info in model_class.model_fields.items():
39
- field_type = self._resolve_type(field_info.annotation)
40
- constraints = self._extract_constraints(field_info)
41
- required = field_info.is_required()
42
- default = field_info.default if not required else None
43
-
44
- fields.append(FieldSchema(
45
- name=name,
46
- type=field_type,
47
- required=required,
48
- default=default,
49
- constraints=constraints,
50
- ))
51
-
52
- model_name = model_class.__name__
53
- plural_name = plural or pluralize(model_name)
54
-
55
- return ModelSchema(name=model_name, plural=plural_name, fields=fields)
56
-
57
- def _resolve_type(self, annotation: Any) -> FieldType:
58
- if annotation is None:
59
- return FieldType.STRING
60
-
61
- origin = get_origin(annotation)
62
- if origin is not None:
63
- args = get_args(annotation)
64
- non_none = [a for a in args if a is not type(None)]
65
- if non_none:
66
- annotation = non_none[0]
67
- else:
68
- return FieldType.STRING
69
-
70
- return TYPE_MAP.get(annotation, FieldType.STRING)
71
-
72
- def _extract_constraints(self, field_info: Any) -> dict:
73
- constraints = {}
74
- metadata = getattr(field_info, "metadata", [])
75
- for m in metadata:
76
- if hasattr(m, "max_length"):
77
- constraints["max_length"] = m.max_length
78
- if hasattr(m, "min_length"):
79
- constraints["min_length"] = m.min_length
80
- if hasattr(m, "ge"):
81
- constraints["min_value"] = m.ge
82
- if hasattr(m, "le"):
83
- constraints["max_value"] = m.le
84
- return constraints
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime, time
4
+ from decimal import Decimal
5
+ from typing import Any, get_args, get_origin
6
+ from uuid import UUID
7
+
8
+ from flashapi.core.pluralize import pluralize
9
+ from flashapi.core.schema import FieldSchema, FieldType, ModelSchema
10
+ from flashapi.inspectors.base import Inspector
11
+
12
+ TYPE_MAP: dict[type, FieldType] = {
13
+ str: FieldType.STRING,
14
+ int: FieldType.INTEGER,
15
+ float: FieldType.FLOAT,
16
+ bool: FieldType.BOOLEAN,
17
+ datetime: FieldType.DATETIME,
18
+ date: FieldType.DATE,
19
+ time: FieldType.TIME,
20
+ UUID: FieldType.UUID,
21
+ Decimal: FieldType.FLOAT,
22
+ bytes: FieldType.BINARY,
23
+ dict: FieldType.JSON,
24
+ list: FieldType.JSON,
25
+ }
26
+
27
+
28
+ class PydanticInspector(Inspector):
29
+ def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
30
+ from pydantic import BaseModel
31
+
32
+ if not issubclass(model_class, BaseModel):
33
+ msg = f"{model_class} is not a Pydantic model"
34
+ raise TypeError(msg)
35
+
36
+ fields: list[FieldSchema] = []
37
+ fields.append(FieldSchema(name="id", type=FieldType.INTEGER, required=False, primary_key=True, auto_generated=True))
38
+
39
+ for name, field_info in model_class.model_fields.items():
40
+ field_type = self._resolve_type(field_info.annotation)
41
+ constraints = self._extract_constraints(field_info)
42
+ required = field_info.is_required()
43
+ default = field_info.default if not required else None
44
+ visibility = self._extract_visibility(field_info)
45
+
46
+ fields.append(FieldSchema(
47
+ name=name,
48
+ type=field_type,
49
+ required=required,
50
+ default=default,
51
+ constraints=constraints,
52
+ **visibility,
53
+ ))
54
+
55
+ model_name = model_class.__name__
56
+ plural_name = plural or pluralize(model_name)
57
+
58
+ return ModelSchema(name=model_name, plural=plural_name, fields=fields)
59
+
60
+ def _resolve_type(self, annotation: Any) -> FieldType:
61
+ if annotation is None:
62
+ return FieldType.STRING
63
+
64
+ origin = get_origin(annotation)
65
+ if origin is not None:
66
+ args = get_args(annotation)
67
+ non_none = [a for a in args if a is not type(None)]
68
+ if non_none:
69
+ annotation = non_none[0]
70
+ else:
71
+ return FieldType.STRING
72
+
73
+ return TYPE_MAP.get(annotation, FieldType.STRING)
74
+
75
+ def _extract_constraints(self, field_info: Any) -> dict:
76
+ constraints = {}
77
+ metadata = getattr(field_info, "metadata", [])
78
+ for m in metadata:
79
+ if hasattr(m, "max_length"):
80
+ constraints["max_length"] = m.max_length
81
+ if hasattr(m, "min_length"):
82
+ constraints["min_length"] = m.min_length
83
+ if hasattr(m, "ge"):
84
+ constraints["min_value"] = m.ge
85
+ if hasattr(m, "le"):
86
+ constraints["max_value"] = m.le
87
+ return constraints
88
+
89
+ def _extract_visibility(self, field_info: Any) -> dict:
90
+ visibility = {}
91
+ extra = getattr(field_info, "json_schema_extra", None) or {}
92
+ flash = extra.get("flash", {}) if isinstance(extra, dict) else {}
93
+ for key in ("readonly", "writeonly", "hidden", "export_exclude"):
94
+ if flash.get(key):
95
+ visibility[key] = True
96
+ if flash.get("auto"):
97
+ visibility["auto"] = flash["auto"]
98
+ visibility["auto_generated"] = True
99
+ return visibility
@@ -1,75 +1,83 @@
1
- from __future__ import annotations
2
-
3
- from flashapi.core.schema import FieldSchema, FieldType, ModelSchema, RelationSchema
4
- from flashapi.core.pluralize import pluralize
5
- from flashapi.inspectors.base import Inspector
6
-
7
-
8
- def _map_sa_type(col_type) -> FieldType:
9
- type_name = type(col_type).__name__.upper()
10
- mapping = {
11
- "VARCHAR": FieldType.STRING,
12
- "STRING": FieldType.STRING,
13
- "TEXT": FieldType.TEXT,
14
- "INTEGER": FieldType.INTEGER,
15
- "BIGINTEGER": FieldType.INTEGER,
16
- "SMALLINTEGER": FieldType.INTEGER,
17
- "FLOAT": FieldType.FLOAT,
18
- "NUMERIC": FieldType.FLOAT,
19
- "BOOLEAN": FieldType.BOOLEAN,
20
- "DATE": FieldType.DATE,
21
- "DATETIME": FieldType.DATETIME,
22
- "TIME": FieldType.TIME,
23
- "UUID": FieldType.UUID,
24
- "JSON": FieldType.JSON,
25
- "BLOB": FieldType.BINARY,
26
- "LARGEBINARY": FieldType.BINARY,
27
- }
28
- return mapping.get(type_name, FieldType.STRING)
29
-
30
-
31
- class SQLAlchemyInspector(Inspector):
32
- def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
33
- table = model_class.__table__
34
- fields: list[FieldSchema] = []
35
-
36
- for col in table.columns:
37
- field_type = _map_sa_type(col.type)
38
- constraints = {}
39
-
40
- if hasattr(col.type, "length") and col.type.length:
41
- constraints["max_length"] = col.type.length
42
-
43
- relation = None
44
- if col.foreign_keys:
45
- fk = next(iter(col.foreign_keys))
46
- target_table = fk.column.table.name
47
- relation = RelationSchema(type="many_to_one", target=target_table)
48
-
49
- has_default = col.default is not None or col.server_default is not None
50
- is_auto_int = (
51
- col.primary_key
52
- and col.autoincrement is not False
53
- and field_type == FieldType.INTEGER
54
- )
55
- auto_generated = is_auto_int or (col.primary_key and has_default)
56
-
57
- default_value = None
58
- if col.default and not callable(getattr(col.default, "arg", None)):
59
- default_value = col.default.arg
60
-
61
- fields.append(FieldSchema(
62
- name=col.name,
63
- type=field_type,
64
- required=not col.nullable and not col.primary_key and not has_default,
65
- default=default_value,
66
- constraints=constraints,
67
- primary_key=col.primary_key,
68
- auto_generated=auto_generated,
69
- relation=relation,
70
- ))
71
-
72
- model_name = model_class.__name__
73
- plural_name = plural or pluralize(model_name)
74
-
75
- return ModelSchema(name=model_name, plural=plural_name, fields=fields)
1
+ from __future__ import annotations
2
+
3
+ from flashapi.core.pluralize import pluralize
4
+ from flashapi.core.schema import FieldSchema, FieldType, ModelSchema, RelationSchema
5
+ from flashapi.inspectors.base import Inspector
6
+
7
+
8
+ def _map_sa_type(col_type) -> FieldType:
9
+ type_name = type(col_type).__name__.upper()
10
+ mapping = {
11
+ "VARCHAR": FieldType.STRING,
12
+ "STRING": FieldType.STRING,
13
+ "TEXT": FieldType.TEXT,
14
+ "INTEGER": FieldType.INTEGER,
15
+ "BIGINTEGER": FieldType.INTEGER,
16
+ "SMALLINTEGER": FieldType.INTEGER,
17
+ "FLOAT": FieldType.FLOAT,
18
+ "NUMERIC": FieldType.FLOAT,
19
+ "BOOLEAN": FieldType.BOOLEAN,
20
+ "DATE": FieldType.DATE,
21
+ "DATETIME": FieldType.DATETIME,
22
+ "TIME": FieldType.TIME,
23
+ "UUID": FieldType.UUID,
24
+ "JSON": FieldType.JSON,
25
+ "BLOB": FieldType.BINARY,
26
+ "LARGEBINARY": FieldType.BINARY,
27
+ }
28
+ return mapping.get(type_name, FieldType.STRING)
29
+
30
+
31
+ class SQLAlchemyInspector(Inspector):
32
+ def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
33
+ table = model_class.__table__
34
+ fields: list[FieldSchema] = []
35
+
36
+ for col in table.columns:
37
+ field_type = _map_sa_type(col.type)
38
+ constraints = {}
39
+
40
+ if hasattr(col.type, "length") and col.type.length:
41
+ constraints["max_length"] = col.type.length
42
+
43
+ relation = None
44
+ if col.foreign_keys:
45
+ fk = next(iter(col.foreign_keys))
46
+ target_table = fk.column.table.name
47
+ relation = RelationSchema(type="many_to_one", target=target_table)
48
+
49
+ has_default = col.default is not None or col.server_default is not None
50
+ has_callable_default = col.default is not None and callable(getattr(col.default, "arg", None))
51
+ is_auto_int = (
52
+ col.primary_key
53
+ and col.autoincrement is not False
54
+ and field_type == FieldType.INTEGER
55
+ )
56
+ auto_generated = is_auto_int or (col.primary_key and has_default) or has_callable_default
57
+
58
+ default_value = None
59
+ if col.default and not callable(getattr(col.default, "arg", None)):
60
+ default_value = col.default.arg
61
+
62
+ visibility = {}
63
+ info = getattr(col, "info", {}) or {}
64
+ for key in ("readonly", "writeonly", "hidden", "export_exclude"):
65
+ if info.get(key):
66
+ visibility[key] = True
67
+
68
+ fields.append(FieldSchema(
69
+ name=col.name,
70
+ type=field_type,
71
+ required=not col.nullable and not col.primary_key and not has_default,
72
+ default=default_value,
73
+ constraints=constraints,
74
+ primary_key=col.primary_key,
75
+ auto_generated=auto_generated,
76
+ relation=relation,
77
+ **visibility,
78
+ ))
79
+
80
+ model_name = model_class.__name__
81
+ plural_name = plural or pluralize(model_name)
82
+
83
+ return ModelSchema(name=model_name, plural=plural_name, fields=fields)
@@ -1,4 +1,4 @@
1
- from flashapi.storage.base import Storage
2
- from flashapi.storage.auto import AutoStorage
3
-
4
- __all__ = ["Storage", "AutoStorage"]
1
+ from flashapi.storage.auto import AutoStorage
2
+ from flashapi.storage.base import Storage
3
+
4
+ __all__ = ["AutoStorage", "Storage"]
flashapi/storage/auto.py CHANGED
@@ -1,106 +1,189 @@
1
- from __future__ import annotations
2
-
3
- import sqlite3
4
- from typing import Any
5
-
6
- from flashapi.core.schema import FieldType, ModelSchema
7
- from flashapi.storage.base import Storage
8
-
9
- FIELD_TYPE_TO_SQL = {
10
- FieldType.STRING: "TEXT",
11
- FieldType.INTEGER: "INTEGER",
12
- FieldType.FLOAT: "REAL",
13
- FieldType.BOOLEAN: "INTEGER",
14
- FieldType.DATE: "TEXT",
15
- FieldType.DATETIME: "TEXT",
16
- FieldType.TIME: "TEXT",
17
- FieldType.UUID: "TEXT",
18
- FieldType.JSON: "TEXT",
19
- FieldType.TEXT: "TEXT",
20
- FieldType.BINARY: "BLOB",
21
- }
22
-
23
-
24
- def _validate_identifier(name: str) -> str:
25
- """Validate and quote a SQL identifier to prevent injection."""
26
- import re
27
- if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
28
- raise ValueError(f"Invalid SQL identifier: {name!r}")
29
- return f'"{name}"'
30
-
31
-
32
- class AutoStorage(Storage):
33
- """SQLite-backed automatic storage for Pydantic/dataclass models."""
34
-
35
- def __init__(self, database: str = "flashapi.db"):
36
- self._db_path = database
37
- self._conn = sqlite3.connect(database, check_same_thread=False)
38
- self._conn.row_factory = sqlite3.Row
39
- self._conn.execute("PRAGMA journal_mode=WAL")
40
-
41
- def ensure_table(self, schema: ModelSchema) -> None:
42
- table = _validate_identifier(schema.plural)
43
- columns = []
44
- for field in schema.fields:
45
- col_name = _validate_identifier(field.name)
46
- if field.primary_key:
47
- columns.append(f"{col_name} INTEGER PRIMARY KEY AUTOINCREMENT")
48
- else:
49
- sql_type = FIELD_TYPE_TO_SQL.get(field.type, "TEXT")
50
- not_null = " NOT NULL" if field.required else ""
51
- columns.append(f"{col_name} {sql_type}{not_null}")
52
-
53
- sql = f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(columns)})"
54
- self._conn.execute(sql)
55
- self._conn.commit()
56
-
57
- def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
58
- safe_table = _validate_identifier(table)
59
- columns = [_validate_identifier(k) for k in data.keys()]
60
- placeholders = ", ".join(["?"] * len(columns))
61
- col_names = ", ".join(columns)
62
- values = list(data.values())
63
-
64
- cursor = self._conn.execute(
65
- f"INSERT INTO {safe_table} ({col_names}) VALUES ({placeholders})", values
66
- )
67
- self._conn.commit()
68
-
69
- item_id = cursor.lastrowid
70
- return self.get(table, item_id)
71
-
72
- def get(self, table: str, item_id: int | str) -> dict[str, Any] | None:
73
- safe_table = _validate_identifier(table)
74
- cursor = self._conn.execute(f"SELECT * FROM {safe_table} WHERE id = ?", (item_id,))
75
- row = cursor.fetchone()
76
- if row is None:
77
- return None
78
- return dict(row)
79
-
80
- def list_all(self, table: str) -> list[dict[str, Any]]:
81
- safe_table = _validate_identifier(table)
82
- cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
83
- return [dict(row) for row in cursor.fetchall()]
84
-
85
- def update(self, table: str, item_id: int | str, data: dict[str, Any]) -> dict[str, Any] | None:
86
- if not self.get(table, item_id):
87
- return None
88
-
89
- safe_table = _validate_identifier(table)
90
- set_clause = ", ".join([f"{_validate_identifier(k)} = ?" for k in data.keys()])
91
- values = list(data.values()) + [item_id]
92
-
93
- self._conn.execute(f"UPDATE {safe_table} SET {set_clause} WHERE id = ?", values)
94
- self._conn.commit()
95
- return self.get(table, item_id)
96
-
97
- def delete(self, table: str, item_id: int | str) -> bool:
98
- if not self.get(table, item_id):
99
- return False
100
- safe_table = _validate_identifier(table)
101
- self._conn.execute(f"DELETE FROM {safe_table} WHERE id = ?", (item_id,))
102
- self._conn.commit()
103
- return True
104
-
105
- def close(self) -> None:
106
- self._conn.close()
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from flashapi.core.schema import FieldType, ModelSchema
8
+ from flashapi.storage.base import Storage
9
+
10
+ FIELD_TYPE_TO_SQL = {
11
+ FieldType.STRING: "TEXT",
12
+ FieldType.INTEGER: "INTEGER",
13
+ FieldType.FLOAT: "REAL",
14
+ FieldType.BOOLEAN: "INTEGER",
15
+ FieldType.DATE: "TEXT",
16
+ FieldType.DATETIME: "TEXT",
17
+ FieldType.TIME: "TEXT",
18
+ FieldType.UUID: "TEXT",
19
+ FieldType.JSON: "TEXT",
20
+ FieldType.TEXT: "TEXT",
21
+ FieldType.BINARY: "BLOB",
22
+ }
23
+
24
+
25
+ def _validate_identifier(name: str) -> str:
26
+ """Validate and quote a SQL identifier to prevent injection."""
27
+ import re
28
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
29
+ msg = f"Invalid SQL identifier: {name!r}"
30
+ raise ValueError(msg)
31
+ return f'"{name}"'
32
+
33
+
34
+ class AutoStorage(Storage):
35
+ """SQLite-backed automatic storage with soft-delete support."""
36
+
37
+ def __init__(self, database: str = "flashapi.db") -> None:
38
+ self._db_path = database
39
+ self._conn = sqlite3.connect(database, check_same_thread=False)
40
+ self._conn.row_factory = sqlite3.Row
41
+ self._conn.execute("PRAGMA journal_mode=WAL")
42
+ self._soft_delete_tables: set[str] = set()
43
+ self._auto_fields: dict[str, list[tuple[str, str]]] = {} # table -> [(field_name, auto_type)]
44
+
45
+ def ensure_table(self, schema: ModelSchema, *, soft_delete: bool = True) -> None:
46
+ table = _validate_identifier(schema.plural)
47
+ columns = []
48
+ for field in schema.fields:
49
+ col_name = _validate_identifier(field.name)
50
+ if field.primary_key:
51
+ columns.append(f"{col_name} INTEGER PRIMARY KEY AUTOINCREMENT")
52
+ else:
53
+ sql_type = FIELD_TYPE_TO_SQL.get(field.type, "TEXT")
54
+ not_null = " NOT NULL" if field.required else ""
55
+ columns.append(f"{col_name} {sql_type}{not_null}")
56
+
57
+ if soft_delete:
58
+ columns.append('"deleted_at" TEXT')
59
+ self._soft_delete_tables.add(schema.plural)
60
+
61
+ auto_fields = [(f.name, f.auto) for f in schema.fields if f.auto]
62
+ if auto_fields:
63
+ self._auto_fields[schema.plural] = auto_fields
64
+
65
+ sql = f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(columns)})"
66
+ self._conn.execute(sql)
67
+ self._conn.commit()
68
+
69
+ def _generate_auto_value(self, auto_type: str) -> Any:
70
+ import uuid
71
+ if auto_type == "uuid":
72
+ return str(uuid.uuid4())
73
+ if auto_type == "datetime":
74
+ return datetime.now(timezone.utc).isoformat()
75
+ if auto_type == "date":
76
+ return datetime.now(timezone.utc).date().isoformat()
77
+ return None
78
+
79
+ def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
80
+ data = dict(data)
81
+ for field_name, auto_type in self._auto_fields.get(table, []):
82
+ if field_name not in data:
83
+ data[field_name] = self._generate_auto_value(auto_type)
84
+
85
+ safe_table = _validate_identifier(table)
86
+ columns = [_validate_identifier(k) for k in data]
87
+ placeholders = ", ".join(["?"] * len(columns))
88
+ col_names = ", ".join(columns)
89
+ values = list(data.values())
90
+
91
+ cursor = self._conn.execute(
92
+ f"INSERT INTO {safe_table} ({col_names}) VALUES ({placeholders})", values,
93
+ )
94
+ self._conn.commit()
95
+
96
+ item_id = cursor.lastrowid
97
+ return self._get_raw(table, item_id)
98
+
99
+ def get(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> dict[str, Any] | None:
100
+ row = self._get_raw(table, item_id, lookup_field=lookup_field)
101
+ if row is None:
102
+ return None
103
+ if table in self._soft_delete_tables and row.get("deleted_at"):
104
+ return None
105
+ return self._strip_internal(row, table)
106
+
107
+ def _get_raw(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> dict[str, Any] | None:
108
+ safe_table = _validate_identifier(table)
109
+ safe_field = _validate_identifier(lookup_field)
110
+ cursor = self._conn.execute(f"SELECT * FROM {safe_table} WHERE {safe_field} = ?", (item_id,))
111
+ row = cursor.fetchone()
112
+ if row is None:
113
+ return None
114
+ return dict(row)
115
+
116
+ def list_all(self, table: str, *, include_deleted: bool = False, only_deleted: bool = False) -> list[dict[str, Any]]:
117
+ safe_table = _validate_identifier(table)
118
+ if table in self._soft_delete_tables:
119
+ if only_deleted:
120
+ cursor = self._conn.execute(
121
+ f"SELECT * FROM {safe_table} WHERE deleted_at IS NOT NULL",
122
+ )
123
+ elif not include_deleted:
124
+ cursor = self._conn.execute(
125
+ f"SELECT * FROM {safe_table} WHERE deleted_at IS NULL",
126
+ )
127
+ else:
128
+ cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
129
+ else:
130
+ cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
131
+ return [self._strip_internal(dict(row), table) for row in cursor.fetchall()]
132
+
133
+ def update(self, table: str, item_id: int | str, data: dict[str, Any], *, lookup_field: str = "id") -> dict[str, Any] | None:
134
+ if not self.get(table, item_id, lookup_field=lookup_field):
135
+ return None
136
+
137
+ safe_table = _validate_identifier(table)
138
+ safe_field = _validate_identifier(lookup_field)
139
+ set_clause = ", ".join([f"{_validate_identifier(k)} = ?" for k in data])
140
+ values = [*list(data.values()), item_id]
141
+
142
+ self._conn.execute(f"UPDATE {safe_table} SET {set_clause} WHERE {safe_field} = ?", values)
143
+ self._conn.commit()
144
+ return self.get(table, item_id, lookup_field=lookup_field)
145
+
146
+ def delete(self, table: str, item_id: int | str, *, soft: bool = True, lookup_field: str = "id") -> bool:
147
+ raw = self._get_raw(table, item_id, lookup_field=lookup_field)
148
+ if raw is None:
149
+ return False
150
+ if table in self._soft_delete_tables and raw.get("deleted_at"):
151
+ return False
152
+
153
+ safe_table = _validate_identifier(table)
154
+ safe_field = _validate_identifier(lookup_field)
155
+ if soft and table in self._soft_delete_tables:
156
+ now = datetime.now(timezone.utc).isoformat()
157
+ self._conn.execute(
158
+ f"UPDATE {safe_table} SET deleted_at = ? WHERE {safe_field} = ?", (now, item_id),
159
+ )
160
+ else:
161
+ self._conn.execute(f"DELETE FROM {safe_table} WHERE {safe_field} = ?", (item_id,))
162
+ self._conn.commit()
163
+ return True
164
+
165
+ def restore(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> bool:
166
+ if table not in self._soft_delete_tables:
167
+ return False
168
+ raw = self._get_raw(table, item_id, lookup_field=lookup_field)
169
+ if raw is None or not raw.get("deleted_at"):
170
+ return False
171
+
172
+ safe_table = _validate_identifier(table)
173
+ safe_field = _validate_identifier(lookup_field)
174
+ self._conn.execute(
175
+ f"UPDATE {safe_table} SET deleted_at = NULL WHERE {safe_field} = ?", (item_id,),
176
+ )
177
+ self._conn.commit()
178
+ return True
179
+
180
+ def bulk_create(self, table: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
181
+ return [self.create(table, item) for item in items]
182
+
183
+ def _strip_internal(self, row: dict, table: str) -> dict:
184
+ if table in self._soft_delete_tables:
185
+ row.pop("deleted_at", None)
186
+ return row
187
+
188
+ def close(self) -> None:
189
+ self._conn.close()