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.
@@ -1,18 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite-chronicle
3
- Version: 0.2
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: License :: OSI Approved :: Apache Software License
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
- ## enable_chronicle(conn, table_name)
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 `added_ms`, `updated_ms`, `version` and `deleted`
38
- 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
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
- The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not have a single or compound primary key.
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 `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.
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 | added_ms | updated_ms | version | deleted |
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
- ## updates_since(conn, table_name, since=None, batch_size=1000)
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=0
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
- ## enable_chronicle(conn, table_name)
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 `added_ms`, `updated_ms`, `version` and `deleted`
22
- 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
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
- The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not have a single or compound primary key.
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 `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.
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 | added_ms | updated_ms | version | deleted |
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
- ## updates_since(conn, table_name, since=None, batch_size=1000)
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=0
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.2"
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 = {text = "Apache-2.0"}
7
+ license = "Apache-2.0"
8
8
  classifiers = [
9
- "License :: OSI Approved :: Apache Software License"
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
1
+ Metadata-Version: 2.4
2
2
  Name: sqlite-chronicle
3
- Version: 0.2
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: License :: OSI Approved :: Apache Software License
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
- ## enable_chronicle(conn, table_name)
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 `added_ms`, `updated_ms`, `version` and `deleted`
38
- 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
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
- The function will raise a `sqlite_chronicle.ChronicleError` exception if the table does not have a single or compound primary key.
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 `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.
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 | added_ms | updated_ms | version | deleted |
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
- ## updates_since(conn, table_name, since=None, batch_size=1000)
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=0
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.
@@ -6,5 +6,6 @@ sqlite_chronicle.egg-info/SOURCES.txt
6
6
  sqlite_chronicle.egg-info/dependency_links.txt
7
7
  sqlite_chronicle.egg-info/requires.txt
8
8
  sqlite_chronicle.egg-info/top_level.txt
9
+ tests/test_cli.py
9
10
  tests/test_sqlite_chronicle.py
10
11
  tests/test_updates_since.py
@@ -0,0 +1,4 @@
1
+
2
+ [test]
3
+ pytest
4
+ sqlite-utils>=4.0a0
@@ -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=0,
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=0,
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=0,
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=0,
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=0,
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=1,
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
- since_id = db.execute("select max(version) from _chronicle_mysteries").fetchone()[0]
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=since_id))
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,4 +0,0 @@
1
-
2
- [test]
3
- pytest
4
- sqlite-utils
@@ -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