Skip to content

Fix missing commits from the previous PR (#49) #50

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ Items marked with :fire: are high priority.

## General Plans and Ideas

- :fire: add (optional) `created_at` and `updated_at` fields to the BaseDBModel class
which will be automatically updated when a record is created or updated.
- add an 'execute' method to the main class to allow executing arbitrary SQL
queries which can be chained to the 'find_first' etc methods or just used
directly.
Expand All @@ -22,6 +20,12 @@ Items marked with :fire: are high priority.
- :fire: support structures like, `list`, `dict`, `set` etc. in the model. These will
need to be `pickled` first then stored as a BLOB in the database . Also
support `date` which can be stored as a Unix timestamp in an integer field.
- on update, check if the model has actually changed before sending the update
to the database. This will prevent unnecessary updates and leave the
`updated_at` correct. However, this will always require a query to the
database to check the current values and so in large batch updates this could
have a considerable performance impact. Probably best to gate this behind a
flag.

## Housekeeping

Expand Down
35 changes: 35 additions & 0 deletions docs/guide/data-ops.md → docs/guide/data-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ print(f"New record inserted with primary key: {result.pk}")
print(f"Name: {result.name}, Age: {result.age}, Email: {result.email}")
```

### Overriding the Timestamps

By default, SQLiter will automatically set the `created_at` and `updated_at`
fields to the current Unix timestamp in UTC when a record is inserted. If you
want to override this behavior, you can set the `created_at` and `updated_at`
fields manually before calling `insert()`:

```python
import time

user.created_at = int(time.time())
user.updated_at = int(time.time())
```

However, by default **this is disabled**. Any model passed to `insert()` will
have the `created_at` and `updated_at` fields set automatically and ignore any
values passed in these 2 fields.

If you want to enable this feature, you can set the `timestamp_override` flag to `True`
when inserting the record:

```python
result = db.insert(user, timestamp_override=True)
```

> [!IMPORTANT]
>
> The `insert()` method will raise a `RecordInsertionError` if you try to insert
Expand Down Expand Up @@ -74,6 +99,16 @@ db.update(user)
> You can also set the primary key value on the model instance manually before
> calling `update()` if you have that.

On suffescul update, the `updated_at` field will be set to the current Unix
timestamp in UTC by default.

> [!WARNING]
>
> Unlike with the `insert()` method, you **CANNOT** override the `updated_at`
> field when calling `update()`. It will always be set to the current Unix
> timestamp in UTC. This is to ensure that the `updated_at` field is always
> accurate.

## Deleting Records

To delete a record from the database, you need to pass the model class and the
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ the table.
> - Type-hints are **REQUIRED** for each field in the model.
> - The Model **automatically** creates an **auto-incrementing integer primary
> key** for each table called `pk`, you do not need to define it yourself.
> - The Model **automatically** creates a `created_at` and `updated_at` field
> which is an integer Unix timestamp **IN UTC** when the record was created or
> last updated. You can convert this timestamp to any format and timezone that
> you need.

### Adding Indexes

Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ nav:
- Connect to a Database: guide/connecting.md
- Properties: guide/properties.md
- Table Operations: guide/tables.md
- Data Operations: guide/data-ops.md
- Data Operations: guide/data-operations.md
- Transactions: guide/transactions.md
- Filtering Results: guide/filtering.md
- Ordering: guide/ordering.md
Expand Down
23 changes: 20 additions & 3 deletions sqliter/sqliter.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,11 +430,18 @@ def _maybe_commit(self) -> None:
if not self._in_transaction and self.auto_commit and self.conn:
self.conn.commit()

def insert(self, model_instance: T) -> T:
def insert(
self, model_instance: T, *, timestamp_override: bool = False
) -> T:
"""Insert a new record into the database.

Args:
model_instance: The instance of the model class to insert.
timestamp_override: If True, override the created_at and updated_at
timestamps with provided values. Default is False. If the values
are not provided, they will be set to the current time as
normal. Without this flag, the timestamps will always be set to
the current time, even if provided.

Returns:
The updated model instance with the primary key (pk) set.
Expand All @@ -447,8 +454,18 @@ def insert(self, model_instance: T) -> T:

# Always set created_at and updated_at timestamps
current_timestamp = int(time.time())
model_instance.created_at = current_timestamp
model_instance.updated_at = current_timestamp

# Handle the case where timestamp_override is False
if not timestamp_override:
# Always override both timestamps with the current time
model_instance.created_at = current_timestamp
model_instance.updated_at = current_timestamp
else:
# Respect provided values, but set to current time if they are 0
if model_instance.created_at == 0:
model_instance.created_at = current_timestamp
if model_instance.updated_at == 0:
model_instance.updated_at = current_timestamp

# Get the data from the model
data = model_instance.model_dump()
Expand Down
213 changes: 213 additions & 0 deletions tests/test_timestamps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Class to test the `created_at` and `updated_at` timestamps."""

from datetime import datetime, timezone

from tests.conftest import ExampleModel


Expand Down Expand Up @@ -48,3 +50,214 @@ def test_update_timestamps(self, db_mock, mocker) -> None:
assert (
returned_instance.updated_at == 1234567891
) # Should be updated to the new timestamp

def test_insert_with_provided_timestamps(self, db_mock, mocker) -> None:
"""Test that user-provided timestamps are respected on insert."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# User-provided timestamps
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=1111111111, # User-provided
updated_at=1111111111, # User-provided
)

# Perform the insert operation
returned_instance = db_mock.insert(
new_instance, timestamp_override=True
)

# Assert that the user-provided timestamps are respected
assert returned_instance.created_at == 1111111111
assert returned_instance.updated_at == 1111111111

def test_insert_with_default_timestamps(self, db_mock, mocker) -> None:
"""Test that timestamps are set when created_at and updated_at are 0."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# Create instance with default (0) timestamps
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=0,
updated_at=0,
)

# Perform the insert operation
returned_instance = db_mock.insert(new_instance)

# Assert that timestamps are set to the mocked time
assert returned_instance.created_at == 1234567890
assert returned_instance.updated_at == 1234567890

def test_insert_with_mixed_timestamps(self, db_mock, mocker) -> None:
"""Test a mix of user-provided and default timestamps work on insert."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# Provide only created_at, leave updated_at as 0
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=1111111111, # User-provided
updated_at=0, # Default to current time
)

# Perform the insert operation
returned_instance = db_mock.insert(
new_instance, timestamp_override=True
)

# Assert that created_at is respected, and updated_at is set to the
# current time
assert returned_instance.created_at == 1111111111
assert returned_instance.updated_at == 1234567890

def test_update_timestamps_on_change(self, db_mock, mocker) -> None:
"""Test that only `updated_at` changes on update."""
# Mock time.time() to return a fixed timestamp for the insert
mocker.patch("time.time", return_value=1234567890)

# Insert a new record
new_instance = ExampleModel(
slug="test", name="Test", content="Test content"
)
returned_instance = db_mock.insert(new_instance)

# Mock time.time() to return a new timestamp for the update
mocker.patch("time.time", return_value=1234567891)

# Update the record
returned_instance.name = "Updated Test"
db_mock.update(returned_instance)

# Assert that created_at stays the same and updated_at is changed
assert returned_instance.created_at == 1234567890
assert returned_instance.updated_at == 1234567891

def test_no_change_if_timestamps_already_set(self, db_mock, mocker) -> None:
"""Test timestamps are not modified if already set during insert."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# User provides both timestamps
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=1111111111, # Already set
updated_at=1111111111, # Already set
)

# Perform the insert operation
returned_instance = db_mock.insert(
new_instance, timestamp_override=True
)

# Assert that timestamps are not modified
assert returned_instance.created_at == 1111111111
assert returned_instance.updated_at == 1111111111

def test_override_but_no_timestamps_provided(self, db_mock, mocker) -> None:
"""Test missing timestamps always set to current time.

Even with `timestamp_override=True`.
"""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# User provides `0` for both timestamps, expecting them to be overridden
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=0, # Should default to current time
updated_at=0, # Should default to current time
)

# Perform the insert with timestamp_override=True
returned_instance = db_mock.insert(
new_instance, timestamp_override=True
)

# Assert that both timestamps are set to the current time, ignoring the
# `0`
assert returned_instance.created_at == 1234567890
assert returned_instance.updated_at == 1234567890

def test_partial_override_with_zero(self, db_mock, mocker) -> None:
"""Test changing `updated_at` only on create.

When `timestamp_override=True
"""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# User provides `created_at`, but leaves `updated_at` as 0
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=1111111111, # Provided by the user
updated_at=0, # Should be set to current time
)

# Perform the insert operation with timestamp_override=True
returned_instance = db_mock.insert(
new_instance, timestamp_override=True
)

# Assert that `created_at` is respected, and `updated_at` is set to the
# current time
assert returned_instance.created_at == 1111111111
assert returned_instance.updated_at == 1234567890

def test_insert_with_override_disabled(self, db_mock, mocker) -> None:
"""Test that timestamp_override=False ignores provided timestamps."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# User provides both timestamps, but they should be ignored
new_instance = ExampleModel(
slug="test",
name="Test",
content="Test content",
created_at=1111111111, # Should be ignored
updated_at=1111111111, # Should be ignored
)

# Perform the insert with timestamp_override=False (default)
returned_instance = db_mock.insert(
new_instance, timestamp_override=False
)

# Assert that both timestamps are set to the mocked current time
assert returned_instance.created_at == 1234567890
assert returned_instance.updated_at == 1234567890

def test_time_is_in_utc(self, db_mock, mocker) -> None:
"""Test that timestamps generated with time.time() are in UTC."""
# Mock time.time() to return a fixed timestamp
mocker.patch("time.time", return_value=1234567890)

# Insert a new instance
new_instance = ExampleModel(
slug="test", name="Test", content="Test content"
)
returned_instance = db_mock.insert(new_instance)

# Convert created_at to UTC datetime and verify the conversion
created_at_utc = datetime.fromtimestamp(
returned_instance.created_at, tz=timezone.utc
)

# Assert that the datetime is correctly interpreted as UTC
assert created_at_utc == datetime(
2009, 2, 13, 23, 31, 30, tzinfo=timezone.utc
)