diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77018bb..0193a41 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,8 @@ repos: hooks: - id: ruff args: [ "--fix" ] + + - repo: https://github.com/python-poetry/poetry + rev: '1.8.0' + hooks: + - id: poetry-check diff --git a/README.md b/README.md index 3958cb9..0b7e1ad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PynamoDB Single Table +A [Pydantic](https://docs.pydantic.dev/latest/) ORM built on top of [PynamoDB](https://github.com/pynamodb/PynamoDB). + [![PyPI](https://img.shields.io/pypi/v/pynamodb_single_table.svg)][pypi status] [![Status](https://img.shields.io/pypi/status/pynamodb_single_table.svg)][pypi status] [![Python Version](https://img.shields.io/pypi/pyversions/pynamodb_single_table)][pypi status] @@ -21,11 +23,73 @@ ## Features -- TODO +Provides a Django-inspired "Active Record"-style ORM using single-table design built on top of Django. + +```python +import abc +from datetime import datetime +from pydantic import Field, EmailStr +from pynamodb_single_table import SingleTableBaseModel + +class BaseTableModel(SingleTableBaseModel, abc.ABC): + class _PynamodbMeta: + table_name = "MyDynamoDBTable" + +class User(BaseTableModel): + __table_name__ = "user" + __str_id_field__ = "username" + username: str + email: EmailStr + account_activated_on: datetime = Field(default_factory=datetime.now) + +# Make sure the table exists in DynamoDB +BaseTableModel.ensure_table_exists(billing_mode="PAY_PER_REQUEST") + +# Create a record +john, was_created = User.get_or_create(username="john_doe", email="john.doe@email.com") +assert was_created + +# Retrieve +john_again = User.get_by_str("john_doe") +assert john_again.email == "john.doe@email.com" + +# Update +now = datetime.now() +john_again.account_activated_on = now +john_again.save() + +assert User.get_by_str("john_doe").account_activated_on == now + +# Delete +john_again.delete() +``` + +## Motivation + +Many use cases need little more than structured CRUD operations with a table-like design (e.g., for storing users and groups), but figuring out how to host that efficiently in the cloud can be a pain. + +DynamoDB is awesome for CRUD when you have clean keys. +It's a truly serverless NoSQL database, including nice features like: +- High performance CRUD operations when you know your primary keys +- Scale-to-zero usage-based pricing available +- Official local testing capability +- Conditional CRUD operations to avoid race conditions +- Multiple methods of indexing into data +- Scalable with reliable performance + +This project, in part, emerges from my frustration with the lack of many truly serverless SQL database services. +By "truly serverless", I mean purely usage-based pricing (generally a combination of storage costs and query costs). +Many small, startup applications use trivial amounts of query throughput and story trivial amounts of data, but finding a way to deploy such an application into the cloud without shelling out $10-$100's per month is tricky. +In AWS, now that Aurora Serverless V1 is being replaced, there is _no_ way to do this. + +However, DynamoDB provides not just the basic functionality needed to do this, it's actually a really good option if your data usage patterns can fit within its constraints. +That means, primarily, that you can always do key-based lookups, and that you can avoid changing your indexing strategy or database schema too much (e.g. modifying a table from having nullable columns into non-nullable). +DynamoDB _can_ do custom queries at tolerable rates, but you're going to get sub-par speed and cost efficiency if you're regularly doing searches across entire tables instead of direct hash key lookups. ## Requirements -- TODO +This project is built on the backs of Pydantic and Pynamodb. +I am incredibly grateful to the developers and communities of both of those projects. ## Installation @@ -35,10 +99,6 @@ You can install _PynamoDB Single Table_ via [pip] from [PyPI]: $ pip install pynamodb_single_table ``` -## Usage - -Please see the [Command-line Reference] for details. - ## Contributing Contributions are very welcome. @@ -68,4 +128,3 @@ This project was generated from [@cjolowicz]'s [Hypermodern Python Cookiecutter] [license]: https://github.com/scnerd/pynamodb_single_table/blob/main/LICENSE [contributor guide]: https://github.com/scnerd/pynamodb_single_table/blob/main/CONTRIBUTING.md -[command-line reference]: https://pynamodb_single_table.readthedocs.io/en/latest/usage.html diff --git a/poetry.lock b/poetry.lock index efdd110..7fbc479 100644 --- a/poetry.lock +++ b/poetry.lock @@ -271,6 +271,26 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "dnspython" +version = "2.3.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "dnspython-2.3.0-py3-none-any.whl", hash = "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46"}, + {file = "dnspython-2.3.0.tar.gz", hash = "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9"}, +] + +[package.extras] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] +dnssec = ["cryptography (>=2.6,<40.0)"] +doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.11.0)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + [[package]] name = "docutils" version = "0.19" @@ -282,6 +302,21 @@ files = [ {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] +[[package]] +name = "email-validator" +version = "2.0.0.post2" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.7" +files = [ + {file = "email_validator-2.0.0.post2-py3-none-any.whl", hash = "sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c"}, + {file = "email_validator-2.0.0.post2.tar.gz", hash = "sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "exceptiongroup" version = "1.2.1" @@ -602,6 +637,7 @@ files = [ [package.dependencies] annotated-types = ">=0.4.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} importlib-metadata = {version = "*", markers = "python_version == \"3.7\""} pydantic-core = "2.14.6" typing-extensions = ">=4.6.1" @@ -1171,4 +1207,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "12f3a0c35d1a69334243469b0c526682aa0f0b51b555df383dac0932586fdffa" +content-hash = "75dc59582f2fea30e09b8886b46a6c14089bbd3bcff0ca9909290549dfa3e704" diff --git a/pyproject.toml b/pyproject.toml index 9e17a46..70d78c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynamodb_single_table" -version = "0.1.0" +version = "0.1.1" description = "PynamoDB Single Table" authors = ["David Maxson "] license = "MIT" @@ -45,6 +45,7 @@ sphinx = ">=4.3.2" sphinx-autobuild = ">=2021.3.14" #sphinx-click = ">=3.0.2" myst-parser = {version = ">=0.16.1"} +pydantic = {extras = ["email"], version = "*"} [tool.poetry.group.constraints.dependencies] urllib3 = "<1.27" diff --git a/src/pynamodb_single_table/__init__.py b/src/pynamodb_single_table/__init__.py index d52b00d..ea7fff0 100644 --- a/src/pynamodb_single_table/__init__.py +++ b/src/pynamodb_single_table/__init__.py @@ -1 +1,5 @@ """PynamoDB Single Table.""" + +from .base import SingleTableBaseModel + +__all__ = ("SingleTableBaseModel",) diff --git a/src/pynamodb_single_table/base.py b/src/pynamodb_single_table/base.py index b14c6f8..91734ae 100644 --- a/src/pynamodb_single_table/base.py +++ b/src/pynamodb_single_table/base.py @@ -43,6 +43,7 @@ class SingleTableBaseModel(BaseModel): __pynamodb_model__: Type[RootModelPrototype] = None uid: Optional[uuid.UUID] = None + version: int = None def __init_subclass__(cls, **kwargs): if cls.__pynamodb_model__: @@ -69,6 +70,10 @@ class RootModel(RootModelPrototype): if not getattr(cls, "__str_id_field__", None): raise TypeError(f"Must define the string ID field for {cls}") + @classmethod + def ensure_table_exists(cls, **kwargs) -> None: + cls.__pynamodb_model__.create_table(wait=True, **kwargs) + @computed_field @property def str_id(self) -> str: @@ -122,16 +127,23 @@ def get_by_uid(cls, uuid_: uuid.UUID) -> Self: @classmethod def _from_item(cls, item) -> Self: - return cls(uid=item.uid, **item.data) + return cls(uid=item.uid, version=item.version, **item.data) - def create(self): + def _to_item(self) -> RootModelPrototype: item = self.__pynamodb_model__( self.__table_name__, str_id=self.str_id, - data=self.model_dump(mode="json", exclude={"uid", "str_id"}), + data=self.model_dump(mode="json", exclude={"uid", "version", "str_id"}), ) if self.uid is not None: item.uid = self.uid + if self.version is not None: + item.version = self.version + return item + + def create(self): + item = self._to_item() + condition = ( self.__pynamodb_model__.table_name.does_not_exist() & self.__pynamodb_model__.uid.does_not_exist() @@ -139,18 +151,18 @@ def create(self): item.save(condition=condition, add_version_condition=False) assert item.uid is not None self.uid = item.uid + self.version = item.version return self def save(self): - item = self.__pynamodb_model__( - self.__table_name__, - uid=self.uid, - str_id=self.str_id, - data=self.model_dump(mode="json", exclude={"uid", "str_id"}), - ) + item = self._to_item() item.save(add_version_condition=False) assert item.uid is not None self.uid = item.uid + self.version = item.version + + def delete(self): + self._to_item().delete() @classmethod def count(cls, *args, **kwargs): diff --git a/tests/test_basics.py b/tests/test_basics.py index 26ebd64..ac65a2b 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -5,7 +5,7 @@ import pytest -from pynamodb_single_table.base import SingleTableBaseModel +from pynamodb_single_table import SingleTableBaseModel class _BaseTableModel(SingleTableBaseModel, abc.ABC): @@ -34,9 +34,7 @@ class Group(_BaseTableModel): @pytest.fixture(scope="function", autouse=True) def recreate_pynamodb_table() -> Type[SingleTableBaseModel]: - _BaseTableModel.__pynamodb_model__.create_table( - wait=True, billing_mode="PAY_PER_REQUEST" - ) + _BaseTableModel.ensure_table_exists(billing_mode="PAY_PER_REQUEST") try: yield _BaseTableModel @@ -159,3 +157,16 @@ def test_scan(): assert len(users) == 2 groups = list(Group.scan()) assert len(groups) == 1 + + +def test_delete(): + user1, _ = User.get_or_create(name="John Doe") + user2, _ = User.get_or_create(name="Joe Schmoe") + + assert len(list(User.query())) == 2 + + print(list(User.__pynamodb_model__.scan())) + user1.delete() + assert len(list(User.query())) == 1 + (userx,) = list(User.query()) + assert userx.name == "Joe Schmoe"