Skip to content

Commit dffad99

Browse files
committed
Merge branch 'preupdate_hook'
2 parents c6f7889 + bad6556 commit dffad99

24 files changed

Lines changed: 1751 additions & 134 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ work/
5353
.venv/
5454
compile_commands.json
5555
.vscode/
56+
.cache/

apsw/__init__.pyi

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ CommitHook = Callable[[], bool]
117117
"""Commit hook is called with no arguments and should return True to abort the commit and False
118118
to let it continue"""
119119

120+
PreupdateHook = Callable[[PreUpdate], None]
121+
"""The hook is called with information about the update, and has no return value"""
122+
120123
TokenizerResult = Iterable[str | tuple[str, ...] | tuple[int, int, *tuple[str, ...]]]
121124
"""The return from a tokenizer is based on the include_offsets and
122125
include_colocated parameters you provided, both defaulting to
@@ -367,7 +370,8 @@ memoryused = memory_used ## OLD-NAME
367370
no_change: object
368371
"""A sentinel value used to indicate no change in a value when
369372
used with :meth:`VTCursor.ColumnNoChange`,
370-
:meth:`VTTable.UpdateChangeRow`, and :attr:`TableChange.new`."""
373+
:meth:`VTTable.UpdateChangeRow`, :attr:`TableChange.new`,
374+
and :class:`PreUpdate.update`."""
371375

372376
def pyobject(object: Any):
373377
"""Indicates a Python object is being provided as a
@@ -1689,6 +1693,35 @@ class Connection:
16891693
* :ref:`Example <example_pragma>`"""
16901694
...
16911695

1696+
def preupdate_hook(self, callback: Optional[PreupdateHook], *, id: Optional[Any] = None) -> None:
1697+
"""A callback just after a database row is updated. You can have multiple hooks at once
1698+
(managed by APSW) by specifying different ``id`` for each. Using :class:`None` for
1699+
``callback`` will remove it.
1700+
1701+
SQLite provides no way to report errors from the callback. The SQLite level update
1702+
will always succeed, with Python exceptions reported when control returns to Python
1703+
code.
1704+
1705+
.. important::
1706+
1707+
The :doc:`session` extension uses the preupdate hook, and will **CRASH
1708+
THE PROCESS** if you register a hook via this method, and then create
1709+
a :class:`Session`.
1710+
1711+
SQLlite must be compiled with ``SQLITE_ENABLE_PREUPDATE_HOOK`` and this must be known
1712+
to APSW at compile time. If not, this API and :class:`PreUpdate` will not be present.
1713+
1714+
You do not get calls undoing changes when a transaction is
1715+
aborted/rolled back. Consequently you can't use this hook to track
1716+
the current state of the database. The approach taken by the
1717+
:doc:`session` is to note the rowid (or primary keys for without rowid
1718+
tables), and initial values the first time that a row is seen. When a
1719+
changeset is requested, it compares the contents of the row now to the row
1720+
then, and generates the appropriate changeset entry.
1721+
1722+
Calls: `sqlite3_preupdate_hook <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
1723+
...
1724+
16921725
def read(self, schema: str, which: int, offset: int, amount: int) -> tuple[bool, bytes]:
16931726
"""Invokes the underlying VFS method to read data from the database. It
16941727
is strongly recommended to read aligned complete pages, since that is
@@ -1831,13 +1864,16 @@ class Connection:
18311864

18321865
setbusytimeout = set_busy_timeout ## OLD-NAME
18331866

1834-
def set_commit_hook(self, callable: Optional[CommitHook]) -> None:
1867+
def set_commit_hook(self, callable: Optional[CommitHook], *, id: Optional[Any] = None) -> None:
18351868
"""*callable* will be called just before a commit. It should return
18361869
False for the commit to go ahead and True for it to be turned
18371870
into a rollback. In the case of an exception in your callable, a
18381871
True (rollback) value is returned. Pass None to unregister
18391872
the existing hook.
18401873
1874+
You can have multiple hooks at once (managed by APSW) by specifying
1875+
different ``id`` for each one.
1876+
18411877
.. seealso::
18421878
18431879
* :ref:`Example <example_commit_hook>`
@@ -1895,12 +1931,15 @@ class Connection:
18951931

18961932
setprogresshandler = set_progress_handler ## OLD-NAME
18971933

1898-
def set_rollback_hook(self, callable: Optional[Callable[[], None]]) -> None:
1934+
def set_rollback_hook(self, callable: Optional[Callable[[], None]], *, id: Optional[Any] = None) -> None:
18991935
"""Sets a callable which is invoked during a rollback. If *callable*
19001936
is *None* then any existing rollback hook is unregistered.
19011937
19021938
The *callable* is called with no parameters and the return value is ignored.
19031939
1940+
You can have multiple hooks at once (managed by APSW) by specifying
1941+
different ``id`` for each one.
1942+
19041943
Calls: `sqlite3_rollback_hook <https://sqlite.org/c3ref/commit_hook.html>`__"""
19051944
...
19061945

@@ -2718,6 +2757,81 @@ class IndexInfo:
27182757
"""Sets *omit* for *aConstraintUsage[which]*"""
27192758
...
27202759

2760+
@final
2761+
class PreUpdate:
2762+
"""Provides the details of one update to the
2763+
:meth:`Connection.preupdate_hook` callback.
2764+
2765+
.. note::
2766+
2767+
The object is only valid inside a the callback.
2768+
Using it outside the hook gives :exc:`InvalidContextError`.
2769+
You should copy all desired information in the callback."""
2770+
2771+
blob_write: int
2772+
"""Writes to blobs show up as `DELETE`, with this having the
2773+
column number being rewritten. The value is negative if
2774+
no blob is being written.
2775+
2776+
Only the old value is available. To get the new value you have
2777+
to query the database.
2778+
2779+
Calls: `sqlite3_preupdate_blobwrite <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
2780+
2781+
connection: Connection
2782+
"""The :class:`Connection` the preupdate is called on."""
2783+
2784+
database_name: str
2785+
"""``main``, ``temp``, the name of an attached database."""
2786+
2787+
depth: int
2788+
"""0 for direct SQL, 1 for triggers, 2 and so on for triggers
2789+
firing by a higher level trigger.
2790+
2791+
Calls: `sqlite3_preupdate_depth <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
2792+
2793+
new: tuple[SQLiteValue, ...] | None
2794+
"""Row values for an INSERT, or after an UPDATE. :class:`None` for
2795+
DELETE. See also :attr:`old` and :attr:`update`.
2796+
2797+
Calls: `sqlite3_preupdate_new <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
2798+
2799+
old: tuple[SQLiteValue, ...] | None
2800+
"""Row values for a DELETE, or before an UPDATE. :class:`None` for
2801+
INSERT. See also :attr:`new` and :attr:`update`.
2802+
2803+
Calls: `sqlite3_preupdate_old <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
2804+
2805+
op: str
2806+
"""The operation code as a string ``INSERT``,
2807+
``DELETE``, or ``UPDATE``. See :attr:`opcode`
2808+
for this as a number."""
2809+
2810+
opcode: int
2811+
"""The operation code - ``apsw.SQLITE_INSERT``,
2812+
``apsw.SQLITE_DELETE``, or ``apsw.SQLITE_UPDATE``.
2813+
See :attr:`op` for this as a string."""
2814+
2815+
rowid: int
2816+
"""The affected rowid."""
2817+
2818+
rowid_new: int
2819+
"""New rowid if changed via rowid UPDATE."""
2820+
2821+
table_name: str
2822+
"""Table name."""
2823+
2824+
update: tuple[SQLiteValue | Literal[no_change], ...] | None
2825+
"""For UPDATE compares old and new values, providing the changed value,
2826+
or :attr:`apsw.no_change` if that column was not changed.
2827+
2828+
:class:`None` for INSERT and DELETE. See also :attr:`old` and
2829+
:attr:`new`.
2830+
2831+
Calls:
2832+
* `sqlite3_preupdate_old <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__
2833+
* `sqlite3_preupdate_new <https://sqlite.org/c3ref/preupdate_blobwrite.html>`__"""
2834+
27212835
@final
27222836
class Rebaser:
27232837
"""This object wraps a `sqlite3_rebaser

apsw/ext.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,14 +758,23 @@ class Trace:
758758
executing trigger is shown too.
759759
:param vtable: If `True` then statements executed behind the
760760
scenes by virtual tables are shown.
761+
:param updates: If `True` and the :meth:`~apsw.Connection.preupdate_hook`
762+
is available, then inserted, updated, and deleted rows are shown.
763+
This is very helpful when you use bindings.
764+
:param transaction: If `True` then transaction start and commit/rollback
765+
will be shown, using commit/rollback hooks.
761766
:param truncate: Truncates SQL text to this many characters
762767
:param indent: Printed before each line of output
763768
764769
You are shown each regular statement start with a prefix of ``>``,
765770
end with a prefix of ``<`` if there were in between statements
766771
like triggers, ``T`` indicating trigger statements, and ``V``
767-
indicating virtual table statements. As each statement ends you
768-
are shown summary information.
772+
indicating virtual table statements. If ``updates`` is on, then
773+
``INS``. ``DEL``, and ``UPD`` are shown followed by the rowid, and
774+
then the columns. For updates, unchanged columns are shown as ``...```.
775+
Transaction control is shown with a ``!`` prefix.
776+
777+
As each statement ends you are shown summary information.
769778
770779
.. list-table::
771780
:header-rows: 1
@@ -830,6 +839,8 @@ def __init__(
830839
*,
831840
trigger: bool = False,
832841
vtable: bool = False,
842+
updates: bool = False,
843+
transaction: bool = False,
833844
truncate: int = 75,
834845
indent: str = "",
835846
):
@@ -838,6 +849,8 @@ def __init__(
838849
self.trigger = trigger
839850
self.vtable = vtable
840851
self.indent = indent
852+
self.updates = updates
853+
self.transaction = transaction
841854
self.truncate = truncate
842855

843856
def _truncate(self, text: str) -> str:
@@ -858,10 +871,51 @@ def __enter__(self):
858871
apsw.SQLITE_TRACE_STMT | apsw.SQLITE_TRACE_ROW | apsw.SQLITE_TRACE_PROFILE, self._sqlite_trace, id=self
859872
)
860873

874+
if self.updates:
875+
if hasattr(self.db, "preupdate_hook"):
876+
self.db.preupdate_hook(self._preupdate, id=self)
877+
else:
878+
self.updates = False
879+
880+
if self.transaction:
881+
self.db.set_commit_hook(self._commit, id=self)
882+
self.db.set_rollback_hook(self._rollback, id=self)
883+
self.transaction_state: str | None = None
884+
861885
return self
862886

887+
def _commit(self):
888+
self._transaction("COMMIT")
889+
return False
890+
891+
def _rollback(self):
892+
self._transaction("ROLLBACK")
893+
894+
def _transaction(self, state: str):
895+
if self.transaction and self.transaction_state != state:
896+
self.transaction_state = state
897+
print(self.indent, f" !{state}", file=self.file)
898+
899+
def _preupdate(self, update: apsw.PreUpdate):
900+
self._transaction("BEGIN")
901+
out = f"{update.op[:3]} {update.rowid}{f'>{update.rowid_new}' if update.rowid_new != update.rowid else ''} ("
902+
for num, column in enumerate(
903+
update.old if update.op == "DELETE" else update.new if update.op == "INSERT" else update.update
904+
):
905+
if len(out) > self.truncate:
906+
break
907+
val = "..." if column is apsw.no_change else apsw.format_sql_value(column)
908+
if num != 0:
909+
out += ", "
910+
out += val
911+
out += ")"
912+
913+
print(self.indent, " " + " " * update.depth, self._truncate(out), file=self.file)
914+
863915
def _sqlite_trace(self, event: dict):
864916
if event["code"] == apsw.SQLITE_TRACE_STMT:
917+
if self.db.in_transaction or not event["readonly"]:
918+
self._transaction("BEGIN")
865919
stmt = self.statements[event["id"]]
866920
if stmt.change_count == -1:
867921
stmt.change_count = event["total_changes"]
@@ -948,6 +1002,11 @@ def _sqlite_trace(self, event: dict):
9481002

9491003
def __exit__(self, *_):
9501004
self.db.trace_v2(0, None, id=self)
1005+
if self.updates:
1006+
self.db.preupdate_hook(None, id=self)
1007+
if self.transaction:
1008+
self.db.set_commit_hook(None, id=self)
1009+
self.db.set_rollback_hook(None, id=self)
9511010

9521011

9531012
class ShowResourceUsage:

0 commit comments

Comments
 (0)