flowmaticdb 1.0.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 (73) hide show
  1. flowmaticdb/__init__.py +27 -0
  2. flowmaticdb/_helpers.py +48 -0
  3. flowmaticdb/_query_with_params.py +67 -0
  4. flowmaticdb/adapters/__init__.py +11 -0
  5. flowmaticdb/adapters/_base.py +98 -0
  6. flowmaticdb/adapters/_mysql.py +190 -0
  7. flowmaticdb/adapters/_postgres.py +162 -0
  8. flowmaticdb/adapters/_sqlite.py +165 -0
  9. flowmaticdb/database/__init__.py +7 -0
  10. flowmaticdb/database/_abc.py +128 -0
  11. flowmaticdb/database/_database.py +112 -0
  12. flowmaticdb/database/_db.py +7 -0
  13. flowmaticdb/database/_table.py +106 -0
  14. flowmaticdb/dialects/__init__.py +13 -0
  15. flowmaticdb/dialects/_base.py +178 -0
  16. flowmaticdb/dialects/_mysql.py +266 -0
  17. flowmaticdb/dialects/_postgres.py +137 -0
  18. flowmaticdb/dialects/_sql_dialect.py +739 -0
  19. flowmaticdb/dialects/_sqlite.py +178 -0
  20. flowmaticdb/exceptions.py +17 -0
  21. flowmaticdb/migrations/__init__.py +7 -0
  22. flowmaticdb/migrations/_loader.py +62 -0
  23. flowmaticdb/migrations/_migration_abc.py +23 -0
  24. flowmaticdb/migrations/_migrator.py +102 -0
  25. flowmaticdb/migrations/_template.py +34 -0
  26. flowmaticdb/query/__init__.py +19 -0
  27. flowmaticdb/query/_alter_table.py +36 -0
  28. flowmaticdb/query/_condition.py +16 -0
  29. flowmaticdb/query/_condition_group.py +176 -0
  30. flowmaticdb/query/_condition_mixin.py +135 -0
  31. flowmaticdb/query/_create_table.py +35 -0
  32. flowmaticdb/query/_ddl_mixins.py +313 -0
  33. flowmaticdb/query/_delete.py +29 -0
  34. flowmaticdb/query/_drop_table.py +34 -0
  35. flowmaticdb/query/_having_mixin.py +257 -0
  36. flowmaticdb/query/_insert.py +29 -0
  37. flowmaticdb/query/_join.py +269 -0
  38. flowmaticdb/query/_joins_mixin.py +60 -0
  39. flowmaticdb/query/_on_conflict.py +10 -0
  40. flowmaticdb/query/_order_by.py +11 -0
  41. flowmaticdb/query/_query.py +70 -0
  42. flowmaticdb/query/_select.py +68 -0
  43. flowmaticdb/query/_simple_mixins.py +142 -0
  44. flowmaticdb/query/_union.py +15 -0
  45. flowmaticdb/query/_update.py +26 -0
  46. flowmaticdb/query/_where_mixin.py +257 -0
  47. flowmaticdb/query/enums/__init__.py +17 -0
  48. flowmaticdb/query/enums/_chain.py +6 -0
  49. flowmaticdb/query/enums/_condition.py +23 -0
  50. flowmaticdb/query/enums/_join.py +10 -0
  51. flowmaticdb/query/enums/_order_by_dir.py +6 -0
  52. flowmaticdb/query/enums/_referential_action.py +10 -0
  53. flowmaticdb/query/enums/_type.py +9 -0
  54. flowmaticdb/query/enums/_union.py +6 -0
  55. flowmaticdb/query/expressions/__init__.py +20 -0
  56. flowmaticdb/query/expressions/_alias.py +33 -0
  57. flowmaticdb/query/expressions/_current_timestamp.py +19 -0
  58. flowmaticdb/query/expressions/_excluded.py +8 -0
  59. flowmaticdb/query/expressions/_expression.py +25 -0
  60. flowmaticdb/query/expressions/_identifier.py +22 -0
  61. flowmaticdb/query/expressions/_raw.py +22 -0
  62. flowmaticdb/query/expressions/_sql.py +19 -0
  63. flowmaticdb/query/expressions/_sub_query.py +27 -0
  64. flowmaticdb/result/__init__.py +14 -0
  65. flowmaticdb/result/_base.py +41 -0
  66. flowmaticdb/result/_mysql.py +71 -0
  67. flowmaticdb/result/_postgres.py +152 -0
  68. flowmaticdb/result/_result.py +29 -0
  69. flowmaticdb/result/_sqlite.py +51 -0
  70. flowmaticdb-1.0.0.dist-info/METADATA +1024 -0
  71. flowmaticdb-1.0.0.dist-info/RECORD +73 -0
  72. flowmaticdb-1.0.0.dist-info/WHEEL +5 -0
  73. flowmaticdb-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,27 @@
1
+ from flowmaticdb._helpers import (
2
+ alias,
3
+ current_timestamp,
4
+ expression,
5
+ identifier,
6
+ now,
7
+ raw,
8
+ sub_query,
9
+ )
10
+ from flowmaticdb._query_with_params import QueryWithParams
11
+ from flowmaticdb.exceptions import AdapterError, DatabaseError, DriverError, QueryError, QueryWithParamsError
12
+
13
+ __all__ = [
14
+ "AdapterError",
15
+ "DatabaseError",
16
+ "DriverError",
17
+ "QueryError",
18
+ "QueryWithParams",
19
+ "QueryWithParamsError",
20
+ "alias",
21
+ "current_timestamp",
22
+ "expression",
23
+ "identifier",
24
+ "now",
25
+ "raw",
26
+ "sub_query",
27
+ ]
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from flowmaticdb.query import SelectQuery
7
+ from flowmaticdb.query.expressions._alias import Alias
8
+ from flowmaticdb.query.expressions._current_timestamp import CurrentTimestamp
9
+ from flowmaticdb.query.expressions._expression import Expression
10
+ from flowmaticdb.query.expressions._identifier import Identifier
11
+ from flowmaticdb.query.expressions._raw import Raw
12
+ from flowmaticdb.query.expressions._sub_query import SubQuery
13
+
14
+
15
+ def escape_ansi(string: str, chars: str) -> str:
16
+ return string.translate(str.maketrans(chars, chars * 2))
17
+
18
+
19
+ def escape_backslash(string: str, chars: str) -> str:
20
+ return string.translate(str.maketrans(chars, "\\" + chars))
21
+
22
+
23
+ def raw(sql: str) -> Raw:
24
+ return Raw(sql)
25
+
26
+
27
+ def identifier(identifier: str | list[str]) -> Identifier:
28
+ return Identifier(identifier)
29
+
30
+
31
+ def alias(identifier: str | list[str] | Any, alias: str) -> Alias:
32
+ return Alias(identifier, alias)
33
+
34
+
35
+ def expression(sql: str, params: list[Any] | None = None) -> Expression:
36
+ return Expression(sql, params)
37
+
38
+
39
+ def sub_query(query: SelectQuery, alias: str) -> SubQuery:
40
+ return SubQuery(query, alias)
41
+
42
+
43
+ def current_timestamp() -> CurrentTimestamp:
44
+ return CurrentTimestamp()
45
+
46
+
47
+ def now() -> datetime:
48
+ return datetime.now(UTC)
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from flowmaticdb.dialects._base import DialectABC
9
+
10
+ REGEX_PATTERN = re.compile(
11
+ r"""(?x)
12
+ (?:
13
+ '(?:\\.|[^\\'])*'
14
+ |"(?:\\.|[^\\"])*"
15
+ |`(?:\\.|[^\\`])*`
16
+ |\[(?:\\.|[^\[\]])*?\]
17
+ |--[^\r\n]*
18
+ |/\*[\s\S]*?\*/
19
+ |\#.*
20
+ )
21
+ |
22
+ (\?)
23
+ |
24
+ ((?<!%)%s)
25
+ """,
26
+ re.MULTILINE,
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class QueryWithParams:
32
+ query: str
33
+ params: list[Any] = field(default_factory=list)
34
+
35
+ def percent_s_to_question_marks(self) -> QueryWithParams:
36
+ def _replacer(match: re.Match[str]) -> str:
37
+ if match.group(2) is not None:
38
+ return "?"
39
+ return match.group(0)
40
+
41
+ query = REGEX_PATTERN.sub(_replacer, self.query)
42
+ return QueryWithParams(query=query, params=list(self.params))
43
+
44
+ def question_marks_to_percent_s(self) -> QueryWithParams:
45
+ def _replacer(match: re.Match[str]) -> str:
46
+ if match.group(1) is not None:
47
+ return "%s"
48
+ return match.group(0)
49
+
50
+ query = REGEX_PATTERN.sub(_replacer, self.query)
51
+ return QueryWithParams(query=query, params=list(self.params))
52
+
53
+ def to_sql(self, dialect: DialectABC) -> str:
54
+ param_idx = 0
55
+
56
+ def _replacer(match: re.Match[str]) -> str:
57
+ nonlocal param_idx
58
+ if match.group(1) is not None or match.group(2) is not None:
59
+ if param_idx < len(self.params):
60
+ value = self.params[param_idx]
61
+ casted = dialect.cast_to_query(value)
62
+ param_idx += 1
63
+ return casted
64
+ return "?"
65
+ return match.group(0)
66
+
67
+ return REGEX_PATTERN.sub(_replacer, self.query)
@@ -0,0 +1,11 @@
1
+ from flowmaticdb.adapters._base import AdapterABC
2
+ from flowmaticdb.adapters._mysql import MySQLAdapter
3
+ from flowmaticdb.adapters._postgres import PsycopgAdapter
4
+ from flowmaticdb.adapters._sqlite import SQLiteAdapter
5
+
6
+ __all__ = [
7
+ "AdapterABC",
8
+ "MySQLAdapter",
9
+ "PsycopgAdapter",
10
+ "SQLiteAdapter",
11
+ ]
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from flowmaticdb.result._base import ResultABC
8
+
9
+ if TYPE_CHECKING:
10
+ from flowmaticdb._query_with_params import QueryWithParams
11
+ from flowmaticdb.dialects._base import DialectABC
12
+
13
+
14
+ class AdapterABC(ABC):
15
+ def __init__(
16
+ self,
17
+ driver_name: str,
18
+ database_name: str,
19
+ startup_queries: list[str] | None = None,
20
+ options: dict[str, Any] | None = None,
21
+ debug_callback: Callable[[str, float, str | None], None] | None = None,
22
+ ) -> None:
23
+ self._driver_name = driver_name
24
+ self._database_name = database_name
25
+ self._startup_queries = startup_queries or []
26
+ self._options = options or {}
27
+ self._debug_callback = debug_callback
28
+ self._in_transaction = False
29
+
30
+ def _exec_startup_queries(self) -> None:
31
+ for query in self._startup_queries:
32
+ self.exec(query)
33
+
34
+ def _debug(self, sql: str, duration: float, error: str | None = None) -> None:
35
+ if self._debug_callback is not None:
36
+ self._debug_callback(sql, duration, error)
37
+
38
+ @property
39
+ def driver_name(self) -> str:
40
+ return self._driver_name
41
+
42
+ @property
43
+ def database_name(self) -> str:
44
+ return self._database_name
45
+
46
+ @abstractmethod
47
+ def version(self) -> str:
48
+ ...
49
+
50
+ @abstractmethod
51
+ def exec(self, query: str) -> None:
52
+ ...
53
+
54
+ @abstractmethod
55
+ def query(self, query: str) -> ResultABC:
56
+ ...
57
+
58
+ @abstractmethod
59
+ def query_with_params(
60
+ self,
61
+ dialect: DialectABC,
62
+ query_with_params: QueryWithParams,
63
+ emulate_prepare: bool = False,
64
+ ) -> ResultABC:
65
+ ...
66
+
67
+ @abstractmethod
68
+ def begin_transaction(self) -> None:
69
+ ...
70
+
71
+ @abstractmethod
72
+ def commit_transaction(self) -> None:
73
+ ...
74
+
75
+ @abstractmethod
76
+ def rollback_transaction(self) -> None:
77
+ ...
78
+
79
+ @abstractmethod
80
+ def begin_savepoint(self, name: str) -> None:
81
+ ...
82
+
83
+ @abstractmethod
84
+ def commit_savepoint(self, name: str) -> None:
85
+ ...
86
+
87
+ @abstractmethod
88
+ def rollback_savepoint(self, name: str) -> None:
89
+ ...
90
+
91
+ @property
92
+ @abstractmethod
93
+ def in_transaction(self) -> bool:
94
+ ...
95
+
96
+ @abstractmethod
97
+ def last_insert_id(self, name: str | None = None) -> int | str | None:
98
+ ...
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from flowmaticdb.adapters._base import AdapterABC
8
+ from flowmaticdb.result._base import ResultABC
9
+ from flowmaticdb.result._mysql import MySQLResult
10
+
11
+ if TYPE_CHECKING:
12
+ from flowmaticdb._query_with_params import QueryWithParams
13
+ from flowmaticdb.dialects._base import DialectABC
14
+
15
+
16
+ class MySQLAdapter(AdapterABC):
17
+ def __init__(
18
+ self,
19
+ database_name: str,
20
+ startup_queries: list[str] | None = None,
21
+ options: dict[str, Any] | None = None,
22
+ debug_callback: Callable[[str, float, str | None], None] | None = None,
23
+ host: str = "localhost",
24
+ port: int = 3306,
25
+ user: str = "root",
26
+ password: str = "",
27
+ ) -> None:
28
+ super().__init__(
29
+ driver_name="mysql",
30
+ database_name=database_name,
31
+ startup_queries=startup_queries,
32
+ options=options,
33
+ debug_callback=debug_callback,
34
+ )
35
+ self._host = host
36
+ self._port = port
37
+ self._user = user
38
+ self._password = password
39
+ self._connection: Any = None
40
+ self._current_cursor: Any = None
41
+ self._connect()
42
+
43
+ def _connect(self) -> None:
44
+ import mysql.connector
45
+
46
+ kwargs: dict[str, Any] = {
47
+ "host": self._host,
48
+ "port": self._port,
49
+ "database": self._database_name,
50
+ "user": self._user,
51
+ "password": self._password,
52
+ "autocommit": True,
53
+ }
54
+
55
+ ssl_mode = self._options.get("ssl_mode")
56
+ if ssl_mode:
57
+ kwargs["ssl_mode"] = ssl_mode
58
+
59
+ connect_timeout = self._options.get("connect_timeout")
60
+ if connect_timeout:
61
+ kwargs["connect_timeout"] = connect_timeout
62
+
63
+ charset = self._options.get("charset", "utf8mb4")
64
+ kwargs["charset"] = charset
65
+
66
+ self._connection = mysql.connector.connect(**kwargs)
67
+
68
+ self._exec_startup_queries()
69
+
70
+ def _drain_cursor(self) -> None:
71
+ """Drain any unread results from the previous cursor.
72
+
73
+ MySQL connector forbids creating a new cursor while the previous
74
+ one still has unread rows. This method consumes and discards any
75
+ remaining rows so the next query can proceed.
76
+ """
77
+ if self._current_cursor is not None:
78
+ try:
79
+ self._current_cursor.fetchall()
80
+ except Exception: # noqa: BLE001, S110
81
+ pass
82
+ self._current_cursor = None
83
+
84
+ def version(self) -> str:
85
+ try:
86
+ cursor = self._connection.execute("SELECT VERSION()")
87
+ row = cursor.fetchone()
88
+ if row:
89
+ return str(row[0])
90
+ return "0"
91
+ except Exception: # noqa: BLE001
92
+ return "0"
93
+
94
+ def exec(self, query: str) -> None:
95
+ start = time.time()
96
+ error: str | None = None
97
+ try:
98
+ self._drain_cursor()
99
+ cursor = self._connection.cursor()
100
+ cursor.execute(query)
101
+ except Exception as e:
102
+ error = str(e)
103
+ raise
104
+ finally:
105
+ duration = time.time() - start
106
+ self._debug(query, duration, error)
107
+
108
+ def query(self, query: str) -> ResultABC:
109
+ start = time.time()
110
+ error: str | None = None
111
+ try:
112
+ self._drain_cursor()
113
+ cursor = self._connection.cursor()
114
+ cursor.execute(query)
115
+ self._current_cursor = cursor
116
+ return MySQLResult(cursor)
117
+ except Exception as e:
118
+ error = str(e)
119
+ raise
120
+ finally:
121
+ duration = time.time() - start
122
+ self._debug(query, duration, error)
123
+
124
+ def query_with_params(
125
+ self,
126
+ dialect: DialectABC,
127
+ query_with_params: QueryWithParams,
128
+ emulate_prepare: bool = False,
129
+ ) -> ResultABC:
130
+ query_with_params = query_with_params.question_marks_to_percent_s()
131
+ sql = query_with_params.query
132
+ params = query_with_params.params
133
+
134
+ start = time.time()
135
+ error: str | None = None
136
+ try:
137
+ self._drain_cursor()
138
+ if emulate_prepare:
139
+ sql_full = query_with_params.to_sql(dialect)
140
+ cursor = self._connection.cursor()
141
+ cursor.execute(sql_full)
142
+ else:
143
+ cursor = self._connection.cursor()
144
+ cursor.execute(sql, params)
145
+ self._current_cursor = cursor
146
+ return MySQLResult(cursor)
147
+ except Exception as e:
148
+ error = str(e)
149
+ raise
150
+ finally:
151
+ duration = time.time() - start
152
+ self._debug(query_with_params.to_sql(dialect), duration, error)
153
+
154
+ def begin_transaction(self) -> None:
155
+ self._connection.start_transaction()
156
+ self._in_transaction = True
157
+
158
+ def commit_transaction(self) -> None:
159
+ self._connection.commit()
160
+ self._in_transaction = False
161
+
162
+ def rollback_transaction(self) -> None:
163
+ self._connection.rollback()
164
+ self._in_transaction = False
165
+
166
+ def begin_savepoint(self, name: str) -> None:
167
+ self._drain_cursor()
168
+ cursor = self._connection.cursor()
169
+ cursor.execute(f"SAVEPOINT {name}")
170
+
171
+ def commit_savepoint(self, name: str) -> None:
172
+ self._drain_cursor()
173
+ cursor = self._connection.cursor()
174
+ cursor.execute(f"RELEASE SAVEPOINT {name}")
175
+
176
+ def rollback_savepoint(self, name: str) -> None:
177
+ self._drain_cursor()
178
+ cursor = self._connection.cursor()
179
+ cursor.execute(f"ROLLBACK TO SAVEPOINT {name}")
180
+
181
+ @property
182
+ def in_transaction(self) -> bool:
183
+ return self._in_transaction
184
+
185
+ def last_insert_id(self, name: str | None = None) -> int | str | None:
186
+ self._drain_cursor()
187
+ cursor = self._connection.cursor()
188
+ cursor.execute("SELECT LAST_INSERT_ID()")
189
+ row = cursor.fetchone()
190
+ return row[0] if row else None
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from flowmaticdb.adapters._base import AdapterABC
8
+ from flowmaticdb.result._base import ResultABC
9
+ from flowmaticdb.result._postgres import PsycopgResult
10
+
11
+ if TYPE_CHECKING:
12
+ from flowmaticdb._query_with_params import QueryWithParams
13
+ from flowmaticdb.dialects._base import DialectABC
14
+
15
+
16
+ class PsycopgAdapter(AdapterABC):
17
+ def __init__(
18
+ self,
19
+ database_name: str,
20
+ startup_queries: list[str] | None = None,
21
+ options: dict[str, Any] | None = None,
22
+ debug_callback: Callable[[str, float, str | None], None] | None = None,
23
+ host: str = "localhost",
24
+ port: int = 5432,
25
+ user: str = "postgres",
26
+ password: str = "",
27
+ ) -> None:
28
+ super().__init__(
29
+ driver_name="postgresql",
30
+ database_name=database_name,
31
+ startup_queries=startup_queries,
32
+ options=options,
33
+ debug_callback=debug_callback,
34
+ )
35
+ self._host = host
36
+ self._port = port
37
+ self._user = user
38
+ self._password = password
39
+ self._connection: Any = None
40
+ self._connect()
41
+
42
+ def _connect(self) -> None:
43
+ import psycopg
44
+
45
+ kwargs: dict[str, Any] = {
46
+ "host": self._host,
47
+ "port": self._port,
48
+ "dbname": self._database_name,
49
+ "user": self._user,
50
+ "password": self._password,
51
+ }
52
+
53
+ ssl_mode = self._options.get("sslmode")
54
+ if ssl_mode:
55
+ kwargs["sslmode"] = ssl_mode
56
+
57
+ search_path = self._options.get("search_path")
58
+ if search_path:
59
+ kwargs["options"] = f"-c search_path={search_path}"
60
+
61
+ self._connection = psycopg.connect(**kwargs)
62
+
63
+ self._exec_startup_queries()
64
+
65
+ def version(self) -> str:
66
+ try:
67
+ cursor = self._connection.execute("SELECT version()")
68
+ row = cursor.fetchone()
69
+ if row:
70
+ version_str = str(row[0])
71
+ import re
72
+ match = re.search(r'(\d+\.\d+(?:\.\d+)?)', version_str)
73
+ if match:
74
+ return match.group(1)
75
+ return "0"
76
+ except Exception: # noqa: BLE001
77
+ return "0"
78
+
79
+ def exec(self, query: str) -> None:
80
+ start = time.time()
81
+ error: str | None = None
82
+ try:
83
+ self._connection.execute(query)
84
+ self._connection.commit()
85
+ except Exception as e:
86
+ error = str(e)
87
+ raise
88
+ finally:
89
+ duration = time.time() - start
90
+ self._debug(query, duration, error)
91
+
92
+ def query(self, query: str) -> ResultABC:
93
+ start = time.time()
94
+ error: str | None = None
95
+ try:
96
+ cursor = self._connection.execute(query)
97
+ return PsycopgResult(cursor)
98
+ except Exception as e:
99
+ error = str(e)
100
+ raise
101
+ finally:
102
+ duration = time.time() - start
103
+ self._debug(query, duration, error)
104
+
105
+ def query_with_params(
106
+ self,
107
+ dialect: DialectABC,
108
+ query_with_params: QueryWithParams,
109
+ emulate_prepare: bool = False,
110
+ ) -> ResultABC:
111
+ query_with_params = query_with_params.question_marks_to_percent_s()
112
+ sql = query_with_params.query
113
+ params = query_with_params.params
114
+
115
+ start = time.time()
116
+ error: str | None = None
117
+ try:
118
+ if emulate_prepare:
119
+ sql_full = query_with_params.to_sql(dialect)
120
+ cursor = self._connection.execute(sql_full)
121
+ else:
122
+ cursor = self._connection.execute(sql, params)
123
+ return PsycopgResult(cursor)
124
+ except Exception as e:
125
+ error = str(e)
126
+ raise
127
+ finally:
128
+ duration = time.time() - start
129
+ self._debug(query_with_params.to_sql(dialect), duration, error)
130
+
131
+ def begin_transaction(self) -> None:
132
+ self._connection.execute("BEGIN TRANSACTION")
133
+ self._in_transaction = True
134
+
135
+ def commit_transaction(self) -> None:
136
+ self._connection.commit()
137
+ self._in_transaction = False
138
+
139
+ def rollback_transaction(self) -> None:
140
+ self._connection.rollback()
141
+ self._in_transaction = False
142
+
143
+ def begin_savepoint(self, name: str) -> None:
144
+ self._connection.execute(f"SAVEPOINT {name}")
145
+
146
+ def commit_savepoint(self, name: str) -> None:
147
+ self._connection.execute(f"RELEASE SAVEPOINT {name}")
148
+
149
+ def rollback_savepoint(self, name: str) -> None:
150
+ self._connection.execute(f"ROLLBACK TO SAVEPOINT {name}")
151
+
152
+ @property
153
+ def in_transaction(self) -> bool:
154
+ return self._connection.info.transaction_status is not None
155
+
156
+ def last_insert_id(self, name: str | None = None) -> int | str | None:
157
+ if name:
158
+ cursor = self._connection.execute(f"SELECT currval('{name}')")
159
+ else:
160
+ cursor = self._connection.execute("SELECT lastval()")
161
+ row = cursor.fetchone()
162
+ return row[0] if row else None