Skip to content

Commit ffde9e9

Browse files
authored
Merge pull request #49 from seapagan/add-timestamps
2 parents 86722f2 + 9e1de16 commit ffde9e9

File tree

8 files changed

+107
-18
lines changed

8 files changed

+107
-18
lines changed

sqliter/model/model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ class BaseDBModel(BaseModel):
3838
"""
3939

4040
pk: int = Field(0, description="The mandatory primary key of the table.")
41+
created_at: int = Field(
42+
default=0,
43+
description="Unix timestamp when the record was created.",
44+
)
45+
updated_at: int = Field(
46+
default=0,
47+
description="Unix timestamp when the record was last updated.",
48+
)
4149

4250
model_config = ConfigDict(
4351
extra="ignore",

sqliter/sqliter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import logging
1212
import sqlite3
13+
import time
1314
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union
1415

1516
from typing_extensions import Self
@@ -444,6 +445,11 @@ def insert(self, model_instance: T) -> T:
444445
model_class = type(model_instance)
445446
table_name = model_class.get_table_name()
446447

448+
# Always set created_at and updated_at timestamps
449+
current_timestamp = int(time.time())
450+
model_instance.created_at = current_timestamp
451+
model_instance.updated_at = current_timestamp
452+
447453
# Get the data from the model
448454
data = model_instance.model_dump()
449455
# remove the primary key field if it exists, otherwise we'll get
@@ -530,6 +536,10 @@ def update(self, model_instance: BaseDBModel) -> None:
530536

531537
primary_key = model_class.get_primary_key()
532538

539+
# Set updated_at timestamp
540+
current_timestamp = int(time.time())
541+
model_instance.updated_at = current_timestamp
542+
533543
fields = ", ".join(
534544
f"{field} = ?"
535545
for field in model_class.model_fields

tests/test_context_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ def test_commit_called_once_in_transaction(self, mocker, tmp_path) -> None:
116116
# Assert that the data was committed
117117
assert result is not None, "Data was not committed."
118118
assert (
119-
result[1] == "test"
120-
), f"Expected slug to be 'test', but got {result[0]}"
119+
result[3] == "test"
120+
), f"Expected slug to be 'test', but got {result[3]}"
121121

122122
# Close the new connection
123123
new_conn.close()

tests/test_debug_logging.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ def test_debug_sql_output_basic_query(
3939

4040
# Assert the SQL query was printed
4141
assert (
42-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
42+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
43+
'"age", "is_active", "score", '
4344
'"nullable_field" FROM "complex_model" WHERE age = 30.5'
4445
in caplog.text
4546
)
@@ -55,7 +56,8 @@ def test_debug_sql_output_string_values(
5556

5657
# Assert the SQL query was printed with the string properly quoted
5758
assert (
58-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
59+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
60+
'"age", "is_active", "score", '
5961
'"nullable_field" FROM "complex_model" WHERE name = \'Alice\''
6062
in caplog.text
6163
)
@@ -71,7 +73,8 @@ def test_debug_sql_output_multiple_conditions(
7173

7274
# Assert the SQL query was printed with multiple conditions
7375
assert (
74-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
76+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
77+
'"age", "is_active", "score", '
7578
'"nullable_field" FROM "complex_model" WHERE name = \'Alice\' AND '
7679
"age = 30.5" in caplog.text
7780
)
@@ -87,7 +90,8 @@ def test_debug_sql_output_order_and_limit(
8790

8891
# Assert the SQL query was printed with ORDER and LIMIT
8992
assert (
90-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
93+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
94+
'"age", "is_active", "score", '
9195
'"nullable_field" FROM "complex_model" ORDER BY "age" DESC LIMIT 1'
9296
in caplog.text
9397
)
@@ -114,7 +118,8 @@ def test_debug_sql_output_with_null_value(
114118

115119
# Assert the SQL query was printed with IS NULL
116120
assert (
117-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
121+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
122+
'"age", "is_active", "score", '
118123
'"nullable_field" FROM "complex_model" WHERE age IS NULL'
119124
in caplog.text
120125
)
@@ -199,8 +204,8 @@ def test_manual_logger_respects_debug_flag(self, caplog) -> None:
199204

200205
# Assert that log output was captured with the manually passed logger
201206
assert (
202-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
203-
in caplog.text
207+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
208+
'"age", "is_active", "score", ' in caplog.text
204209
)
205210

206211
def test_manual_logger_above_debug_level(self, caplog) -> None:
@@ -228,7 +233,8 @@ def test_debug_sql_output_no_matching_records(
228233

229234
# Assert that the SQL query was logged despite no matching records
230235
assert (
231-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
236+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
237+
'"age", "is_active", "score", '
232238
'"nullable_field" FROM "complex_model" WHERE age = 100'
233239
in caplog.text
234240
)
@@ -242,7 +248,8 @@ def test_debug_sql_output_empty_query(
242248

243249
# Assert that the SQL query was logged for a full table scan
244250
assert (
245-
'Executing SQL: SELECT "pk", "name", "age", "is_active", "score", '
251+
'Executing SQL: SELECT "pk", "created_at", "updated_at", "name", '
252+
'"age", "is_active", "score", '
246253
'"nullable_field" FROM "complex_model"' in caplog.text
247254
)
248255

tests/test_optional_fields.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ def test_exclude_all_fields_error(
415415
"address",
416416
"phone",
417417
"occupation",
418+
"created_at",
419+
"updated_at",
418420
]
419421
).fetch_all()
420422

tests/test_query.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -561,27 +561,35 @@ def test_query_mixed_valid_invalid_filter(self, db_mock) -> None:
561561

562562
def test_fetch_result_with_list_of_tuples(self, mocker) -> None:
563563
"""Test _fetch_result when _execute_query returns list of tuples."""
564+
# ensure we get a dependable timestamp
565+
mocker.patch("time.time", return_value=1234567890)
566+
564567
db = SqliterDB(memory=True)
565568

566569
# Create some mock tuples (mimicking database rows)
567570
mock_result = [
568-
("1", "john", "John", "content"),
569-
("2", "jane", "Jane", "content"),
571+
("1", "1234567890", "1234567890", "john", "John", "content"),
572+
("2", "1234567890", "1234567890", "jane", "Jane", "content"),
570573
]
571574

572575
# Mock the _execute_query method on the QueryBuilder instance
573576
query = db.select(ExampleModel)
574577
mocker.patch.object(query, "_execute_query", return_value=mock_result)
575578

576-
# Perform the fetch (this will internally call _fetch_result)
579+
# Perform the fetch_one (this will internally call _fetch_result)
577580
result = query.fetch_one()
578581

579582
# Assert that the result is the first tuple in the list and correct type
580583
# and content
581584
assert not isinstance(result, list)
582585
assert isinstance(result, ExampleModel)
583586
assert result == ExampleModel(
584-
pk=1, slug="john", name="John", content="content"
587+
pk=1,
588+
updated_at=1234567890,
589+
created_at=1234567890,
590+
slug="john",
591+
name="John",
592+
content="content",
585593
)
586594

587595
def test_exclude_pk_raises_valueerror(self) -> None:

tests/test_sqliter.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,9 @@ def test_insert_license(self, db_mock) -> None:
178178
cursor.execute("SELECT * FROM test_table WHERE slug = ?", ("mit",))
179179
result = cursor.fetchone()
180180
assert result[0] == 1
181-
assert result[1] == "mit"
182-
assert result[2] == "MIT License"
183-
assert result[3] == "MIT License Content"
181+
assert result[3] == "mit"
182+
assert result[4] == "MIT License"
183+
assert result[5] == "MIT License Content"
184184

185185
def test_fetch_license(self, db_mock) -> None:
186186
"""Test fetching a license by primary key."""
@@ -454,6 +454,8 @@ def test_select_with_exclude_all_fields_error(
454454
db_mock_detailed.select(
455455
DetailedPersonModel,
456456
exclude=[
457+
"created_at",
458+
"updated_at",
457459
"name",
458460
"age",
459461
"email",
@@ -542,6 +544,8 @@ def test_complex_model_field_types(self, db_mock) -> None:
542544
# Expected types in SQLite (INTEGER, REAL, TEXT, etc.)
543545
expected_types = {
544546
"pk": "INTEGER",
547+
"created_at": "INTEGER",
548+
"updated_at": "INTEGER",
545549
"name": "TEXT",
546550
"age": "REAL",
547551
"price": "REAL",

tests/test_timestamps.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Class to test the `created_at` and `updated_at` timestamps."""
2+
3+
from tests.conftest import ExampleModel
4+
5+
6+
class TestTimestamps:
7+
"""Test the `created_at` and `updated_at` timestamps."""
8+
9+
def test_insert_timestamps(self, db_mock, mocker) -> None:
10+
"""Test both timestamps are set on record insert."""
11+
# Mock time.time() to return a fixed timestamp
12+
mocker.patch("time.time", return_value=1234567890)
13+
14+
new_instance = ExampleModel(
15+
slug="test", name="Test", content="Test content"
16+
)
17+
18+
# Perform the insert operation
19+
returned_instance = db_mock.insert(new_instance)
20+
21+
# Assert that both created_at and updated_at are set to the mocked
22+
# timestamp
23+
assert returned_instance.created_at == 1234567890
24+
assert returned_instance.updated_at == 1234567890
25+
26+
def test_update_timestamps(self, db_mock, mocker) -> None:
27+
"""Test that the `updated_at` timestamp is updated on record update."""
28+
# Mock time.time() to return a fixed timestamp for the update
29+
mocker.patch("time.time", return_value=1234567890)
30+
31+
new_instance = ExampleModel(
32+
slug="test", name="Test", content="Test content"
33+
)
34+
35+
# Perform the insert operation
36+
returned_instance = db_mock.insert(new_instance)
37+
38+
mocker.patch("time.time", return_value=1234567891)
39+
40+
# Perform the update operation
41+
db_mock.update(returned_instance)
42+
43+
# Assert that created_at remains the same and updated_at changes to the
44+
# new mocked value
45+
assert (
46+
returned_instance.created_at == 1234567890
47+
) # Should remain unchanged
48+
assert (
49+
returned_instance.updated_at == 1234567891
50+
) # Should be updated to the new timestamp

0 commit comments

Comments
 (0)