sqlite-chronicle 0.2__tar.gz → 0.3__tar.gz
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.
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/PKG-INFO +35 -16
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/README.md +29 -11
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/pyproject.toml +12 -4
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/sqlite_chronicle.egg-info/PKG-INFO +35 -16
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/sqlite_chronicle.egg-info/SOURCES.txt +1 -0
- sqlite_chronicle-0.3/sqlite_chronicle.egg-info/requires.txt +4 -0
- sqlite_chronicle-0.3/sqlite_chronicle.py +309 -0
- sqlite_chronicle-0.3/tests/test_cli.py +51 -0
- sqlite_chronicle-0.3/tests/test_sqlite_chronicle.py +143 -0
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/tests/test_updates_since.py +8 -8
- sqlite-chronicle-0.2/sqlite_chronicle.egg-info/requires.txt +0 -4
- sqlite-chronicle-0.2/sqlite_chronicle.py +0 -196
- sqlite-chronicle-0.2/tests/test_sqlite_chronicle.py +0 -117
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/setup.cfg +0 -0
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/sqlite_chronicle.egg-info/dependency_links.txt +0 -0
- {sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/sqlite_chronicle.egg-info/top_level.txt +0 -0
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite-chronicle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
4
4
|
Summary: Use triggers to maintain a chronicle table of updated/deleted timestamps in SQLite
|
|
5
5
|
Author: Simon Willison
|
|
6
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/simonw/sqlite-chronicle
|
|
8
8
|
Project-URL: Changelog, https://github.com/simonw/sqlite-chronicle/releases
|
|
9
9
|
Project-URL: Issues, https://github.com/simonw/sqlite-chronicle/issues
|
|
10
10
|
Project-URL: CI, https://github.com/simonw/sqlite-chronicle/actions
|
|
11
|
-
Classifier:
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Classifier: Programming Language :: SQL
|
|
12
13
|
Description-Content-Type: text/markdown
|
|
13
14
|
Provides-Extra: test
|
|
14
15
|
Requires-Dist: pytest; extra == "test"
|
|
15
|
-
Requires-Dist: sqlite-utils; extra == "test"
|
|
16
|
+
Requires-Dist: sqlite-utils>=4.0a0; extra == "test"
|
|
16
17
|
|
|
17
18
|
# sqlite-chronicle
|
|
18
19
|
|
|
@@ -29,25 +30,38 @@ Use triggers to track when rows in a SQLite table were updated or deleted
|
|
|
29
30
|
pip install sqlite-chronicle
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
##
|
|
33
|
+
## Command-line interface
|
|
34
|
+
|
|
35
|
+
You can enable chronicle for specific tables in a SQLite database using the command-line interface, passing in one or more table names:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m sqlite_chronicle database.db table_1 table_2
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Python API
|
|
42
|
+
|
|
43
|
+
This package exposes two Python functions for configuring and using chronicle tables:
|
|
44
|
+
|
|
45
|
+
### enable_chronicle(conn, table_name)
|
|
33
46
|
|
|
34
47
|
This module provides a function: `sqlite_chronicle.enable_chronicle(conn, table_name)`, which does the following:
|
|
35
48
|
|
|
36
49
|
1. Checks if a `_chronicle_{table_name}` table exists already. If so, it does nothing. Otherwise...
|
|
37
|
-
2. Creates that table, with the same primary key columns as the original table plus integer columns `
|
|
38
|
-
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `
|
|
50
|
+
2. Creates that table, with the same primary key columns as the original table plus integer columns `__added_ms`, `__updated_ms`, `__version` and `__deleted`
|
|
51
|
+
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `__added_ms` and `__updated_ms` to the current timestamp in milliseconds, and `__version` column that starts at 1 and increments for each subsequent row
|
|
39
52
|
4. Sets up three triggers on the table:
|
|
40
|
-
- An after insert trigger, which creates a new row in the chronicle table, sets `added_ms` and `updated_ms` to the current time and increments the `version`
|
|
41
|
-
- An after update trigger, which updates the `updated_ms` timestamp and also updates any primary keys if they have changed (likely extremely rare) plus increments the `version`
|
|
42
|
-
- An after delete trigger, which updates the `updated_ms`, increments the `version` and places a `1` in the `deleted` column
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
- An AFTER INSERT trigger, which creates a new row in the chronicle table, sets `__added_ms` and `__updated_ms` to the current time and sets the `__version` to one higher than the current maximum version for that table
|
|
55
|
+
- An AFTER UPDATE trigger, which updates the `__updated_ms` timestamp and increments the `__version` - but only if at least one column in the row has changed
|
|
56
|
+
- An AFTER DELETE trigger, which updates the `__updated_ms`, increments the `__version` and places a `1` in the `deleted` column
|
|
57
|
+
|
|
58
|
+
The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not exist or if it does not have a single or compound primary key,
|
|
45
59
|
|
|
46
|
-
Note that the `
|
|
60
|
+
Note that the `__version` for a table is a globally incrementing number, so every time it is set it will be set to the current `max(__version)` + 1 for that entire table.
|
|
47
61
|
|
|
48
62
|
The end result is a chronicle table that looks something like this:
|
|
49
63
|
|
|
50
|
-
| id |
|
|
64
|
+
| id | __added_ms | __updated_ms | __version | __deleted |
|
|
51
65
|
|-----|---------------|---------|--------|---------|
|
|
52
66
|
| 47 | 1694408890954 | 1694408890954 | 2 | 0 |
|
|
53
67
|
| 48 | 1694408874863 | 1694408874863 | 3 | 1 |
|
|
@@ -55,7 +69,7 @@ The end result is a chronicle table that looks something like this:
|
|
|
55
69
|
| 2 | 1694408825192 | 1694408825192 | 5 | 0 |
|
|
56
70
|
| 3 | 1694408825192 | 1694408825192 | 6 | 0 |
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
### updates_since(conn, table_name, since=None, batch_size=1000)
|
|
59
73
|
|
|
60
74
|
The `sqlite_chronicle.updates_since()` function returns a generator over a list of `Change` objects.
|
|
61
75
|
|
|
@@ -75,7 +89,7 @@ Change(
|
|
|
75
89
|
updated_ms=1701836971223,
|
|
76
90
|
version=5,
|
|
77
91
|
row={'id': 5, 'name': 'Simon'},
|
|
78
|
-
deleted=
|
|
92
|
+
deleted=False
|
|
79
93
|
)
|
|
80
94
|
```
|
|
81
95
|
A `Change` is a dataclass with the following properties:
|
|
@@ -91,6 +105,11 @@ Any time you call this you should track the last `version` number that you see,
|
|
|
91
105
|
|
|
92
106
|
Note that if a row had multiple updates in between calls to this function you will still only see one `Change` object for that row - the `updated_ms` and `version` will reflect the most recent update.
|
|
93
107
|
|
|
108
|
+
## Implementation notes
|
|
109
|
+
|
|
110
|
+
- If you run `INSERT OR REPLACE INTO ...` and update an existing record in a way that does not change any of the fields, this system will still treat that record as if it has been updated. Use `INSERT ... ON CONFLICT SET` upserts instead to avoid this problem.
|
|
111
|
+
- Updates to columns that are part of a primary key for the record is not currently supported.
|
|
112
|
+
|
|
94
113
|
## Potential applications
|
|
95
114
|
|
|
96
115
|
Chronicle tables can be used to efficiently answer the question "what rows have been inserted, updated or deleted since I last checked" - by looking at the `version` column which has an index to make it fast to answer that question.
|
|
@@ -13,25 +13,38 @@ Use triggers to track when rows in a SQLite table were updated or deleted
|
|
|
13
13
|
pip install sqlite-chronicle
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Command-line interface
|
|
17
|
+
|
|
18
|
+
You can enable chronicle for specific tables in a SQLite database using the command-line interface, passing in one or more table names:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m sqlite_chronicle database.db table_1 table_2
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Python API
|
|
25
|
+
|
|
26
|
+
This package exposes two Python functions for configuring and using chronicle tables:
|
|
27
|
+
|
|
28
|
+
### enable_chronicle(conn, table_name)
|
|
17
29
|
|
|
18
30
|
This module provides a function: `sqlite_chronicle.enable_chronicle(conn, table_name)`, which does the following:
|
|
19
31
|
|
|
20
32
|
1. Checks if a `_chronicle_{table_name}` table exists already. If so, it does nothing. Otherwise...
|
|
21
|
-
2. Creates that table, with the same primary key columns as the original table plus integer columns `
|
|
22
|
-
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `
|
|
33
|
+
2. Creates that table, with the same primary key columns as the original table plus integer columns `__added_ms`, `__updated_ms`, `__version` and `__deleted`
|
|
34
|
+
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `__added_ms` and `__updated_ms` to the current timestamp in milliseconds, and `__version` column that starts at 1 and increments for each subsequent row
|
|
23
35
|
4. Sets up three triggers on the table:
|
|
24
|
-
- An after insert trigger, which creates a new row in the chronicle table, sets `added_ms` and `updated_ms` to the current time and increments the `version`
|
|
25
|
-
- An after update trigger, which updates the `updated_ms` timestamp and also updates any primary keys if they have changed (likely extremely rare) plus increments the `version`
|
|
26
|
-
- An after delete trigger, which updates the `updated_ms`, increments the `version` and places a `1` in the `deleted` column
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
- An AFTER INSERT trigger, which creates a new row in the chronicle table, sets `__added_ms` and `__updated_ms` to the current time and sets the `__version` to one higher than the current maximum version for that table
|
|
38
|
+
- An AFTER UPDATE trigger, which updates the `__updated_ms` timestamp and increments the `__version` - but only if at least one column in the row has changed
|
|
39
|
+
- An AFTER DELETE trigger, which updates the `__updated_ms`, increments the `__version` and places a `1` in the `deleted` column
|
|
40
|
+
|
|
41
|
+
The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not exist or if it does not have a single or compound primary key,
|
|
29
42
|
|
|
30
|
-
Note that the `
|
|
43
|
+
Note that the `__version` for a table is a globally incrementing number, so every time it is set it will be set to the current `max(__version)` + 1 for that entire table.
|
|
31
44
|
|
|
32
45
|
The end result is a chronicle table that looks something like this:
|
|
33
46
|
|
|
34
|
-
| id |
|
|
47
|
+
| id | __added_ms | __updated_ms | __version | __deleted |
|
|
35
48
|
|-----|---------------|---------|--------|---------|
|
|
36
49
|
| 47 | 1694408890954 | 1694408890954 | 2 | 0 |
|
|
37
50
|
| 48 | 1694408874863 | 1694408874863 | 3 | 1 |
|
|
@@ -39,7 +52,7 @@ The end result is a chronicle table that looks something like this:
|
|
|
39
52
|
| 2 | 1694408825192 | 1694408825192 | 5 | 0 |
|
|
40
53
|
| 3 | 1694408825192 | 1694408825192 | 6 | 0 |
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
### updates_since(conn, table_name, since=None, batch_size=1000)
|
|
43
56
|
|
|
44
57
|
The `sqlite_chronicle.updates_since()` function returns a generator over a list of `Change` objects.
|
|
45
58
|
|
|
@@ -59,7 +72,7 @@ Change(
|
|
|
59
72
|
updated_ms=1701836971223,
|
|
60
73
|
version=5,
|
|
61
74
|
row={'id': 5, 'name': 'Simon'},
|
|
62
|
-
deleted=
|
|
75
|
+
deleted=False
|
|
63
76
|
)
|
|
64
77
|
```
|
|
65
78
|
A `Change` is a dataclass with the following properties:
|
|
@@ -75,6 +88,11 @@ Any time you call this you should track the last `version` number that you see,
|
|
|
75
88
|
|
|
76
89
|
Note that if a row had multiple updates in between calls to this function you will still only see one `Change` object for that row - the `updated_ms` and `version` will reflect the most recent update.
|
|
77
90
|
|
|
91
|
+
## Implementation notes
|
|
92
|
+
|
|
93
|
+
- If you run `INSERT OR REPLACE INTO ...` and update an existing record in a way that does not change any of the fields, this system will still treat that record as if it has been updated. Use `INSERT ... ON CONFLICT SET` upserts instead to avoid this problem.
|
|
94
|
+
- Updates to columns that are part of a primary key for the record is not currently supported.
|
|
95
|
+
|
|
78
96
|
## Potential applications
|
|
79
97
|
|
|
80
98
|
Chronicle tables can be used to efficiently answer the question "what rows have been inserted, updated or deleted since I last checked" - by looking at the `version` column which has an index to make it fast to answer that question.
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlite-chronicle"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3"
|
|
4
4
|
description = "Use triggers to maintain a chronicle table of updated/deleted timestamps in SQLite"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{name = "Simon Willison"}]
|
|
7
|
-
license =
|
|
7
|
+
license = "Apache-2.0"
|
|
8
8
|
classifiers = [
|
|
9
|
-
"
|
|
9
|
+
"Topic :: Database",
|
|
10
|
+
"Programming Language :: SQL"
|
|
10
11
|
]
|
|
11
12
|
|
|
12
13
|
[project.urls]
|
|
@@ -16,4 +17,11 @@ Issues = "https://github.com/simonw/sqlite-chronicle/issues"
|
|
|
16
17
|
CI = "https://github.com/simonw/sqlite-chronicle/actions"
|
|
17
18
|
|
|
18
19
|
[project.optional-dependencies]
|
|
19
|
-
test = ["pytest", "sqlite-utils"]
|
|
20
|
+
test = ["pytest", "sqlite-utils>=4.0a0"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
py-modules = ["sqlite_chronicle"]
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlite-chronicle
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
4
4
|
Summary: Use triggers to maintain a chronicle table of updated/deleted timestamps in SQLite
|
|
5
5
|
Author: Simon Willison
|
|
6
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/simonw/sqlite-chronicle
|
|
8
8
|
Project-URL: Changelog, https://github.com/simonw/sqlite-chronicle/releases
|
|
9
9
|
Project-URL: Issues, https://github.com/simonw/sqlite-chronicle/issues
|
|
10
10
|
Project-URL: CI, https://github.com/simonw/sqlite-chronicle/actions
|
|
11
|
-
Classifier:
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Classifier: Programming Language :: SQL
|
|
12
13
|
Description-Content-Type: text/markdown
|
|
13
14
|
Provides-Extra: test
|
|
14
15
|
Requires-Dist: pytest; extra == "test"
|
|
15
|
-
Requires-Dist: sqlite-utils; extra == "test"
|
|
16
|
+
Requires-Dist: sqlite-utils>=4.0a0; extra == "test"
|
|
16
17
|
|
|
17
18
|
# sqlite-chronicle
|
|
18
19
|
|
|
@@ -29,25 +30,38 @@ Use triggers to track when rows in a SQLite table were updated or deleted
|
|
|
29
30
|
pip install sqlite-chronicle
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
##
|
|
33
|
+
## Command-line interface
|
|
34
|
+
|
|
35
|
+
You can enable chronicle for specific tables in a SQLite database using the command-line interface, passing in one or more table names:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m sqlite_chronicle database.db table_1 table_2
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Python API
|
|
42
|
+
|
|
43
|
+
This package exposes two Python functions for configuring and using chronicle tables:
|
|
44
|
+
|
|
45
|
+
### enable_chronicle(conn, table_name)
|
|
33
46
|
|
|
34
47
|
This module provides a function: `sqlite_chronicle.enable_chronicle(conn, table_name)`, which does the following:
|
|
35
48
|
|
|
36
49
|
1. Checks if a `_chronicle_{table_name}` table exists already. If so, it does nothing. Otherwise...
|
|
37
|
-
2. Creates that table, with the same primary key columns as the original table plus integer columns `
|
|
38
|
-
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `
|
|
50
|
+
2. Creates that table, with the same primary key columns as the original table plus integer columns `__added_ms`, `__updated_ms`, `__version` and `__deleted`
|
|
51
|
+
3. Creates a new row in the chronicle table corresponding to every row in the original table, setting `__added_ms` and `__updated_ms` to the current timestamp in milliseconds, and `__version` column that starts at 1 and increments for each subsequent row
|
|
39
52
|
4. Sets up three triggers on the table:
|
|
40
|
-
- An after insert trigger, which creates a new row in the chronicle table, sets `added_ms` and `updated_ms` to the current time and increments the `version`
|
|
41
|
-
- An after update trigger, which updates the `updated_ms` timestamp and also updates any primary keys if they have changed (likely extremely rare) plus increments the `version`
|
|
42
|
-
- An after delete trigger, which updates the `updated_ms`, increments the `version` and places a `1` in the `deleted` column
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
- An AFTER INSERT trigger, which creates a new row in the chronicle table, sets `__added_ms` and `__updated_ms` to the current time and sets the `__version` to one higher than the current maximum version for that table
|
|
55
|
+
- An AFTER UPDATE trigger, which updates the `__updated_ms` timestamp and increments the `__version` - but only if at least one column in the row has changed
|
|
56
|
+
- An AFTER DELETE trigger, which updates the `__updated_ms`, increments the `__version` and places a `1` in the `deleted` column
|
|
57
|
+
|
|
58
|
+
The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not exist or if it does not have a single or compound primary key,
|
|
45
59
|
|
|
46
|
-
Note that the `
|
|
60
|
+
Note that the `__version` for a table is a globally incrementing number, so every time it is set it will be set to the current `max(__version)` + 1 for that entire table.
|
|
47
61
|
|
|
48
62
|
The end result is a chronicle table that looks something like this:
|
|
49
63
|
|
|
50
|
-
| id |
|
|
64
|
+
| id | __added_ms | __updated_ms | __version | __deleted |
|
|
51
65
|
|-----|---------------|---------|--------|---------|
|
|
52
66
|
| 47 | 1694408890954 | 1694408890954 | 2 | 0 |
|
|
53
67
|
| 48 | 1694408874863 | 1694408874863 | 3 | 1 |
|
|
@@ -55,7 +69,7 @@ The end result is a chronicle table that looks something like this:
|
|
|
55
69
|
| 2 | 1694408825192 | 1694408825192 | 5 | 0 |
|
|
56
70
|
| 3 | 1694408825192 | 1694408825192 | 6 | 0 |
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
### updates_since(conn, table_name, since=None, batch_size=1000)
|
|
59
73
|
|
|
60
74
|
The `sqlite_chronicle.updates_since()` function returns a generator over a list of `Change` objects.
|
|
61
75
|
|
|
@@ -75,7 +89,7 @@ Change(
|
|
|
75
89
|
updated_ms=1701836971223,
|
|
76
90
|
version=5,
|
|
77
91
|
row={'id': 5, 'name': 'Simon'},
|
|
78
|
-
deleted=
|
|
92
|
+
deleted=False
|
|
79
93
|
)
|
|
80
94
|
```
|
|
81
95
|
A `Change` is a dataclass with the following properties:
|
|
@@ -91,6 +105,11 @@ Any time you call this you should track the last `version` number that you see,
|
|
|
91
105
|
|
|
92
106
|
Note that if a row had multiple updates in between calls to this function you will still only see one `Change` object for that row - the `updated_ms` and `version` will reflect the most recent update.
|
|
93
107
|
|
|
108
|
+
## Implementation notes
|
|
109
|
+
|
|
110
|
+
- If you run `INSERT OR REPLACE INTO ...` and update an existing record in a way that does not change any of the fields, this system will still treat that record as if it has been updated. Use `INSERT ... ON CONFLICT SET` upserts instead to avoid this problem.
|
|
111
|
+
- Updates to columns that are part of a primary key for the record is not currently supported.
|
|
112
|
+
|
|
94
113
|
## Potential applications
|
|
95
114
|
|
|
96
115
|
Chronicle tables can be used to efficiently answer the question "what rows have been inserted, updated or deleted since I last checked" - by looking at the `version` column which has an index to make it fast to answer that question.
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import sqlite3
|
|
3
|
+
import textwrap
|
|
4
|
+
from typing import Generator, Optional, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChronicleError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class Change:
|
|
13
|
+
pks: tuple
|
|
14
|
+
added_ms: int
|
|
15
|
+
updated_ms: int
|
|
16
|
+
version: int
|
|
17
|
+
row: dict
|
|
18
|
+
deleted: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def enable_chronicle(conn: sqlite3.Connection, table_name: str) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Turn on chronicle tracking for `table_name`.
|
|
24
|
+
|
|
25
|
+
- Creates _chronicle_<table> (PK cols + __added_ms, __updated_ms, __version, __deleted)
|
|
26
|
+
- Populates that table with one row per existing row in the original
|
|
27
|
+
- AFTER INSERT trigger
|
|
28
|
+
- AFTER UPDATE trigger (WHEN any OLD<>NEW)
|
|
29
|
+
- AFTER DELETE trigger
|
|
30
|
+
|
|
31
|
+
Requires client code to use UPSERT (INSERT…ON CONFLICT DO UPDATE) for upserts rather
|
|
32
|
+
than INSERT OR REPLACE, since INSERT OR REPLACE will be incorrectly tracked.
|
|
33
|
+
"""
|
|
34
|
+
cursor = conn.cursor()
|
|
35
|
+
|
|
36
|
+
# If chronicle table exists already, do nothing
|
|
37
|
+
chronicle_table = f"_chronicle_{table_name}"
|
|
38
|
+
cursor.execute(
|
|
39
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
40
|
+
(chronicle_table,),
|
|
41
|
+
)
|
|
42
|
+
if cursor.fetchone():
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Gather table schema info
|
|
46
|
+
cursor.execute(f'PRAGMA table_info("{table_name}")')
|
|
47
|
+
table_info = cursor.fetchall()
|
|
48
|
+
|
|
49
|
+
# Error if no such table
|
|
50
|
+
if not table_info:
|
|
51
|
+
raise ChronicleError(f"Table {table_name!r} does not exist")
|
|
52
|
+
|
|
53
|
+
# Identify primary key columns and non-PK columns
|
|
54
|
+
primary_key_columns = [(row[1], row[2]) for row in table_info if row[5]]
|
|
55
|
+
if not primary_key_columns:
|
|
56
|
+
raise ChronicleError(f"{table_name!r} has no PRIMARY KEY")
|
|
57
|
+
non_pk_columns = [row[1] for row in table_info if not row[5]]
|
|
58
|
+
primary_key_names = [col for col, _ in primary_key_columns]
|
|
59
|
+
|
|
60
|
+
# SQL expressions for timestamps and versioning
|
|
61
|
+
current_timestamp_expr = (
|
|
62
|
+
"CAST((julianday('now') - 2440587.5) * 86400 * 1000 AS INTEGER)"
|
|
63
|
+
)
|
|
64
|
+
next_version_expr = (
|
|
65
|
+
f'COALESCE((SELECT MAX(__version) FROM "{chronicle_table}"), 0) + 1'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Build trigger WHEN condition: any non-PK column changed
|
|
69
|
+
if non_pk_columns:
|
|
70
|
+
update_condition = " OR ".join(
|
|
71
|
+
f'OLD."{col}" IS NOT NEW."{col}"' for col in non_pk_columns
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
# no non-PK columns → treat any update as a change
|
|
75
|
+
update_condition = "1"
|
|
76
|
+
|
|
77
|
+
# Build PK matching clause for WHERE conditions
|
|
78
|
+
primary_key_match_clause = " AND ".join(
|
|
79
|
+
f'"{col}" = NEW."{col}"' for col in primary_key_names
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Collect all SQL statements to execute
|
|
83
|
+
sql_statements: List[str] = []
|
|
84
|
+
|
|
85
|
+
# 1) Create chronicle table
|
|
86
|
+
pk_definitions = ", ".join(
|
|
87
|
+
f'"{col}" {col_type}' for col, col_type in primary_key_columns
|
|
88
|
+
)
|
|
89
|
+
pk_constraint = ", ".join(f'"{col}"' for col in primary_key_names)
|
|
90
|
+
sql_statements.append(
|
|
91
|
+
textwrap.dedent(
|
|
92
|
+
f"""
|
|
93
|
+
CREATE TABLE "{chronicle_table}" (
|
|
94
|
+
{pk_definitions},
|
|
95
|
+
__added_ms INTEGER,
|
|
96
|
+
__updated_ms INTEGER,
|
|
97
|
+
__version INTEGER,
|
|
98
|
+
__deleted INTEGER DEFAULT 0,
|
|
99
|
+
PRIMARY KEY({pk_constraint})
|
|
100
|
+
)
|
|
101
|
+
"""
|
|
102
|
+
).strip()
|
|
103
|
+
)
|
|
104
|
+
sql_statements.append(
|
|
105
|
+
textwrap.dedent(
|
|
106
|
+
f"""
|
|
107
|
+
CREATE INDEX "{chronicle_table}__version_idx"
|
|
108
|
+
ON "{chronicle_table}"(__version);
|
|
109
|
+
"""
|
|
110
|
+
).strip()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 2) Seed chronicle table with existing rows
|
|
114
|
+
version_expr = (
|
|
115
|
+
f"ROW_NUMBER() OVER (ORDER BY "
|
|
116
|
+
+ ", ".join(f'"{col}"' for col in primary_key_names)
|
|
117
|
+
+ ")"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
cols_insert = (
|
|
121
|
+
", ".join(f'"{col}"' for col in primary_key_names)
|
|
122
|
+
+ ", __added_ms, __updated_ms, __version, __deleted"
|
|
123
|
+
)
|
|
124
|
+
cols_select = (
|
|
125
|
+
", ".join(f'"{col}"' for col in primary_key_names)
|
|
126
|
+
+ f", {current_timestamp_expr} AS __added_ms"
|
|
127
|
+
+ f", {current_timestamp_expr} AS __updated_ms"
|
|
128
|
+
+ f", {version_expr} AS __version"
|
|
129
|
+
+ ", 0 AS __deleted"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
sql_statements.append(
|
|
133
|
+
f'INSERT INTO "{chronicle_table}" ({cols_insert})\n'
|
|
134
|
+
f" SELECT {cols_select}\n"
|
|
135
|
+
f' FROM "{table_name}";'
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 3) AFTER INSERT trigger
|
|
139
|
+
sql_statements.append(
|
|
140
|
+
textwrap.dedent(
|
|
141
|
+
f"""
|
|
142
|
+
CREATE TRIGGER "chronicle_{table_name}_ai"
|
|
143
|
+
AFTER INSERT ON "{table_name}"
|
|
144
|
+
FOR EACH ROW
|
|
145
|
+
BEGIN
|
|
146
|
+
INSERT OR IGNORE INTO "{chronicle_table}" (
|
|
147
|
+
{', '.join(f'"{col}"' for col in primary_key_names)},
|
|
148
|
+
__added_ms, __updated_ms, __version, __deleted
|
|
149
|
+
)
|
|
150
|
+
VALUES (
|
|
151
|
+
{', '.join(f'NEW."{col}"' for col in primary_key_names)},
|
|
152
|
+
{current_timestamp_expr}, {current_timestamp_expr},
|
|
153
|
+
{next_version_expr}, 0
|
|
154
|
+
);
|
|
155
|
+
END;
|
|
156
|
+
"""
|
|
157
|
+
).strip()
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 4) AFTER UPDATE trigger (only if real change)
|
|
161
|
+
sql_statements.append(
|
|
162
|
+
textwrap.dedent(
|
|
163
|
+
f"""
|
|
164
|
+
CREATE TRIGGER "chronicle_{table_name}_au"
|
|
165
|
+
AFTER UPDATE ON "{table_name}"
|
|
166
|
+
FOR EACH ROW
|
|
167
|
+
WHEN {update_condition}
|
|
168
|
+
BEGIN
|
|
169
|
+
UPDATE "{chronicle_table}"
|
|
170
|
+
SET __updated_ms = {current_timestamp_expr},
|
|
171
|
+
__version = {next_version_expr}
|
|
172
|
+
WHERE {primary_key_match_clause};
|
|
173
|
+
END;
|
|
174
|
+
"""
|
|
175
|
+
).strip()
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 5) AFTER DELETE trigger
|
|
179
|
+
pk_old_match = " AND ".join(f'"{col}" = OLD."{col}"' for col in primary_key_names)
|
|
180
|
+
sql_statements.append(
|
|
181
|
+
textwrap.dedent(
|
|
182
|
+
f"""
|
|
183
|
+
CREATE TRIGGER "chronicle_{table_name}_ad"
|
|
184
|
+
AFTER DELETE ON "{table_name}"
|
|
185
|
+
FOR EACH ROW
|
|
186
|
+
BEGIN
|
|
187
|
+
UPDATE "{chronicle_table}"
|
|
188
|
+
SET __updated_ms = {current_timestamp_expr},
|
|
189
|
+
__version = {next_version_expr},
|
|
190
|
+
__deleted = 1
|
|
191
|
+
WHERE {pk_old_match};
|
|
192
|
+
END;
|
|
193
|
+
"""
|
|
194
|
+
).strip()
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Execute all statements within a transaction
|
|
198
|
+
with conn:
|
|
199
|
+
for stmt in sql_statements:
|
|
200
|
+
cursor.execute(stmt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def updates_since(
|
|
204
|
+
conn: sqlite3.Connection,
|
|
205
|
+
table_name: str,
|
|
206
|
+
since: Optional[int] = None,
|
|
207
|
+
batch_size: int = 1000,
|
|
208
|
+
) -> Generator[Change, None, None]:
|
|
209
|
+
"""
|
|
210
|
+
Yields Change(pks, added_ms, updated_ms, version, row, deleted)
|
|
211
|
+
for every chronicle.version > since, in ascending order.
|
|
212
|
+
"""
|
|
213
|
+
cur = conn.cursor()
|
|
214
|
+
cur.row_factory = sqlite3.Row
|
|
215
|
+
if since is None:
|
|
216
|
+
since = 0
|
|
217
|
+
|
|
218
|
+
# find PK columns
|
|
219
|
+
cur.execute(f'PRAGMA table_info("{table_name}")')
|
|
220
|
+
cols = cur.fetchall()
|
|
221
|
+
pk_names = [c["name"] for c in cols if c["pk"]]
|
|
222
|
+
non_pk = [c["name"] for c in cols if not c["pk"]]
|
|
223
|
+
|
|
224
|
+
# build select
|
|
225
|
+
chron = f"_chronicle_{table_name}"
|
|
226
|
+
pks = ", ".join(f'c."{c}"' for c in pk_names)
|
|
227
|
+
originals = ", ".join(f't."{c}"' for c in non_pk)
|
|
228
|
+
select = ", ".join(
|
|
229
|
+
[pks, originals, "c.__added_ms", "c.__updated_ms", "c.__version", "c.__deleted"]
|
|
230
|
+
)
|
|
231
|
+
join = " AND ".join(f'c."{c}" = t."{c}"' for c in pk_names)
|
|
232
|
+
|
|
233
|
+
sql = textwrap.dedent(
|
|
234
|
+
f"""
|
|
235
|
+
SELECT {select}
|
|
236
|
+
FROM {chron} AS c
|
|
237
|
+
LEFT JOIN "{table_name}" AS t
|
|
238
|
+
ON {join}
|
|
239
|
+
WHERE c.__version > ?
|
|
240
|
+
ORDER BY c.__version
|
|
241
|
+
LIMIT {batch_size}
|
|
242
|
+
"""
|
|
243
|
+
).strip()
|
|
244
|
+
|
|
245
|
+
while True:
|
|
246
|
+
rows = cur.execute(sql, (since,)).fetchall()
|
|
247
|
+
if not rows:
|
|
248
|
+
break
|
|
249
|
+
for r in rows:
|
|
250
|
+
since = r["__version"]
|
|
251
|
+
# build row dict of original columns
|
|
252
|
+
row = {
|
|
253
|
+
c: r[c]
|
|
254
|
+
for c in r.keys()
|
|
255
|
+
if c not in ("__added_ms", "__updated_ms", "__version", "__deleted")
|
|
256
|
+
}
|
|
257
|
+
yield Change(
|
|
258
|
+
pks=tuple(r[c] for c in pk_names),
|
|
259
|
+
added_ms=r["__added_ms"],
|
|
260
|
+
updated_ms=r["__updated_ms"],
|
|
261
|
+
version=r["__version"],
|
|
262
|
+
row=row,
|
|
263
|
+
deleted=bool(r["__deleted"]),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cli_main(argv=None) -> int:
|
|
268
|
+
import argparse
|
|
269
|
+
import sys
|
|
270
|
+
|
|
271
|
+
parser = argparse.ArgumentParser(
|
|
272
|
+
prog="python -m sqlite_chronicle",
|
|
273
|
+
description="Enable chronicle tracking on one or more tables in an SQLite DB.",
|
|
274
|
+
)
|
|
275
|
+
parser.add_argument("db_path", help="Path to the SQLite database file")
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"tables",
|
|
278
|
+
nargs="+",
|
|
279
|
+
help="One or more table names to enable chronicle tracking on",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
args = parser.parse_args(argv)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
conn = sqlite3.connect(args.db_path)
|
|
286
|
+
except sqlite3.Error as e:
|
|
287
|
+
print(f"ERROR: cannot open database {args.db_path!r}: {e}", file=sys.stderr)
|
|
288
|
+
return 1
|
|
289
|
+
|
|
290
|
+
any_error = False
|
|
291
|
+
for tbl in args.tables:
|
|
292
|
+
try:
|
|
293
|
+
enable_chronicle(conn, tbl)
|
|
294
|
+
print(f"- chronicle enabled on table {tbl!r}")
|
|
295
|
+
except ChronicleError as ce:
|
|
296
|
+
print(f"ERROR: {ce}", file=sys.stderr)
|
|
297
|
+
any_error = True
|
|
298
|
+
except sqlite3.Error as se:
|
|
299
|
+
print(f"SQL ERROR on table {tbl!r}: {se}", file=sys.stderr)
|
|
300
|
+
any_error = True
|
|
301
|
+
|
|
302
|
+
conn.close()
|
|
303
|
+
return 1 if any_error else 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
import sys
|
|
308
|
+
|
|
309
|
+
sys.exit(cli_main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from sqlite_chronicle import cli_main
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_cli_main_success(tmp_path, capsys):
|
|
6
|
+
# Setup a simple DB file with a table with PRIMARY KEY
|
|
7
|
+
db_path = tmp_path / "test.db"
|
|
8
|
+
conn = sqlite3.connect(str(db_path))
|
|
9
|
+
conn.execute("CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)")
|
|
10
|
+
conn.execute("INSERT INTO t1 (val) VALUES (?)", ("foo",))
|
|
11
|
+
conn.commit()
|
|
12
|
+
conn.close()
|
|
13
|
+
|
|
14
|
+
# Enable chronicle on the table
|
|
15
|
+
exit_code = cli_main([str(db_path), "t1"])
|
|
16
|
+
captured = capsys.readouterr()
|
|
17
|
+
assert exit_code == 0
|
|
18
|
+
assert "chronicle enabled on table 't1'" in captured.out
|
|
19
|
+
|
|
20
|
+
# Enabling chronicle again on the same table should be no-op (success)
|
|
21
|
+
exit_code = cli_main([str(db_path), "t1"])
|
|
22
|
+
captured = capsys.readouterr()
|
|
23
|
+
assert exit_code == 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_cli_main_error_invalid_db(capsys):
|
|
27
|
+
# Test passing a non-existent DB path
|
|
28
|
+
fake_path = "/nonexistent/path/to.db"
|
|
29
|
+
exit_code = cli_main([fake_path, "t1"])
|
|
30
|
+
captured = capsys.readouterr()
|
|
31
|
+
|
|
32
|
+
assert exit_code == 1
|
|
33
|
+
assert f"ERROR: cannot open database '{fake_path}'" in captured.err
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_cli_main_bad_table(tmp_path, capsys):
|
|
37
|
+
# Create a table and then drop it to cause SQL error during processing
|
|
38
|
+
db_path = tmp_path / "test.db"
|
|
39
|
+
conn = sqlite3.connect(str(db_path))
|
|
40
|
+
conn.execute("CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)")
|
|
41
|
+
conn.commit()
|
|
42
|
+
# Drop t1 so enable_chronicle fails with SQL error
|
|
43
|
+
conn.execute("DROP TABLE t1")
|
|
44
|
+
conn.commit()
|
|
45
|
+
conn.close()
|
|
46
|
+
|
|
47
|
+
exit_code = cli_main([str(db_path), "t1"])
|
|
48
|
+
captured = capsys.readouterr()
|
|
49
|
+
|
|
50
|
+
assert exit_code == 1
|
|
51
|
+
assert "ERROR: Table 't1' does not exist" in captured.err
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import sqlite_utils
|
|
3
|
+
from sqlite_chronicle import enable_chronicle
|
|
4
|
+
import time
|
|
5
|
+
from unittest.mock import ANY
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize("table_name", ("dogs", "dogs and stuff", "weird.table.name"))
|
|
9
|
+
@pytest.mark.parametrize("pks", (["id"], ["id", "name"]))
|
|
10
|
+
def test_enable_chronicle(table_name, pks):
|
|
11
|
+
chronicle_table = f"_chronicle_{table_name}"
|
|
12
|
+
db = sqlite_utils.Database(memory=True)
|
|
13
|
+
db[table_name].insert_all(
|
|
14
|
+
[
|
|
15
|
+
{"id": 1, "name": "Cleo", "color": "black"},
|
|
16
|
+
],
|
|
17
|
+
pk=pks[0] if len(pks) == 1 else pks,
|
|
18
|
+
)
|
|
19
|
+
enable_chronicle(db.conn, table_name)
|
|
20
|
+
db[table_name].insert({"id": 2, "name": "Pancakes", "color": "corgi"})
|
|
21
|
+
# It should have the same primary keys
|
|
22
|
+
assert db[chronicle_table].pks == pks
|
|
23
|
+
# Should also have updated_ms and deleted columns
|
|
24
|
+
assert set(db[chronicle_table].columns_dict.keys()) == set(
|
|
25
|
+
pks + ["__added_ms", "__updated_ms", "__version", "__deleted"]
|
|
26
|
+
)
|
|
27
|
+
# With an index
|
|
28
|
+
assert db[chronicle_table].indexes[0].columns == ["__version"]
|
|
29
|
+
if pks == ["id"]:
|
|
30
|
+
expected = [
|
|
31
|
+
{
|
|
32
|
+
"id": 1,
|
|
33
|
+
"__added_ms": ANY,
|
|
34
|
+
"__updated_ms": ANY,
|
|
35
|
+
"__version": 1,
|
|
36
|
+
"__deleted": 0,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": 2,
|
|
40
|
+
"__added_ms": ANY,
|
|
41
|
+
"__updated_ms": ANY,
|
|
42
|
+
"__version": 2,
|
|
43
|
+
"__deleted": 0,
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
else:
|
|
47
|
+
expected = [
|
|
48
|
+
{
|
|
49
|
+
"id": 1,
|
|
50
|
+
"name": "Cleo",
|
|
51
|
+
"__added_ms": ANY,
|
|
52
|
+
"__updated_ms": ANY,
|
|
53
|
+
"__version": 1,
|
|
54
|
+
"__deleted": 0,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": 2,
|
|
58
|
+
"name": "Pancakes",
|
|
59
|
+
"__added_ms": ANY,
|
|
60
|
+
"__updated_ms": ANY,
|
|
61
|
+
"__version": 2,
|
|
62
|
+
"__deleted": 0,
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
rows = list(db[chronicle_table].rows)
|
|
66
|
+
assert rows == expected
|
|
67
|
+
for row in rows:
|
|
68
|
+
assert row["__added_ms"] != 0
|
|
69
|
+
assert row["__updated_ms"] != 0
|
|
70
|
+
assert row["__added_ms"] == row["__updated_ms"]
|
|
71
|
+
# Running it again should do nothing because table exists
|
|
72
|
+
enable_chronicle(db.conn, table_name)
|
|
73
|
+
# Insert a row
|
|
74
|
+
db[table_name].insert({"id": 3, "name": "Mango", "color": "orange"})
|
|
75
|
+
get_by = 3 if pks == ["id"] else (3, "Mango")
|
|
76
|
+
row = db[chronicle_table].get(get_by)
|
|
77
|
+
if pks == ["id"]:
|
|
78
|
+
assert row == {
|
|
79
|
+
"id": 3,
|
|
80
|
+
"__added_ms": ANY,
|
|
81
|
+
"__updated_ms": ANY,
|
|
82
|
+
"__version": 3,
|
|
83
|
+
"__deleted": 0,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
else:
|
|
87
|
+
assert row == {
|
|
88
|
+
"id": 3,
|
|
89
|
+
"name": "Mango",
|
|
90
|
+
"__added_ms": ANY,
|
|
91
|
+
"__updated_ms": ANY,
|
|
92
|
+
"__version": 3,
|
|
93
|
+
"__deleted": 0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
version = db[chronicle_table].get(get_by)["__version"]
|
|
97
|
+
updated_ms = db[chronicle_table].get(get_by)["__updated_ms"]
|
|
98
|
+
time.sleep(0.01)
|
|
99
|
+
# Update a row
|
|
100
|
+
db[table_name].update(get_by, {"color": "mango"})
|
|
101
|
+
assert db[chronicle_table].get(get_by)["__version"] > version
|
|
102
|
+
assert db[chronicle_table].get(get_by)["__updated_ms"] > updated_ms
|
|
103
|
+
# Delete a row
|
|
104
|
+
assert db[table_name].count == 3
|
|
105
|
+
time.sleep(0.01)
|
|
106
|
+
db[table_name].delete(get_by)
|
|
107
|
+
assert db[table_name].count == 2
|
|
108
|
+
assert db[chronicle_table].get(get_by)["__deleted"] == 1
|
|
109
|
+
new_version = db[chronicle_table].get(get_by)["__version"]
|
|
110
|
+
assert new_version > version
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("pks", (["foo"], ["foo", "bar"]))
|
|
114
|
+
def test_enable_chronicle_alternative_primary_keys(pks):
|
|
115
|
+
db = sqlite_utils.Database(memory=True)
|
|
116
|
+
db["dogs"].insert({"foo": 1, "bar": 2, "name": "Cleo", "color": "black"}, pk=pks)
|
|
117
|
+
enable_chronicle(db.conn, "dogs")
|
|
118
|
+
assert db["_chronicle_dogs"].pks == pks
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_upsert():
|
|
122
|
+
db = sqlite_utils.Database(memory=True)
|
|
123
|
+
dogs = db.table("dogs", pk="id").create(
|
|
124
|
+
{"id": int, "name": str, "color": str}, pk="id"
|
|
125
|
+
)
|
|
126
|
+
enable_chronicle(db.conn, "dogs")
|
|
127
|
+
dogs.insert({"id": 1, "name": "Cleo", "color": "black"})
|
|
128
|
+
dogs.upsert({"id": 2, "name": "Pancakes", "color": "corgi"})
|
|
129
|
+
|
|
130
|
+
def chronicle_rows():
|
|
131
|
+
return list(
|
|
132
|
+
db.query("select id, __version as version from _chronicle_dogs order by id")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert chronicle_rows() == [{"id": 1, "version": 1}, {"id": 2, "version": 2}]
|
|
136
|
+
|
|
137
|
+
# Upsert that should update the row
|
|
138
|
+
dogs.upsert({"id": 1, "name": "Cleo", "color": "brown"})
|
|
139
|
+
assert chronicle_rows() == [{"id": 1, "version": 3}, {"id": 2, "version": 2}]
|
|
140
|
+
|
|
141
|
+
# Upsert that should be a no-op
|
|
142
|
+
dogs.upsert({"id": 1, "name": "Cleo", "color": "brown"})
|
|
143
|
+
assert chronicle_rows() == [{"id": 1, "version": 3}, {"id": 2, "version": 2}]
|
|
@@ -59,7 +59,7 @@ def test_updates_since(db):
|
|
|
59
59
|
"name": "The fate of the crew on the Mary Celeste",
|
|
60
60
|
"year": "1872",
|
|
61
61
|
},
|
|
62
|
-
deleted=
|
|
62
|
+
deleted=False,
|
|
63
63
|
),
|
|
64
64
|
Change(
|
|
65
65
|
pks=(2,),
|
|
@@ -71,7 +71,7 @@ def test_updates_since(db):
|
|
|
71
71
|
"name": "The disappearance of the Amber Room",
|
|
72
72
|
"year": "1941",
|
|
73
73
|
},
|
|
74
|
-
deleted=
|
|
74
|
+
deleted=False,
|
|
75
75
|
),
|
|
76
76
|
]
|
|
77
77
|
last = changes[-1].version
|
|
@@ -84,7 +84,7 @@ def test_updates_since(db):
|
|
|
84
84
|
updated_ms=ANY,
|
|
85
85
|
version=3,
|
|
86
86
|
row={"id": 3, "name": "The lost city of Atlantis", "year": "360 BC"},
|
|
87
|
-
deleted=
|
|
87
|
+
deleted=False,
|
|
88
88
|
)
|
|
89
89
|
]
|
|
90
90
|
last2 = new_changes[-1].version
|
|
@@ -101,7 +101,7 @@ def test_updates_since(db):
|
|
|
101
101
|
"name": "The fate of the crew on the Mary Celeste",
|
|
102
102
|
"year": "unknown",
|
|
103
103
|
},
|
|
104
|
-
deleted=
|
|
104
|
+
deleted=False,
|
|
105
105
|
),
|
|
106
106
|
Change(
|
|
107
107
|
pks=(3,),
|
|
@@ -109,7 +109,7 @@ def test_updates_since(db):
|
|
|
109
109
|
updated_ms=ANY,
|
|
110
110
|
version=5,
|
|
111
111
|
row={"id": 3, "name": "The lost city of Atlantis", "year": "unknown"},
|
|
112
|
-
deleted=
|
|
112
|
+
deleted=False,
|
|
113
113
|
),
|
|
114
114
|
]
|
|
115
115
|
last3 = new_changes2[-1].version
|
|
@@ -122,7 +122,7 @@ def test_updates_since(db):
|
|
|
122
122
|
updated_ms=ANY,
|
|
123
123
|
version=6,
|
|
124
124
|
row={"id": 2, "name": None, "year": None},
|
|
125
|
-
deleted=
|
|
125
|
+
deleted=True,
|
|
126
126
|
)
|
|
127
127
|
]
|
|
128
128
|
|
|
@@ -144,12 +144,12 @@ def test_updates_since_more_rows_than_batch_size_in_an_update():
|
|
|
144
144
|
({"id": i, "name": "Name {}".format(i)} for i in range(201)), pk="id"
|
|
145
145
|
)
|
|
146
146
|
enable_chronicle(db.conn, "mysteries")
|
|
147
|
-
|
|
147
|
+
max_v = db.execute("select max(__version) from _chronicle_mysteries").fetchone()[0]
|
|
148
148
|
# Update them all in one go
|
|
149
149
|
with db.conn:
|
|
150
150
|
db.execute("update mysteries set name = 'Updated'")
|
|
151
151
|
|
|
152
|
-
changes = list(updates_since(db.conn, "mysteries", batch_size=100, since=
|
|
152
|
+
changes = list(updates_since(db.conn, "mysteries", batch_size=100, since=max_v))
|
|
153
153
|
assert len(changes) == 201
|
|
154
154
|
# Each change should have a different version
|
|
155
155
|
assert len(set(c.version for c in changes)) == 201
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import dataclasses
|
|
2
|
-
import sqlite3
|
|
3
|
-
import textwrap
|
|
4
|
-
from typing import Generator, Optional
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ChronicleError(Exception):
|
|
8
|
-
pass
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclasses.dataclass
|
|
12
|
-
class Change:
|
|
13
|
-
pks: tuple
|
|
14
|
-
added_ms: int
|
|
15
|
-
updated_ms: int
|
|
16
|
-
version: int
|
|
17
|
-
row: dict
|
|
18
|
-
deleted: bool
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def enable_chronicle(conn: sqlite3.Connection, table_name: str):
|
|
22
|
-
c = conn.cursor()
|
|
23
|
-
|
|
24
|
-
# Check if the _chronicle_ table exists
|
|
25
|
-
c.execute(
|
|
26
|
-
f"SELECT name FROM sqlite_master WHERE type='table' AND name='_chronicle_{table_name}';"
|
|
27
|
-
)
|
|
28
|
-
if c.fetchone():
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
# Determine primary key columns and their types
|
|
32
|
-
c.execute(f'PRAGMA table_info("{table_name}");')
|
|
33
|
-
primary_key_columns = [(row[1], row[2]) for row in c.fetchall() if row[5]]
|
|
34
|
-
if not primary_key_columns:
|
|
35
|
-
raise ChronicleError(f"Table {table_name} has no primary keys")
|
|
36
|
-
|
|
37
|
-
# Create the _chronicle_ table
|
|
38
|
-
pk_def = ", ".join(
|
|
39
|
-
[f'"{col_name}" {col_type}' for col_name, col_type in primary_key_columns]
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
current_time_expr = "CAST((julianday('now') - 2440587.5) * 86400 * 1000 AS INTEGER)"
|
|
43
|
-
next_version_expr = (
|
|
44
|
-
f'COALESCE((SELECT MAX(version) FROM "_chronicle_{table_name}"), 0) + 1'
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
with conn:
|
|
48
|
-
c.execute(
|
|
49
|
-
f"""
|
|
50
|
-
CREATE TABLE "_chronicle_{table_name}" (
|
|
51
|
-
{pk_def},
|
|
52
|
-
added_ms INTEGER,
|
|
53
|
-
updated_ms INTEGER,
|
|
54
|
-
version INTEGER DEFAULT 0,
|
|
55
|
-
deleted INTEGER DEFAULT 0,
|
|
56
|
-
PRIMARY KEY ({', '.join([f'"{col[0]}"' for col in primary_key_columns])})
|
|
57
|
-
);
|
|
58
|
-
"""
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
# Index on version column
|
|
62
|
-
c.execute(
|
|
63
|
-
f"CREATE INDEX '_chronicle_{table_name}_version' ON '_chronicle_{table_name}' (version);"
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Populate the _chronicle_ table with existing rows from the original table
|
|
67
|
-
c.execute(
|
|
68
|
-
f"""
|
|
69
|
-
INSERT INTO "_chronicle_{table_name}" (
|
|
70
|
-
{', '.join([col[0] for col in primary_key_columns])},
|
|
71
|
-
added_ms,
|
|
72
|
-
updated_ms,
|
|
73
|
-
version
|
|
74
|
-
)
|
|
75
|
-
SELECT
|
|
76
|
-
{', '.join([f'"{col[0]}"' for col in primary_key_columns])},
|
|
77
|
-
{current_time_expr},
|
|
78
|
-
{current_time_expr},
|
|
79
|
-
ROW_NUMBER() OVER (ORDER BY id)
|
|
80
|
-
FROM "{table_name}";
|
|
81
|
-
"""
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
# Create the after insert trigger
|
|
85
|
-
after_insert_sql = textwrap.dedent(
|
|
86
|
-
f"""
|
|
87
|
-
CREATE TRIGGER "_chronicle_{table_name}_ai"
|
|
88
|
-
AFTER INSERT ON "{table_name}"
|
|
89
|
-
FOR EACH ROW
|
|
90
|
-
BEGIN
|
|
91
|
-
INSERT INTO "_chronicle_{table_name}" ({', '.join([f'"{col[0]}"' for col in primary_key_columns])}, added_ms, updated_ms, version)
|
|
92
|
-
VALUES ({', '.join(['NEW.' + f'"{col[0]}"' for col in primary_key_columns])}, {current_time_expr}, {current_time_expr}, {next_version_expr});
|
|
93
|
-
END;
|
|
94
|
-
"""
|
|
95
|
-
)
|
|
96
|
-
c.execute(after_insert_sql)
|
|
97
|
-
|
|
98
|
-
# Create the after update trigger
|
|
99
|
-
c.execute(
|
|
100
|
-
f"""
|
|
101
|
-
CREATE TRIGGER "_chronicle_{table_name}_au"
|
|
102
|
-
AFTER UPDATE ON "{table_name}"
|
|
103
|
-
FOR EACH ROW
|
|
104
|
-
BEGIN
|
|
105
|
-
UPDATE "_chronicle_{table_name}"
|
|
106
|
-
SET updated_ms = {current_time_expr},
|
|
107
|
-
version = {next_version_expr},
|
|
108
|
-
{', '.join([f'"{col[0]}" = NEW."{col[0]}"' for col in primary_key_columns])}
|
|
109
|
-
WHERE { ' AND '.join([f'"{col[0]}" = OLD."{col[0]}"' for col in primary_key_columns]) };
|
|
110
|
-
END;
|
|
111
|
-
"""
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
# Create the after delete trigger
|
|
115
|
-
c.execute(
|
|
116
|
-
f"""
|
|
117
|
-
CREATE TRIGGER "_chronicle_{table_name}_ad"
|
|
118
|
-
AFTER DELETE ON "{table_name}"
|
|
119
|
-
FOR EACH ROW
|
|
120
|
-
BEGIN
|
|
121
|
-
UPDATE "_chronicle_{table_name}"
|
|
122
|
-
SET updated_ms = {current_time_expr},
|
|
123
|
-
version = {next_version_expr},
|
|
124
|
-
deleted = 1
|
|
125
|
-
WHERE { ' AND '.join([f'"{col[0]}" = OLD."{col[0]}"' for col in primary_key_columns]) };
|
|
126
|
-
END;
|
|
127
|
-
"""
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def updates_since(
|
|
132
|
-
conn: sqlite3.Connection,
|
|
133
|
-
table_name: str,
|
|
134
|
-
since: Optional[int] = None,
|
|
135
|
-
batch_size: int = 1000,
|
|
136
|
-
) -> Generator[Change, None, None]:
|
|
137
|
-
cursor = conn.cursor()
|
|
138
|
-
cursor.row_factory = sqlite3.Row
|
|
139
|
-
|
|
140
|
-
if since is None:
|
|
141
|
-
since = 0
|
|
142
|
-
|
|
143
|
-
# Find primary keys
|
|
144
|
-
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
145
|
-
columns = cursor.fetchall()
|
|
146
|
-
primary_keys = [col["name"] for col in columns if col["pk"]]
|
|
147
|
-
|
|
148
|
-
# Create the join_on clause based on primary keys
|
|
149
|
-
join_conditions = [f'chronicle."{pk}" = t."{pk}"' for pk in primary_keys]
|
|
150
|
-
join_on = " AND ".join(join_conditions)
|
|
151
|
-
|
|
152
|
-
# Select clause is primary keys from chronicle table, then columns from original table
|
|
153
|
-
select_clause = ", ".join(
|
|
154
|
-
[f"chronicle.{pk}" for pk in primary_keys]
|
|
155
|
-
+ [f"t.{col['name']}" for col in columns if col not in primary_keys]
|
|
156
|
-
+ [
|
|
157
|
-
"chronicle.added_ms as __chronicle_added_ms",
|
|
158
|
-
"chronicle.updated_ms as __chronicle_updated_ms",
|
|
159
|
-
"chronicle.version as __chronicle_version",
|
|
160
|
-
"CASE WHEN t.id IS NULL THEN 1 ELSE 0 END AS __chronicle_deleted",
|
|
161
|
-
]
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
# Paginate through ordered by version batch_size at a time
|
|
165
|
-
while True:
|
|
166
|
-
sql = textwrap.dedent(
|
|
167
|
-
f"""
|
|
168
|
-
SELECT {select_clause}
|
|
169
|
-
FROM "_chronicle_{table_name}" chronicle
|
|
170
|
-
LEFT JOIN "{table_name}" t ON {join_on}
|
|
171
|
-
WHERE chronicle.version > ?
|
|
172
|
-
ORDER BY chronicle.version
|
|
173
|
-
LIMIT {batch_size}
|
|
174
|
-
"""
|
|
175
|
-
)
|
|
176
|
-
rows = cursor.execute(sql, (since,)).fetchall()
|
|
177
|
-
|
|
178
|
-
if not rows:
|
|
179
|
-
break
|
|
180
|
-
|
|
181
|
-
for row in rows:
|
|
182
|
-
# Need row without __chronicle_ columns
|
|
183
|
-
row_without = dict(
|
|
184
|
-
(k, row[k]) for k in row.keys() if not k.startswith("__chronicle_")
|
|
185
|
-
)
|
|
186
|
-
added_ms = row["__chronicle_added_ms"]
|
|
187
|
-
updated_ms = row["__chronicle_updated_ms"]
|
|
188
|
-
since = row["__chronicle_version"]
|
|
189
|
-
yield Change(
|
|
190
|
-
pks=tuple(row[pk] for pk in primary_keys),
|
|
191
|
-
added_ms=added_ms,
|
|
192
|
-
updated_ms=updated_ms,
|
|
193
|
-
version=since,
|
|
194
|
-
row=row_without,
|
|
195
|
-
deleted=row["__chronicle_deleted"],
|
|
196
|
-
)
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import sqlite_utils
|
|
3
|
-
from sqlite_chronicle import enable_chronicle
|
|
4
|
-
import time
|
|
5
|
-
from unittest.mock import ANY
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.mark.parametrize("table_name", ("dogs", "dogs and stuff", "weird.table.name"))
|
|
9
|
-
@pytest.mark.parametrize("pks", (["id"], ["id", "name"]))
|
|
10
|
-
def test_enable_chronicle(table_name, pks):
|
|
11
|
-
chronicle_table = f"_chronicle_{table_name}"
|
|
12
|
-
db = sqlite_utils.Database(memory=True)
|
|
13
|
-
db[table_name].insert_all(
|
|
14
|
-
[
|
|
15
|
-
{"id": 1, "name": "Cleo", "color": "black"},
|
|
16
|
-
{"id": 2, "name": "Pancakes", "color": "corgi"},
|
|
17
|
-
],
|
|
18
|
-
pk=pks[0] if len(pks) == 1 else pks,
|
|
19
|
-
)
|
|
20
|
-
enable_chronicle(db.conn, table_name)
|
|
21
|
-
# It should have the same primary keys
|
|
22
|
-
assert db[chronicle_table].pks == pks
|
|
23
|
-
# Should also have updated_ms and deleted columns
|
|
24
|
-
assert set(db[chronicle_table].columns_dict.keys()) == set(
|
|
25
|
-
pks + ["added_ms", "updated_ms", "version", "deleted"]
|
|
26
|
-
)
|
|
27
|
-
# With an index
|
|
28
|
-
assert db[chronicle_table].indexes[0].columns == ["version"]
|
|
29
|
-
if pks == ["id"]:
|
|
30
|
-
expected = [
|
|
31
|
-
{
|
|
32
|
-
"id": 1,
|
|
33
|
-
"added_ms": ANY,
|
|
34
|
-
"updated_ms": ANY,
|
|
35
|
-
"version": 1,
|
|
36
|
-
"deleted": 0,
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"id": 2,
|
|
40
|
-
"added_ms": ANY,
|
|
41
|
-
"updated_ms": ANY,
|
|
42
|
-
"version": 2,
|
|
43
|
-
"deleted": 0,
|
|
44
|
-
},
|
|
45
|
-
]
|
|
46
|
-
else:
|
|
47
|
-
expected = [
|
|
48
|
-
{
|
|
49
|
-
"id": 1,
|
|
50
|
-
"name": "Cleo",
|
|
51
|
-
"added_ms": ANY,
|
|
52
|
-
"updated_ms": ANY,
|
|
53
|
-
"version": 1,
|
|
54
|
-
"deleted": 0,
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"id": 2,
|
|
58
|
-
"name": "Pancakes",
|
|
59
|
-
"added_ms": ANY,
|
|
60
|
-
"updated_ms": ANY,
|
|
61
|
-
"version": 2,
|
|
62
|
-
"deleted": 0,
|
|
63
|
-
},
|
|
64
|
-
]
|
|
65
|
-
rows = list(db[chronicle_table].rows)
|
|
66
|
-
assert rows == expected
|
|
67
|
-
# Running it again should do nothing because table exists
|
|
68
|
-
enable_chronicle(db.conn, table_name)
|
|
69
|
-
# Insert a row
|
|
70
|
-
db[table_name].insert({"id": 3, "name": "Mango", "color": "orange"})
|
|
71
|
-
get_by = 3 if pks == ["id"] else (3, "Mango")
|
|
72
|
-
row = db[chronicle_table].get(get_by)
|
|
73
|
-
if pks == ["id"]:
|
|
74
|
-
assert row == {
|
|
75
|
-
"id": 3,
|
|
76
|
-
"added_ms": ANY,
|
|
77
|
-
"updated_ms": ANY,
|
|
78
|
-
"version": 3,
|
|
79
|
-
"deleted": 0,
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
else:
|
|
83
|
-
assert row == {
|
|
84
|
-
"id": 3,
|
|
85
|
-
"name": "Mango",
|
|
86
|
-
"added_ms": ANY,
|
|
87
|
-
"updated_ms": ANY,
|
|
88
|
-
"version": 3,
|
|
89
|
-
"deleted": 0,
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
version = db[chronicle_table].get(get_by)["updated_ms"]
|
|
93
|
-
time.sleep(0.01)
|
|
94
|
-
# Update a row
|
|
95
|
-
db[table_name].update(get_by, {"color": "mango"})
|
|
96
|
-
assert db[chronicle_table].get(get_by)["updated_ms"] > version
|
|
97
|
-
# Delete a row
|
|
98
|
-
assert db[table_name].count == 3
|
|
99
|
-
time.sleep(0.01)
|
|
100
|
-
db[table_name].delete(get_by)
|
|
101
|
-
assert db[table_name].count == 2
|
|
102
|
-
assert db[chronicle_table].get(get_by)["deleted"] == 1
|
|
103
|
-
new_version = db[chronicle_table].get(get_by)["updated_ms"]
|
|
104
|
-
assert new_version > version
|
|
105
|
-
# Now update a column that's part of the compound primary key
|
|
106
|
-
time.sleep(0.1)
|
|
107
|
-
if pks == ["id", "name"]:
|
|
108
|
-
db[table_name].update((2, "Pancakes"), {"name": "Pancakes the corgi"})
|
|
109
|
-
# This should have renamed the row in the chronicle table as well
|
|
110
|
-
renamed_row = db[chronicle_table].get((2, "Pancakes the corgi"))
|
|
111
|
-
assert renamed_row["updated_ms"] > version
|
|
112
|
-
else:
|
|
113
|
-
# Update single primary key
|
|
114
|
-
db[table_name].update(2, {"id": 4})
|
|
115
|
-
# This should have renamed the row in the chronicle table as well
|
|
116
|
-
renamed_row = db[chronicle_table].get(4)
|
|
117
|
-
assert renamed_row["updated_ms"] > version
|
|
File without changes
|
{sqlite-chronicle-0.2 → sqlite_chronicle-0.3}/sqlite_chronicle.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|