From 181ae7bd689d9bacb2041712639e859c21b6591f Mon Sep 17 00:00:00 2001 From: Evert Timberg Date: Mon, 2 Aug 2021 17:27:41 -0400 Subject: [PATCH] feat: Add PostgresUUID field --- .github/workflows/cicd.yml | 29 +++++++- dev_requirements.txt | 8 +++ docker-compose.yml | 14 ++++ setup.py | 3 + src/ormar_postgres_extensions/__init__.py | 1 + .../fields/__init__.py | 1 + src/ormar_postgres_extensions/fields/uuid.py | 50 +++++++++++++ tests/conftest.py | 42 +++++++++++ tests/database.py | 10 +++ tests/fields/__init__.py | 0 tests/fields/test_uuid.py | 70 +++++++++++++++++++ 11 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/ormar_postgres_extensions/fields/__init__.py create mode 100644 src/ormar_postgres_extensions/fields/uuid.py create mode 100644 tests/conftest.py create mode 100644 tests/database.py create mode 100644 tests/fields/__init__.py create mode 100644 tests/fields/test_uuid.py diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 1eb98b4..4723c9c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -30,11 +30,10 @@ jobs: invoke lint tests: name: Tests - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" strategy: matrix: - os: [ubuntu-latest, windows-latest] python-version: ['3.6', '3.7', '3.8', '3.9', '3.10.0-beta.1'] fail-fast: false steps: @@ -51,6 +50,19 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: invoke test --coverage + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: DEV_USER + POSTGRES_PASSWORD: DEV_PASSWORD + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 version_checks: name: Dependency Version Constraint Checks runs-on: ubuntu-latest @@ -74,6 +86,19 @@ jobs: run: | . $VENV/bin/activate invoke test --coverage + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: DEV_USER + POSTGRES_PASSWORD: DEV_PASSWORD + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 dry_run: name: Build runs-on: ubuntu-latest diff --git a/dev_requirements.txt b/dev_requirements.txt index eafe132..b15774d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -87,6 +87,8 @@ pkginfo==1.7.1 # via twine pluggy==0.13.1 # via pytest +psycopg2-binary==2.9.1 + # via ormar-postgres-extensions py==1.10.0 # via pytest py-githooks==1.1.0 @@ -112,6 +114,10 @@ pynacl==1.4.0 pyparsing==2.4.7 # via packaging pytest==6.2.4 + # via + # ormar-postgres-extensions + # pytest-asyncio +pytest-asyncio==0.15.1 # via ormar-postgres-extensions readme-renderer==29.0 # via twine @@ -129,6 +135,8 @@ rfc3986==1.5.0 # via twine secretstorage==3.3.1 # via keyring +semver==2.13.0 + # via ormar-postgres-extensions six==1.16.0 # via # bleach diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6d2179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.6' +services: + postgres: + image: postgres:13 + environment: + POSTGRES_USER: DEV_USER + POSTGRES_PASSWORD: DEV_PASSWORD + networks: + - local + ports: + - 5432:5432 + +networks: + local: \ No newline at end of file diff --git a/setup.py b/setup.py index 69d3510..91658a9 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,9 @@ "codecov", "coverage[toml]", "invoke", + "psycopg2-binary", "pytest", + "pytest-asyncio", ] dev_requires = [ "black", @@ -27,6 +29,7 @@ "pip-tools", "py-githooks", "pygithub", + "semver", "twine", "wheel", *test_requires, diff --git a/src/ormar_postgres_extensions/__init__.py b/src/ormar_postgres_extensions/__init__.py index e69de29..9eb8999 100644 --- a/src/ormar_postgres_extensions/__init__.py +++ b/src/ormar_postgres_extensions/__init__.py @@ -0,0 +1 @@ +from .fields import PostgresUUID # noqa: F401 diff --git a/src/ormar_postgres_extensions/fields/__init__.py b/src/ormar_postgres_extensions/fields/__init__.py new file mode 100644 index 0000000..a0cd7b1 --- /dev/null +++ b/src/ormar_postgres_extensions/fields/__init__.py @@ -0,0 +1 @@ +from .uuid import PostgresUUID # noqa: F401 diff --git a/src/ormar_postgres_extensions/fields/uuid.py b/src/ormar_postgres_extensions/fields/uuid.py new file mode 100644 index 0000000..9e7b763 --- /dev/null +++ b/src/ormar_postgres_extensions/fields/uuid.py @@ -0,0 +1,50 @@ +import uuid +from typing import ( + Any, + Optional, +) + +import ormar +from sqlalchemy.dialects import postgresql +from sqlalchemy.engine.default import DefaultDialect +from sqlalchemy.types import TypeDecorator + + +class PostgresUUIDTypeDecorator(TypeDecorator): + """ + Postgres specific GUID type for user with Ormar + """ + + impl = postgresql.UUID + + def process_literal_param( + self, value: Optional[uuid.UUID], dialect: DefaultDialect + ) -> Optional[str]: + # Literal parameters for PG UUID values need to be quoted inside + # of single quotes + return f"'{value}'" if value is not None else None + + def process_bind_param( + self, value: Optional[uuid.UUID], dialect: DefaultDialect + ) -> Optional[str]: + return str(value) if value is not None else None + + def process_result_value( + self, value: Optional[str], dialect: DefaultDialect + ) -> Optional[uuid.UUID]: + if value is None: + return value + if not isinstance(value, uuid.UUID): + return uuid.UUID(value) + return value + + +class PostgresUUID(ormar.UUID): + """ + Custom UUID field for the schema that uses a native PG UUID type + """ + + @classmethod + def get_column_type(cls, **kwargs: Any) -> PostgresUUIDTypeDecorator: + # Tell Ormar that this column should be a postgres UUID type + return PostgresUUIDTypeDecorator() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..958bda9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +import pytest +import sqlalchemy + +from .database import ( + DATABASE_URL, + DB_NAME, + database, + metadata, +) + + +@pytest.fixture() +def root_engine(): + root_engine = sqlalchemy.create_engine( + str(DATABASE_URL.replace(database="postgres")), isolation_level="AUTOCOMMIT" + ) + return root_engine + + +@pytest.fixture() +def test_database(root_engine): + with root_engine.connect() as conn: + print(f"Creating test database '{DB_NAME}'") + conn.execute(f'DROP DATABASE IF EXISTS "{DB_NAME}";') + conn.execute(f'CREATE DATABASE "{DB_NAME}"') + + yield + + with root_engine.connect() as conn: + root_engine.execute(f'DROP DATABASE "{DB_NAME}"') + + +@pytest.fixture() +async def db(test_database): + # Ensure the DB has the schema we need for testing + engine = sqlalchemy.create_engine(str(DATABASE_URL)) + metadata.create_all(engine) + engine.dispose() + + await database.connect() + yield + await database.disconnect() diff --git a/tests/database.py b/tests/database.py new file mode 100644 index 0000000..f7167ec --- /dev/null +++ b/tests/database.py @@ -0,0 +1,10 @@ +import databases +import sqlalchemy + +DB_HOST = "localhost" +DB_NAME = "TEST_DATABASE" +DATABASE_URL = databases.DatabaseURL( + f"postgres://DEV_USER:DEV_PASSWORD@{DB_HOST}:5432/{DB_NAME}" +) +database = databases.Database(str(DATABASE_URL)) +metadata = sqlalchemy.MetaData() diff --git a/tests/fields/__init__.py b/tests/fields/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fields/test_uuid.py b/tests/fields/test_uuid.py new file mode 100644 index 0000000..bb61363 --- /dev/null +++ b/tests/fields/test_uuid.py @@ -0,0 +1,70 @@ +from typing import Optional +from uuid import ( + UUID, + uuid4, +) + +import ormar +import pytest + +from ormar_postgres_extensions.fields import PostgresUUID +from tests.database import ( + database, + metadata, +) + + +class UUIDTestModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + uid: UUID = PostgresUUID(default=uuid4) + + +class NullableUUIDTestModel(ormar.Model): + class Meta: + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + uid: Optional[UUID] = PostgresUUID(nullable=True) + + +@pytest.mark.asyncio +async def test_create_model_with_uuid_specified(db): + created = await UUIDTestModel(uid="2b077a49-0dbe-4dd1-88a1-9aebe3cb7653").save() + assert str(created.uid) == "2b077a49-0dbe-4dd1-88a1-9aebe3cb7653" + assert isinstance(created.uid, UUID) + + # Confirm the model got saved to the DB by querying it back + found = await UUIDTestModel.objects.get() + assert found.uid == created.uid + assert isinstance(found.uid, UUID) + + +@pytest.mark.asyncio +async def test_get_model_by_uuid(db): + created = await UUIDTestModel(uid="2b077a49-0dbe-4dd1-88a1-9aebe3cb7653").save() + + found = await UUIDTestModel.objects.filter( + uid="2b077a49-0dbe-4dd1-88a1-9aebe3cb7653" + ).all() + assert len(found) == 1 + assert found[0] == created + + +@pytest.mark.asyncio +async def test_create_model_with_nullable_uuid(db): + created = await NullableUUIDTestModel().save() + assert created.uid is None + + +@pytest.mark.asyncio +async def test_get_model_with_nullable_uuid(db): + created = await NullableUUIDTestModel().save() + + # Ensure querying a model with a null UUID works + found = await NullableUUIDTestModel.objects.get() + assert found == created