diff --git a/.github/actions/publish/action.yaml b/.github/actions/publish/action.yaml index e119c21..e8830dc 100644 --- a/.github/actions/publish/action.yaml +++ b/.github/actions/publish/action.yaml @@ -34,9 +34,8 @@ runs: shell: bash - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: ${{ inputs.PYTHON_VERSION }} + run: uv python install ${{ inputs.PYTHON_VERSION }} + shell: bash - name: Build package run: uv build diff --git a/.github/actions/test-docs/action.yaml b/.github/actions/test-docs/action.yaml index 57e5ac7..b08ffff 100644 --- a/.github/actions/test-docs/action.yaml +++ b/.github/actions/test-docs/action.yaml @@ -9,14 +9,6 @@ inputs: description: Python version required: false default: "3.13" - POSTGRES_VERSION: - description: Postgres major version to use - required: false - default: 12 - CONTAINER_NETWORK: - description: Docker container network to use - required: false - default: pgmob-network runs: using: "composite" @@ -33,9 +25,8 @@ runs: shell: bash - name: Setup python - uses: actions/setup-python@v4 - with: - python-version: ${{ inputs.PYTHON_VERSION }} + run: uv python install ${{ inputs.PYTHON_VERSION }} + shell: bash - name: Install dependencies run: uv sync diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml new file mode 100644 index 0000000..29a9774 --- /dev/null +++ b/.github/workflows/auto-release.yaml @@ -0,0 +1,119 @@ +name: Auto Release from Changelog + +on: + push: + branches: + - main + paths: + - 'CHANGELOG.md' + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Parse Changelog + id: changelog + run: | + # Extract the latest version and its content from CHANGELOG.md + python3 << 'EOF' + import re + import sys + + with open('CHANGELOG.md', 'r') as f: + content = f.read() + + # Match version headers like ## [0.3.1] - 2026-02-10 + version_pattern = r'^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})' + matches = list(re.finditer(version_pattern, content, re.MULTILINE)) + + if not matches: + print("No version found in CHANGELOG.md", file=sys.stderr) + sys.exit(1) + + # Get the first (latest) version + first_match = matches[0] + version = first_match.group(1) + date = first_match.group(2) + + # Extract content between first and second version headers + start_pos = first_match.end() + if len(matches) > 1: + end_pos = matches[1].start() + body = content[start_pos:end_pos].strip() + else: + # If only one version, get everything after it until the end or separator + remaining = content[start_pos:] + separator_match = re.search(r'^---$', remaining, re.MULTILINE) + if separator_match: + body = remaining[:separator_match.start()].strip() + else: + body = remaining.strip() + + # Clean up the body - remove leading/trailing whitespace + body = body.strip() + + # Write outputs + with open('version.txt', 'w') as f: + f.write(version) + with open('body.txt', 'w') as f: + f.write(body) + + print(f"Version: {version}") + print(f"Date: {date}") + print(f"Body length: {len(body)} characters") + EOF + + # Set outputs + VERSION=$(cat version.txt) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + + # For multiline output, use delimiter + { + echo 'body<> $GITHUB_OUTPUT + + - name: Check if release exists + id: check_release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.changelog.outputs.tag }}" + + # Check if release exists + if gh release view "$TAG" &>/dev/null; then + echo "Release $TAG already exists" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Release $TAG does not exist" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create Release + if: steps.check_release.outputs.exists == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.changelog.outputs.tag }}" + VERSION="${{ steps.changelog.outputs.version }}" + + # Create release with changelog body + gh release create "$TAG" \ + --title "Release $VERSION" \ + --notes "${{ steps.changelog.outputs.body }}" \ + --verify-tag + + - name: Skip Release + if: steps.check_release.outputs.exists == 'true' + run: | + echo "Release ${{ steps.changelog.outputs.tag }} already exists. Skipping creation." diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index aaa7281..00836fe 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -5,6 +5,7 @@ on: paths: - 'docs/**' - '.github/**' + - 'src/pgmob/**' pull_request: branches: - main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 747da64..fcec58f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,12 +14,6 @@ jobs: uses: actions/checkout@v3 - name: Run tests uses: ./.github/actions/test - # - name: Bump version - # uses: ./.github/actions/bump-version - # with: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # GIT_USERNAME: ${{ secrets.GIT_USERNAME }} - # GIT_EMAIL: ${{ secrets.GIT_EMAIL }} - name: Publish to PyPI Test uses: ./.github/actions/publish with: diff --git a/.gitignore b/.gitignore index 6c2bf9f..7518e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ .DS_Store .mypy_cache/ .pytest_cache/ +.hypothesis/ # coverage artifacts/ @@ -19,3 +20,6 @@ coverage.xml # docs build docs/_build .vscode/launch.json + +# IDEs +.kiro/specs/ diff --git a/.windsurf/rules/api-standards.md b/.windsurf/rules/api-standards.md index a1733b8..8834b40 100644 --- a/.windsurf/rules/api-standards.md +++ b/.windsurf/rules/api-standards.md @@ -224,6 +224,150 @@ def owner(self, value: str) -> None: generic._set_ephemeral_attr(self, "owner", value) ``` +### Mixin Properties + +Mixin properties follow the same API standards as regular properties. They provide consistent getter/setter behavior with change tracking. + +#### Mixin Property Docstrings + +Mixin properties MUST include docstrings that describe the property: + +```python +class NamedObjectMixin: + """Mixin providing name property with change tracking.""" + + @property + def name(self) -> str: + """The object's name. + + Returns: + The name of the database object. + """ + return self._name + + @name.setter + def name(self, value: str) -> None: + """Set the object's name. + + Changes are queued and applied when alter() is called. + + Args: + value: New name for the object. + + Example: + >>> table.name = "new_table_name" + >>> table.alter() # Apply the name change + """ + from . import generic + generic._set_ephemeral_attr(self, "name", value) +``` + +#### Mixin Initialization in Object __init__ + +Objects using mixins MUST call mixin initialization methods in their `__init__`: + +```python +class Table( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + _DynamicObject, + _CollectionChild +): + """Postgres Table object. + + Args: + name: Table name + schema: Schema name (default: 'public') + owner: Table owner + cluster: Postgres cluster object + parent: Parent collection + oid: Table OID + + Attributes: + name: Table name (from NamedObjectMixin) + owner: Table owner (from OwnedObjectMixin) + schema: Schema name (from SchemaObjectMixin) + tablespace: Tablespace (from TablespaceObjectMixin) + row_security: Whether row security is enabled + oid: Table OID + """ + + def __init__( + self, + name: str, + schema: str = "public", + owner: Optional[str] = None, + cluster: "Cluster" = None, + parent: "TableCollection" = None, + oid: Optional[int] = None + ): + # Initialize base classes + super().__init__(kind="TABLE", cluster=cluster, oid=oid, name=name, schema=schema) + _CollectionChild.__init__(self, parent=parent) + + # Initialize mixins - REQUIRED for mixin properties to work + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + self._init_tablespace(None) + + # Table-specific attributes + self._row_security: bool = False +``` + +#### Documenting Mixin Properties in Class Docstrings + +When a class uses mixins, document which properties come from mixins in the class docstring: + +```python +class Sequence( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + _DynamicObject, + _CollectionChild +): + """Postgres sequence object. + + Attributes: + name: Sequence name (from NamedObjectMixin) + owner: Sequence owner (from OwnedObjectMixin) + schema: Schema name (from SchemaObjectMixin) + data_type: Data type of the sequence + start_value: Starting value + increment_by: Increment value + min_value: Minimum value + max_value: Maximum value + """ +``` + +#### Mixin Property Behavior + +Mixin properties behave identically to regular properties: + +- **Getters**: Return the current value +- **Setters**: Queue changes via `_set_ephemeral_attr()` for later application +- **Change tracking**: Changes are stored in `self._changes` dictionary +- **Application**: Changes are applied when `alter()` is called + +```python +# Using mixin properties +table = cluster.tables["users"] + +# Get property (from NamedObjectMixin) +print(table.name) # "users" + +# Set property (queues change) +table.name = "app_users" + +# Property is updated locally +print(table.name) # "app_users" + +# Apply change to database +table.alter() # Executes: ALTER TABLE users RENAME TO app_users +``` + ### Lazy Properties ```python from ._decorators import get_lazy_property diff --git a/.windsurf/rules/code-conventions.md b/.windsurf/rules/code-conventions.md index ff87359..61ac985 100644 --- a/.windsurf/rules/code-conventions.md +++ b/.windsurf/rules/code-conventions.md @@ -86,6 +86,16 @@ class _BaseCollection: class _LazyBaseCollection: pass + +# Suffix with "Mixin" for mixin classes +class NamedObjectMixin: + pass + +class OwnedObjectMixin: + pass + +class SchemaObjectMixin: + pass ``` ### Functions and Methods @@ -130,6 +140,174 @@ def is_connected(self) -> bool: return self._is_connected ``` +## Mixin Patterns + +### Mixin Naming Convention + +All mixin classes MUST be suffixed with "Mixin" to clearly identify them: + +```python +# Good: Clear mixin naming +class NamedObjectMixin: + """Provides name property.""" + pass + +class OwnedObjectMixin: + """Provides owner property.""" + pass + +# Bad: Missing Mixin suffix +class NamedObject: # Unclear if this is a mixin or concrete class + pass +``` + +### Mixin Initialization Pattern + +Mixins MUST NOT define `__init__` methods to avoid Method Resolution Order (MRO) conflicts. Instead, mixins provide explicit initialization methods prefixed with `_init_`: + +```python +class NamedObjectMixin: + """Mixin providing name property. + + Objects using this mixin must call _init_name() in their __init__ method. + """ + + def _init_name(self, name: str) -> None: + """Initialize the name attribute. + + Args: + name: The object's name + """ + self._name: str = name + + @property + def name(self) -> str: + """The object's name.""" + return self._name + + @name.setter + def name(self, value: str) -> None: + """Set the object's name.""" + from . import generic + generic._set_ephemeral_attr(self, "name", value) +``` + +### Calling Mixin Initialization Methods + +Objects using mixins MUST call each mixin's `_init_*()` method in their `__init__`: + +```python +class Table( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + _DynamicObject, + _CollectionChild +): + """Table with mixin properties.""" + + def __init__( + self, + name: str, + schema: str = "public", + owner: Optional[str] = None, + cluster: "Cluster" = None, + parent: "TableCollection" = None + ): + # Initialize base classes first + super().__init__(kind="TABLE", cluster=cluster, name=name, schema=schema) + _CollectionChild.__init__(self, parent=parent) + + # Initialize mixins - REQUIRED + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + + # Object-specific attributes + self._row_security: bool = False +``` + +### Mixin Inheritance Order + +When using multiple mixins, follow this inheritance order to ensure proper MRO: + +1. Mixins (left to right, most specific to least specific) +2. Base classes (_DynamicObject, _CollectionChild, etc.) +3. object (implicit) + +```python +# Good: Mixins before base classes +class Table( + NamedObjectMixin, # Mixin 1 + OwnedObjectMixin, # Mixin 2 + SchemaObjectMixin, # Mixin 3 + TablespaceObjectMixin, # Mixin 4 + _DynamicObject, # Base class 1 + _CollectionChild # Base class 2 +): + pass + +# Bad: Base classes before mixins +class Table( + _DynamicObject, # Wrong order + NamedObjectMixin, + OwnedObjectMixin +): + pass +``` + +### Why Mixins Don't Define __init__ + +Defining `__init__` in mixins causes MRO conflicts when multiple mixins are used: + +```python +# Bad: Mixin with __init__ (causes MRO issues) +class BadMixin: + def __init__(self, name: str): + self._name = name # Conflicts with other __init__ methods + +# Good: Mixin with explicit initialization +class GoodMixin: + def _init_name(self, name: str) -> None: + """Initialize name. Call from object's __init__.""" + self._name = name +``` + +The explicit initialization pattern: +- Avoids constructor signature conflicts +- Makes initialization order explicit and controllable +- Works correctly with Python's MRO +- Allows objects to choose which mixins to initialize + +### Mixin Property Pattern + +All mixin properties follow the same pattern: + +1. **Private attribute**: Store value in `self._attribute_name` +2. **Property getter**: Return the private attribute +3. **Property setter**: Call `_set_ephemeral_attr()` for change tracking +4. **Initialization method**: Set initial value via `_init_*()` method + +```python +class OwnedObjectMixin: + """Mixin providing owner property.""" + + def _init_owner(self, owner: Optional[str] = None) -> None: + """Initialize owner attribute.""" + self._owner: Optional[str] = owner + + @property + def owner(self) -> Optional[str]: + """The object's owner.""" + return self._owner + + @owner.setter + def owner(self, value: str) -> None: + """Set the object's owner. Queues change for alter().""" + from . import generic + generic._set_ephemeral_attr(self, "owner", value) +``` + ## Type Hints ### Always Use Type Hints diff --git a/.windsurf/rules/project-patterns.md b/.windsurf/rules/project-patterns.md index 42eda11..b4f9a3d 100644 --- a/.windsurf/rules/project-patterns.md +++ b/.windsurf/rules/project-patterns.md @@ -171,24 +171,78 @@ table_names = cluster.tables.keys() # Fast - uses metadata ## Inheritance and Mixins -### Using Mixins +### Overview + +PGMob uses mixins to provide common properties (name, owner, schema, tablespace) to PostgreSQL object classes. Mixins eliminate code duplication and ensure consistent behavior across all object types. + +Available mixins: +- **NamedObjectMixin**: Provides `name` property for objects with names +- **OwnedObjectMixin**: Provides `owner` property for objects with owners +- **SchemaObjectMixin**: Provides `schema` property for objects in schemas +- **TablespaceObjectMixin**: Provides `tablespace` property for objects in tablespaces + +### Mixin Initialization Pattern + +Mixins use explicit initialization methods (`_init_*()`) that must be called in the object's `__init__` method. This avoids constructor conflicts with multiple inheritance. + ```python -from .objects.generic import ( - _DynamicObject, - _CollectionChild, +from .objects.mixins import ( + NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin, - NamedObjectMixin + TablespaceObjectMixin ) +from .objects.generic import _DynamicObject, _CollectionChild -class MyObject( +class Table( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + TablespaceObjectMixin, _DynamicObject, - _CollectionChild, + _CollectionChild +): + """Table object with mixin properties.""" + + def __init__( + self, + name: str, + schema: str = "public", + owner: Optional[str] = None, + cluster: "Cluster" = None, + parent: "TableCollection" = None, + oid: Optional[int] = None + ): + # Initialize base classes + super().__init__(kind="TABLE", cluster=cluster, oid=oid, name=name, schema=schema) + _CollectionChild.__init__(self, parent=parent) + + # Initialize mixins - REQUIRED + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + self._init_tablespace(None) + + # Object-specific attributes + self._row_security: bool = False +``` + +### Creating Objects with Mixins + +#### Example 1: Object with Name, Owner, and Schema + +```python +from .objects.mixins import NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin +from .objects.generic import _DynamicObject, _CollectionChild + +class Sequence( + NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin, - NamedObjectMixin + _DynamicObject, + _CollectionChild ): - """Object with common properties via mixins.""" + """Sequence object.""" def __init__( self, @@ -196,18 +250,116 @@ class MyObject( schema: str = "public", owner: Optional[str] = None, cluster: "Cluster" = None, - **kwargs + parent: "SequenceCollection" = None, + oid: Optional[int] = None ): - super().__init__(kind="MY_OBJECT", cluster=cluster, name=name, schema=schema) - _CollectionChild.__init__(self, parent=kwargs.get('parent')) + super().__init__(kind="SEQUENCE", cluster=cluster, oid=oid, name=name, schema=schema) + _CollectionChild.__init__(self, parent=parent) # Initialize mixins - self.__init_name__(name) - self.__init_schema__(schema) - self.__init_owner__(owner) + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) - # Object-specific attributes - self._custom_attr = kwargs.get('custom_attr') + # Sequence-specific attributes + self._start_value: Optional[int] = None + self._increment_by: Optional[int] = None +``` + +#### Example 2: Object with Only Name and Owner + +```python +from .objects.mixins import NamedObjectMixin, OwnedObjectMixin +from .objects.generic import _DynamicObject, _CollectionChild + +class Schema( + NamedObjectMixin, + OwnedObjectMixin, + _DynamicObject, + _CollectionChild +): + """Schema object (does NOT inherit SchemaObjectMixin).""" + + def __init__( + self, + name: str, + owner: Optional[str] = None, + cluster: "Cluster" = None, + parent: "SchemaCollection" = None, + oid: Optional[int] = None + ): + super().__init__(kind="SCHEMA", cluster=cluster, oid=oid, name=name) + _CollectionChild.__init__(self, parent=parent) + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) +``` + +#### Example 3: Object with Only Name + +```python +from .objects.mixins import NamedObjectMixin +from .objects.generic import _DynamicObject, _CollectionChild + +class Role( + NamedObjectMixin, + _DynamicObject, + _CollectionChild +): + """Role object.""" + + def __init__( + self, + name: str, + cluster: "Cluster" = None, + parent: "RoleCollection" = None, + oid: Optional[int] = None, + password: Optional[str] = None + ): + super().__init__(kind="ROLE", cluster=cluster, oid=oid, name=name) + _CollectionChild.__init__(self, parent=parent) + + # Initialize mixin + self._init_name(name) + + # Role-specific attributes + self._password = password + self._superuser: bool = False + self._login: bool = False +``` + +### Which Mixins to Use + +Choose mixins based on the PostgreSQL object type: + +| Object Type | NamedObjectMixin | OwnedObjectMixin | SchemaObjectMixin | TablespaceObjectMixin | +|-------------|------------------|------------------|-------------------|----------------------| +| Table | ✓ | ✓ | ✓ | ✓ | +| View | ✓ | ✓ | ✓ | ✗ | +| Sequence | ✓ | ✓ | ✓ | ✗ | +| Schema | ✓ | ✓ | ✗ | ✗ | +| Role | ✓ | ✗ | ✗ | ✗ | +| Database | ✓ | ✓ | ✗ | ✓ | +| Procedure | ✓ | ✓ | ✓ | ✗ | + +**Note**: Schema objects do NOT inherit from SchemaObjectMixin (schemas don't belong to schemas). + +### Using Mixin Properties + +Mixin properties work exactly like regular properties with change tracking: + +```python +# Get property value +table = cluster.tables["users"] +print(table.name) # From NamedObjectMixin +print(table.owner) # From OwnedObjectMixin +print(table.schema) # From SchemaObjectMixin + +# Set property value (queues change for alter()) +table.owner = "new_owner" +table.schema = "app_schema" +table.alter() # Apply changes to database ``` ### Change Tracking Pattern @@ -527,6 +679,112 @@ def bulk_alter_owner(self, tables: List[Table], new_owner: str) -> None: self.execute(stmt) ``` +## Development Environment Setup + +### CRITICAL: Initial Setup Required + +Before starting any development work, you MUST set up the development environment: + +```bash +# Install all dependencies including dev tools +uv sync --extra dev --extra psycopg2-binary +``` + +This command: +- Installs all project dependencies +- Installs development tools (pytest, ruff, ty, etc.) +- Installs the psycopg2-binary adapter for PostgreSQL + +Run this command once at the start of development or whenever dependencies change. + +## Code Quality Verification + +### CRITICAL: Always Run After Code Changes + +After implementing ANY code change to the codebase, you MUST run the following checks in order: + +1. **Unit Tests**: Verify functionality works correctly +2. **Linting**: Ensure code style compliance +3. **Type Checks**: Verify type safety + +### Running Tests + +```bash +# Run all tests +uv run pytest + +# Run specific test file +uv run pytest src/tests/test_mixins.py + +# Run with coverage +uv run pytest --cov=src/pgmob --cov-report=term-missing + +# Run tests matching pattern +uv run pytest -k "test_mixin" +``` + +### Running Linting + +```bash +# Run ruff linter (checks code style and common issues) +uv run ruff check src/ + +# Run ruff with auto-fix +uv run ruff check --fix src/ + +# Check specific file +uv run ruff check src/pgmob/objects/mixins.py +``` + +### Running Type Checks + +```bash +# Run ty type checker +uv run ty check + +# Type checking is project-wide, no single file option +``` + +### Complete Verification Workflow + +After making code changes, run this complete workflow: + +```bash +# 1. Run tests +uv run pytest + +# 2. Run linting +uv run ruff check src/ + +# 3. Run type checks +uv run ty check +``` + +# 3. Run type checks +uv run ty check +``` + +If any check fails, fix the issues before proceeding. Do not consider a code change complete until all three checks pass. + +### Handling Check Failures + +**Test Failures:** +- Review the test output to understand what failed +- Fix the implementation or update tests if requirements changed +- Re-run tests to verify the fix + +**Linting Failures:** +- Use `uv run ruff check --fix` to auto-fix simple issues +- Manually fix remaining issues following PEP 8 and project conventions +- Re-run linting to verify compliance + +**Type Check Failures:** +- Add missing type hints +- Fix incorrect type annotations +- Use `# type: ignore` only as a last resort with a comment explaining why +- Re-run type checks to verify fixes +- Note: Type warnings in mixin classes about `_set_ephemeral_attr` expecting `_DynamicObject` are expected and safe to ignore, as mixins are only used with classes that inherit from `_DynamicObject` + ## Documentation Patterns ### Example in Docstring diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f32036..71fcfd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-02-10 + +### Changed + +- **Internal Refactoring**: Introduced mixin-based architecture to eliminate code duplication + - Created reusable mixin classes for common properties (name, owner, schema, tablespace) + - Refactored 8 object classes (Table, View, Sequence, Database, Schema, Role, Procedure/Function/Aggregate/WindowFunction, LargeObject) to use mixins + - Reduced code duplication by 30-40% across object types + - Improved maintainability - common property changes now only need to be made in one place + - Enhanced type safety with proper type hints on all mixin properties + - Full backward compatibility maintained - no API changes + ## [0.3.0] - 2026-02-06 ### Breaking Changes diff --git a/pyproject.toml b/pyproject.toml index d9bfb68..7a50f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pgmob" -version = "0.3.0" +version = "0.3.1" description = "Postgres Managed Objects - a Postgres database management interface" authors = [ {name = "Kirill Kravtsov", email = "nvarscar@gmail.com"} @@ -31,6 +31,7 @@ dev = [ "ruff>=0.15.0", "ty>=0.0.15", "pyyaml>=6.0.3", + "hypothesis>=6.0.0", ] [project.urls] diff --git a/src/pgmob/objects/databases.py b/src/pgmob/objects/databases.py index c7f8f7d..02cb110 100644 --- a/src/pgmob/objects/databases.py +++ b/src/pgmob/objects/databases.py @@ -8,12 +8,13 @@ from ..errors import PostgresError from ..sql import SQL, Composable, Identifier, Literal from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class Database(generic._DynamicObject, generic._CollectionChild): +class Database(NamedObjectMixin, OwnedObjectMixin, generic._DynamicObject, generic._CollectionChild): """ Postgres Database object. Represents a database object on a Postgres server. @@ -58,7 +59,12 @@ def __init__( ): super().__init__(cluster=cluster, name=name, kind="DATABASE", oid=oid) generic._CollectionChild.__init__(self, parent=parent) - self._owner = owner + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) + + # Database-specific attributes self._encoding = encoding self._collation = collation self._is_template = is_template @@ -81,22 +87,6 @@ def _modify(self, column: str, value: Any): self.cluster.execute(sql, self.name) # properties - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - @property def encoding(self) -> str | None: return self._encoding diff --git a/src/pgmob/objects/hba_rules.py b/src/pgmob/objects/hba_rules.py index df1bb11..fa6e318 100644 --- a/src/pgmob/objects/hba_rules.py +++ b/src/pgmob/objects/hba_rules.py @@ -179,11 +179,11 @@ def __add__(self, other: Iterable[HBARule]): return self @override - def extend(self, item: Iterable[HBARule]): # type: ignore[override] # Intentional: more specific than parent's Iterable[HBARule] + def extend(self, item: Iterable[str | HBARule]): # type: ignore[override] # Intentional: accepts str | HBARule, not just HBARule """Add multiple HBA rules to the collection Args: - item(Iterable[Any]): An iterable of lines from pg_hba as strings or HBARule object + item(Iterable[str | HBARule]): An iterable of lines from pg_hba as strings or HBARule objects """ self.data.extend([HBARule(x) for x in item]) diff --git a/src/pgmob/objects/large_objects.py b/src/pgmob/objects/large_objects.py index bfb055c..a665051 100644 --- a/src/pgmob/objects/large_objects.py +++ b/src/pgmob/objects/large_objects.py @@ -10,12 +10,13 @@ from pgmob.sql import SQL, Literal from . import generic +from .mixins import OwnedObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class LargeObject(generic._DynamicObject, generic._CollectionChild): +class LargeObject(OwnedObjectMixin, generic._DynamicObject, generic._CollectionChild): """ Postgres LargeObject object. Represents a large object on a Postgres server. @@ -40,7 +41,9 @@ def __init__( """Initialize a new LargeObject object""" super().__init__(kind="LARGE OBJECT", cluster=cluster, oid=oid, name=str(oid)) generic._CollectionChild.__init__(self, parent=parent) - self._owner = owner + + # Initialize mixin + self._init_owner(owner) def _sql_fqn(self) -> Literal: return Literal(self._oid) @@ -55,14 +58,6 @@ def _with_lobject(self, task, mode="rw"): lo.close() return result - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - def drop(self): """Drops the largeobject from the Postgres cluster""" self._with_lobject(lambda lo: lo.unlink()) diff --git a/src/pgmob/objects/mixins.py b/src/pgmob/objects/mixins.py new file mode 100644 index 0000000..0bcb1ed --- /dev/null +++ b/src/pgmob/objects/mixins.py @@ -0,0 +1,218 @@ +"""Mixin classes for common PostgreSQL object properties. + +This module provides reusable mixin classes that encapsulate common property +patterns used across PostgreSQL object types. Each mixin handles a single +property concern and can be composed into object classes through multiple +inheritance. + +Mixins: + NamedObjectMixin: Provides name property with change tracking + OwnedObjectMixin: Provides owner property with change tracking + SchemaObjectMixin: Provides schema property with change tracking + TablespaceObjectMixin: Provides tablespace property with change tracking + +Usage: + Objects using these mixins must call the corresponding _init_*() method + in their __init__ method to initialize the private attributes. + +Example: + class Table(NamedObjectMixin, OwnedObjectMixin, _DynamicObject): + def __init__(self, name: str, owner: str | None = None): + _DynamicObject.__init__(self, kind="TABLE", name=name) + self._init_name(name) + self._init_owner(owner) +""" + + +class NamedObjectMixin: + """Mixin providing name property with change tracking. + + This mixin provides a name property that integrates with PGMob's change + tracking system. When the name is modified, the change is tracked for + later application via the alter() method. + + Objects using this mixin must call _init_name() in their __init__ method. + + Attributes: + name: The object's name (read/write property) + """ + + def _init_name(self, name: str) -> None: + """Initialize the name attribute. + + This method must be called from the object's __init__ method to + properly initialize the name attribute. + + Args: + name: The object's name + """ + self._name: str = name + + @property + def name(self) -> str: + """The object's name. + + Returns: + The current name of the object + """ + return self._name + + @name.setter + def name(self, value: str) -> None: + """Set the object's name. + + Setting the name creates a change tracking entry that will be + applied when alter() is called on the object. + + Args: + value: New name for the object + """ + from . import generic + + generic._set_ephemeral_attr(self, "name", value) # type: ignore[arg-type] # Mixin used with _DynamicObject + + +class OwnedObjectMixin: + """Mixin providing owner property with change tracking. + + This mixin provides an owner property that integrates with PGMob's change + tracking system. When the owner is modified, the change is tracked for + later application via the alter() method. + + Objects using this mixin must call _init_owner() in their __init__ method. + + Attributes: + owner: The object's owner (read/write property, can be None) + """ + + def _init_owner(self, owner: str | None = None) -> None: + """Initialize the owner attribute. + + This method must be called from the object's __init__ method to + properly initialize the owner attribute. + + Args: + owner: The object's owner (optional, defaults to None) + """ + self._owner: str | None = owner + + @property + def owner(self) -> str | None: + """The object's owner. + + Returns: + The current owner of the object, or None if no owner is set + """ + return self._owner + + @owner.setter + def owner(self, value: str) -> None: + """Set the object's owner. + + Setting the owner creates a change tracking entry that will be + applied when alter() is called on the object. + + Args: + value: New owner for the object + """ + from . import generic + + generic._set_ephemeral_attr(self, "owner", value) # type: ignore[arg-type] # Mixin used with _DynamicObject + + +class SchemaObjectMixin: + """Mixin providing schema property with change tracking. + + This mixin provides a schema property that integrates with PGMob's change + tracking system. When the schema is modified, the change is tracked for + later application via the alter() method. + + Objects using this mixin must call _init_schema() in their __init__ method. + + Attributes: + schema: The schema this object belongs to (read/write property) + """ + + def _init_schema(self, schema: str = "public") -> None: + """Initialize the schema attribute. + + This method must be called from the object's __init__ method to + properly initialize the schema attribute. + + Args: + schema: The schema name (defaults to "public") + """ + self._schema: str = schema + + @property + def schema(self) -> str: + """The schema this object belongs to. + + Returns: + The current schema name + """ + return self._schema + + @schema.setter + def schema(self, value: str) -> None: + """Set the object's schema. + + Setting the schema creates a change tracking entry that will be + applied when alter() is called on the object. + + Args: + value: New schema for the object + """ + from . import generic + + generic._set_ephemeral_attr(self, "schema", value) # type: ignore[arg-type] # Mixin used with _DynamicObject + + +class TablespaceObjectMixin: + """Mixin providing tablespace property with change tracking. + + This mixin provides a tablespace property that integrates with PGMob's + change tracking system. When the tablespace is modified, the change is + tracked for later application via the alter() method. + + Objects using this mixin must call _init_tablespace() in their __init__ + method. + + Attributes: + tablespace: The tablespace this object is assigned to (read/write + property, can be None) + """ + + def _init_tablespace(self, tablespace: str | None = None) -> None: + """Initialize the tablespace attribute. + + This method must be called from the object's __init__ method to + properly initialize the tablespace attribute. + + Args: + tablespace: The tablespace name (optional, defaults to None) + """ + self._tablespace: str | None = tablespace + + @property + def tablespace(self) -> str | None: + """The tablespace this object is assigned to. + + Returns: + The current tablespace name, or None if no tablespace is assigned + """ + return self._tablespace + + @tablespace.setter + def tablespace(self, value: str) -> None: + """Set the object's tablespace. + + Setting the tablespace creates a change tracking entry that will be + applied when alter() is called on the object. + + Args: + value: New tablespace for the object + """ + from . import generic + + generic._set_ephemeral_attr(self, "tablespace", value) # type: ignore[arg-type] # Mixin used with _DynamicObject diff --git a/src/pgmob/objects/procedures.py b/src/pgmob/objects/procedures.py index 4d7cbd1..37448ff 100644 --- a/src/pgmob/objects/procedures.py +++ b/src/pgmob/objects/procedures.py @@ -8,6 +8,7 @@ from ..errors import PostgresError from ..sql import SQL, Composable, Identifier from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin if TYPE_CHECKING: from ..cluster import Cluster @@ -42,7 +43,13 @@ class ParallelSafety(generic.AliasEnum): UNSAFE = "u" -class _BaseProcedure(generic._DynamicObject, generic._CollectionChild): +class _BaseProcedure( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + generic._DynamicObject, + generic._CollectionChild, +): """Postgres Procedure base object. Represents a stored procedure, function, window function or aggregate on a Postgres server. @@ -95,8 +102,14 @@ def __init__( ): super().__init__(kind=kind, cluster=cluster, oid=oid, name=name, schema=schema) generic._CollectionChild.__init__(self, parent=parent) + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + + # Procedure-specific attributes self._language = language - self._owner = owner self._security_definer = security_definer self._leak_proof = leak_proof self._strict = strict @@ -162,30 +175,6 @@ def refresh(self): mapper = _ProcedureMapper(result[0]) mapper.map(self) - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - - @property - def schema(self) -> str | None: - return self._schema - - @schema.setter - def schema(self, schema: str): - generic._set_ephemeral_attr(self, "schema", schema) - - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - class Procedure(_BaseProcedure): """Postgres Procedure object. Represents a stored procedure on a Postgres server. diff --git a/src/pgmob/objects/roles.py b/src/pgmob/objects/roles.py index 4db5162..25a443a 100644 --- a/src/pgmob/objects/roles.py +++ b/src/pgmob/objects/roles.py @@ -8,12 +8,13 @@ from ..errors import PostgresError from ..sql import SQL, Composable, Identifier, Literal from . import generic +from .mixins import NamedObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class Role(generic._DynamicObject, generic._CollectionChild): +class Role(NamedObjectMixin, generic._DynamicObject, generic._CollectionChild): """ Postgres Role object. Represents a role object on a Postgres server. @@ -34,7 +35,7 @@ class Role(generic._DynamicObject, generic._CollectionChild): parent (DatabaseCollection): parent collection Attributes: - name (str): Table name + name (str): Role name cluster (str): Postgres cluster object superuser (bool): SUPERUSER permissions inherit (bool): INHERIT permissions @@ -67,6 +68,11 @@ def __init__( ): super().__init__(kind="ROLE", cluster=cluster, oid=oid, name=name) generic._CollectionChild.__init__(self, parent=parent) + + # Initialize mixin + self._init_name(name) + + # Role-specific attributes self._password = password self._cluster = cluster self._superuser = superuser @@ -80,14 +86,6 @@ def __init__( self._valid_until = valid_until self._oid = oid - @property - def name(self): - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - @property def superuser(self) -> bool: return self._superuser diff --git a/src/pgmob/objects/schemas.py b/src/pgmob/objects/schemas.py index 8a4b34d..8f0e9a8 100644 --- a/src/pgmob/objects/schemas.py +++ b/src/pgmob/objects/schemas.py @@ -6,12 +6,13 @@ from ..errors import PostgresError from ..sql import SQL, Composable, Identifier from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class Schema(generic._DynamicObject, generic._CollectionChild): +class Schema(NamedObjectMixin, OwnedObjectMixin, generic._DynamicObject, generic._CollectionChild): """Postgres schema object. Represents a schema object on a Postgres cluster. Args: @@ -23,8 +24,8 @@ class Schema(generic._DynamicObject, generic._CollectionChild): Attributes: name (str): Schema name - cluster (str): Postgres cluster object owner (str): Schema owner + cluster (str): Postgres cluster object oid (int): Schema OID """ @@ -38,23 +39,10 @@ def __init__( ): super().__init__(cluster=cluster, name=name, kind="SCHEMA", oid=oid) generic._CollectionChild.__init__(self, parent=parent) - self._owner = owner - - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - - @property - def owner(self) -> str | None: - return self._owner - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) + # Initialize mixins + self._init_name(name) + self._init_owner(owner) def drop(self, cascade: bool = False): """Drops the schema from the Postgres cluster diff --git a/src/pgmob/objects/sequences.py b/src/pgmob/objects/sequences.py index be269ff..f0308f2 100644 --- a/src/pgmob/objects/sequences.py +++ b/src/pgmob/objects/sequences.py @@ -8,12 +8,19 @@ from ..errors import PostgresError from ..sql import SQL, Literal from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class Sequence(generic._DynamicObject, generic._CollectionChild): +class Sequence( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + generic._DynamicObject, + generic._CollectionChild, +): """Postgres sequence object. Represents a sequence on a Postgres server. Args: @@ -26,9 +33,9 @@ class Sequence(generic._DynamicObject, generic._CollectionChild): Attributes: name (str): Sequence name - cluster (str): Postgres cluster object - schema (str): Schema name owner (str): Sequence owner + schema (str): Schema name + cluster (str): Postgres cluster object data_type (str): Data type start_value (int): Sequence start value min_value (int): Sequence minimum value @@ -52,8 +59,13 @@ def __init__( """Initialize a new Sequence object""" super().__init__(kind="SEQUENCE", cluster=cluster, oid=oid, name=name, schema=schema) generic._CollectionChild.__init__(self, parent=parent) - self._owner = owner - self._schema: str = schema + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + + # Sequence-specific attributes self._data_type: str | None = None self._start_value: int | None = None self._min_value: int | None = None @@ -149,30 +161,6 @@ def cache_size(self) -> int | None: def last_value(self) -> int | None: return self._last_value - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - - @property - def schema(self) -> str: - return self._schema - - @schema.setter - def schema(self, schema: str): - generic._set_ephemeral_attr(self, "schema", schema) - - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - # methods def drop(self, cascade: bool = False): """Drops the sequence from the Postgres cluster diff --git a/src/pgmob/objects/tables.py b/src/pgmob/objects/tables.py index e148ddd..7928d39 100644 --- a/src/pgmob/objects/tables.py +++ b/src/pgmob/objects/tables.py @@ -8,12 +8,20 @@ from ..errors import PostgresError from ..sql import SQL, Identifier from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin, TablespaceObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class Table(generic._DynamicObject, generic._CollectionChild): +class Table( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + TablespaceObjectMixin, + generic._DynamicObject, + generic._CollectionChild, +): """Postgres Table object. Represents a table object on a Postgres server. Args: @@ -26,10 +34,10 @@ class Table(generic._DynamicObject, generic._CollectionChild): Attributes: name (str): Table name - cluster (str): Postgres cluster object - schema (str): Schema name owner (str): Table owner + schema (str): Schema name tablespace (str): Tablespace + cluster (str): Postgres cluster object row_security (bool): Whether the row security is enabled oid (int): Table OID """ @@ -46,9 +54,14 @@ def __init__( """Initialize a new Table object""" super().__init__(kind="TABLE", cluster=cluster, oid=oid, name=name, schema=schema) generic._CollectionChild.__init__(self, parent=parent) - self._schema: str = schema - self._owner = owner - self._tablespace: str | None = None + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + self._init_tablespace(None) + + # Table-specific attributes self._row_security: bool = False def drop(self, cascade: bool = False): @@ -102,30 +115,6 @@ def row_security(self, value: bool): ) self._row_security = value - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - - @property - def schema(self) -> str: - return self._schema - - @schema.setter - def schema(self, schema: str): - generic._set_ephemeral_attr(self, "schema", schema) - - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - class _TableMapper(generic._BaseObjectMapper[Table]): """Maps out a resultset from a database query to a table object""" diff --git a/src/pgmob/objects/views.py b/src/pgmob/objects/views.py index a721c75..c0bd8b5 100644 --- a/src/pgmob/objects/views.py +++ b/src/pgmob/objects/views.py @@ -8,12 +8,19 @@ from ..errors import PostgresError from ..sql import SQL from . import generic +from .mixins import NamedObjectMixin, OwnedObjectMixin, SchemaObjectMixin if TYPE_CHECKING: from ..cluster import Cluster -class View(generic._DynamicObject, generic._CollectionChild): +class View( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + generic._DynamicObject, + generic._CollectionChild, +): """Postgres View object. Represents a view object on a Postgres server. Args: @@ -44,8 +51,11 @@ def __init__( """Initialize a new View object""" super().__init__(kind="VIEW", cluster=cluster, oid=oid, name=name, schema=schema) generic._CollectionChild.__init__(self, parent=parent) - self._schema: str = schema - self._owner = owner + + # Initialize mixins + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) def drop(self, cascade: bool = False): """Drops the view from the Postgres cluster @@ -69,30 +79,6 @@ def refresh(self): mapper = _ViewMapper(result[0]) mapper.map(self) - @property - def owner(self) -> str | None: - return self._owner - - @owner.setter - def owner(self, owner: str): - generic._set_ephemeral_attr(self, "owner", owner) - - @property - def schema(self) -> str: - return self._schema - - @schema.setter - def schema(self, schema: str): - generic._set_ephemeral_attr(self, "schema", schema) - - @property - def name(self) -> str: - return self._name - - @name.setter - def name(self, name: str): - generic._set_ephemeral_attr(self, "name", name) - class _ViewMapper(generic._BaseObjectMapper[View]): """Maps out a resultset from a database query to a table object""" diff --git a/src/tests/functional/conftest.py b/src/tests/functional/conftest.py index 6e96a77..727dab3 100644 --- a/src/tests/functional/conftest.py +++ b/src/tests/functional/conftest.py @@ -5,8 +5,8 @@ from types import ModuleType import docker +import docker.errors import pytest -from docker.types import ContainerSpec from pgmob.cluster import Cluster @@ -124,7 +124,7 @@ def create_with_table(name): @pytest.fixture -def psql(container: ContainerSpec): +def psql(container): """Callable that runs a command locally in postgresql container using psql binary. Args: diff --git a/src/tests/functional/test_adapters.py b/src/tests/functional/test_adapters.py index f5b4ffc..e782b0c 100644 --- a/src/tests/functional/test_adapters.py +++ b/src/tests/functional/test_adapters.py @@ -50,7 +50,7 @@ def cursor(adapter): @pytest.fixture() -def lobject_factory(adapter: BaseAdapter, lo_ids_factory, db) -> BaseLargeObject: +def lobject_factory(adapter: BaseAdapter, lo_ids_factory, db): """Lobject object rw""" lo_ids = lo_ids_factory(db=db) @@ -61,7 +61,7 @@ def wrapper(mode="rw"): @pytest.fixture() -def lobject_r(adapter: BaseAdapter, lo_ids_factory, db) -> BaseLargeObject: +def lobject_r(adapter: BaseAdapter, lo_ids_factory, db): """Lobject object read""" lo_ids = lo_ids_factory(db=db) with adapter.lobject(lo_ids[0], "r") as lob: diff --git a/src/tests/functional/test_databases.py b/src/tests/functional/test_databases.py index e32a15b..80a415d 100644 --- a/src/tests/functional/test_databases.py +++ b/src/tests/functional/test_databases.py @@ -30,7 +30,7 @@ def test_init(self, db, databases: objects.DatabaseCollection): assert db_item.min_multixact_id is not None assert db_item.tablespace is not None assert db_item.acl is None - assert db_item.oid > 0 + assert db_item.oid is not None and db_item.oid > 0 assert str(db_item) == f"Database('{db}')" # setters @@ -63,7 +63,7 @@ def test_create(self, role, db, databases: objects.DatabaseCollection, psql): psql(f"DROP DATABASE {db}") db_obj = databases.new(name=db, owner=role, template="template0", is_template=False) db_obj.create() - assert db_obj.oid > 0 + assert db_obj.oid is not None and db_obj.oid > 0 assert psql(self.database_query.format(field="r.rolname", db=db)).output == role def test_script(self, db, databases: objects.DatabaseCollection, psql): diff --git a/src/tests/functional/test_roles.py b/src/tests/functional/test_roles.py index c27a588..a752ce5 100644 --- a/src/tests/functional/test_roles.py +++ b/src/tests/functional/test_roles.py @@ -1,5 +1,5 @@ import re -from datetime import date +from datetime import date, datetime import pytest @@ -40,15 +40,17 @@ def test_roles(self, roles: objects.RoleCollection, tmp_role: str): assert role_item.replication == False assert role_item.connection_limit == 10 assert role_item.bypassrls == False - assert role_item.valid_until.date() == day - assert role_item.oid > 0 + assert role_item.valid_until is not None and role_item.valid_until.date() == day + assert role_item.oid is not None and role_item.oid > 0 assert str(role_item) == f"Role('{tmp_role}')" # methods def test_get_password_md5(self, roles: objects.RoleCollection, tmp_role: str): role_item = roles[tmp_role] - assert isinstance(role_item.get_password_md5(), str) - assert re.search("^md5\\w{32}$", role_item.get_password_md5()) + password_md5 = role_item.get_password_md5() + assert isinstance(password_md5, str) + search_result = re.search("^md5\\w{32}$", password_md5) + assert search_result is not None def test_name(self, test_role, roles: objects.RoleCollection, psql, tmp_role): renamed = test_role.create("pgmobrenamerole") @@ -69,10 +71,14 @@ def test_create(self, tmp_role, roles: objects.RoleCollection, psql): psql(f"DROP ROLE {tmp_role}") day = date.today() role_obj = roles.new( - name=tmp_role, password="foobar", createdb=True, valid_until=day, connection_limit=2 + name=tmp_role, + password="foobar", + createdb=True, + valid_until=datetime.combine(day, datetime.min.time()), + connection_limit=2, ) role_obj.create() - assert role_obj.oid > 0 + assert role_obj.oid is not None and role_obj.oid > 0 assert psql(self.role_query.format(field="rolname", role=tmp_role)).output == tmp_role assert psql(self.role_query.format(field="rolvaliduntil::date", role=tmp_role)).output == str(day) assert psql(self.role_query.format(field="rolcreatedb", role=tmp_role)).output == "t" @@ -80,9 +86,10 @@ def test_create(self, tmp_role, roles: objects.RoleCollection, psql): def test_script(self, tmp_role, roles: objects.RoleCollection, psql): role_obj = roles[tmp_role] - assert re.match( - "CREATE ROLE.*CREATEROLE.*LOGIN.*CONNECTION LIMIT.*VALID UNTIL", role_obj.script().decode("utf8") - ) + script = role_obj.script() + script_str = script.decode("utf8") if isinstance(script, bytes) else str(script) + match_result = re.match("CREATE ROLE.*CREATEROLE.*LOGIN.*CONNECTION LIMIT.*VALID UNTIL", script_str) + assert match_result is not None def test_change_password(self, tmp_role: str, roles: objects.RoleCollection, psql): pw_query = f"SELECT rolpassword FROM pg_catalog.pg_authid WHERE rolname = '{tmp_role}'" diff --git a/src/tests/functional/test_schemas.py b/src/tests/functional/test_schemas.py index ce583bc..0ef6c59 100644 --- a/src/tests/functional/test_schemas.py +++ b/src/tests/functional/test_schemas.py @@ -90,7 +90,7 @@ def test_create(self, schemas: objects.SchemaCollection, db, role, psql): assert psql(f"DROP SCHEMA {tmp_schema}", db=db).exit_code == 0 schema_obj = schemas.new(name=tmp_schema, owner=role) schema_obj.create() - assert schema_obj.oid > 0 + assert schema_obj.oid is not None and schema_obj.oid > 0 assert ( psql(self.schema_query.format(field="n.nspname", schema=tmp_schema), db=db).output == tmp_schema ) @@ -99,4 +99,7 @@ def test_create(self, schemas: objects.SchemaCollection, db, role, psql): def test_script(self, schemas: objects.SchemaCollection): schema_obj = schemas["tmp"] - assert re.match("CREATE SCHEMA.* AUTHORIZATION .*", schema_obj.script().decode("utf8")) + script = schema_obj.script() + script_str = script.decode("utf8") if isinstance(script, bytes) else str(script) + match_result = re.match("CREATE SCHEMA.* AUTHORIZATION .*", script_str) + assert match_result is not None diff --git a/src/tests/test_backup.py b/src/tests/test_backup.py index 75c7315..994e834 100644 --- a/src/tests/test_backup.py +++ b/src/tests/test_backup.py @@ -100,6 +100,7 @@ def test_file_backup_shared_params(self, cluster): def test_file_backup_params(self, cluster): backup = FileBackup(cluster=cluster) + assert isinstance(backup.options, BackupOptions) backup.options.compress = True backup.options.exclude_table_data = ["a"] backup.options.blobs = False @@ -140,6 +141,7 @@ def test_gcp_backup_shared_params(self, cluster): def test_gcp_backup_paramrs(self, cluster): backup = GCPBackup(cluster=cluster) + assert isinstance(backup.options, BackupOptions) backup.options.compress = True backup.options.exclude_table_data = ["a"] @@ -156,6 +158,7 @@ def test_restore_init(self, cluster): restore = FileRestore(cluster=cluster) options = restore.options assert options.__class__ is RestoreOptions + assert isinstance(options, RestoreOptions) # shared assert options.data_only == False @@ -235,6 +238,7 @@ def test_file_restore_shared_params(self, cluster): def test_file_restore_params(self, cluster): restore = FileRestore(cluster=cluster) + assert isinstance(restore.options, RestoreOptions) restore.options.disable_triggers = True restore.options.indexes = ["a", "b"] restore.options.jobs = 4 @@ -287,6 +291,7 @@ def test_gcp_restore_shared_params(self, cluster): def test_gcp_restore_params(self, cluster): restore = GCPRestore(cluster=cluster) + assert isinstance(restore.options, RestoreOptions) restore.options.disable_triggers = True restore.options.indexes = ["a", "b"] restore.options.jobs = 4 diff --git a/src/tests/test_hba_rules.py b/src/tests/test_hba_rules.py index a78b3a5..512eb53 100644 --- a/src/tests/test_hba_rules.py +++ b/src/tests/test_hba_rules.py @@ -60,17 +60,17 @@ def test_init(self): class TestHBARuleCollection: def test_collection_equality(self): - col1 = HBARuleCollection(None) + col1 = HBARuleCollection(None) # type: ignore[arg-type] col1.extend([HBARule("host postgres")]) - col2 = HBARuleCollection(None) + col2 = HBARuleCollection(None) # type: ignore[arg-type] col2.extend([HBARule("host postgres")]) assert col1 == col2 - col2 = HBARuleCollection(None) + col2 = HBARuleCollection(None) # type: ignore[arg-type] col2.extend([HBARule("host postgres")]) assert col1 == col2 - col1 = HBARuleCollection(None) + col1 = HBARuleCollection(None) # type: ignore[arg-type] col1.extend( [ HBARule("host postgres"), @@ -81,7 +81,7 @@ def test_collection_equality(self): ] ) - col2 = HBARuleCollection(None) + col2 = HBARuleCollection(None) # type: ignore[arg-type] col2.extend( [ HBARule("host \tpostgres"), @@ -101,7 +101,7 @@ def test_inequality(self): assert HBARule("#comment") != HBARule("") def test_collection_in(self): - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection.extend( [ "#hba file", @@ -135,7 +135,7 @@ def test_collection_append(self): rule2 = HBARule(string2) rule3 = HBARule(string3) # adding rules - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection += [rule1] collection.extend([rule2]) collection.append(rule3) @@ -143,7 +143,7 @@ def test_collection_append(self): for r in collection: assert isinstance(r, HBARule) # appending a string - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection += [rule1] collection.append(string2) collection.extend([string3]) @@ -157,14 +157,14 @@ def test_collection_insert(self): rule2 = HBARule(string2) rule3 = HBARule("local postgres") # adding rules - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection += [rule1] collection.insert(0, rule3) assert collection == [rule3, rule1] for r in collection: assert isinstance(r, HBARule) # adding a string - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection += [rule1] collection.insert(1, string2) assert collection == [rule1, rule2] @@ -176,11 +176,11 @@ def test_collection_remove(self): rule1 = HBARule("#hba file") rule2 = HBARule(string2) rule3 = HBARule("local postgres") - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection.extend([rule1, rule3]) collection.remove(rule3) assert collection == [rule1] - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection.extend([rule1, rule2]) collection.remove(string2) assert collection == [rule1] @@ -189,7 +189,7 @@ def test_collection_index(self): string2 = "local postgres" rule1 = HBARule("#hba file") rule2 = HBARule(string2) - collection = HBARuleCollection(None) + collection = HBARuleCollection(None) # type: ignore[arg-type] collection.extend([rule1, rule2]) assert collection.index(rule1) == 0 assert collection.index(string2) == 1 diff --git a/src/tests/test_mixins.py b/src/tests/test_mixins.py new file mode 100644 index 0000000..9ccb27e --- /dev/null +++ b/src/tests/test_mixins.py @@ -0,0 +1,485 @@ +"""Tests for mixin classes. + +This module tests the mixin classes that provide common property patterns +for PostgreSQL objects. Tests cover property getters/setters, initialization, +composition, and change tracking behavior. +""" + +from hypothesis import given +from hypothesis import strategies as st + +from pgmob.objects.generic import _DynamicObject +from pgmob.objects.mixins import ( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + TablespaceObjectMixin, +) + + +# Test helper classes that combine mixins with _DynamicObject +class NamedTestObject(NamedObjectMixin, _DynamicObject): + """Test object with name property.""" + + def __init__(self, name: str, cluster=None): + _DynamicObject.__init__(self, kind="TEST", name=name, cluster=cluster) + self._init_name(name) + + +class OwnedTestObject(OwnedObjectMixin, _DynamicObject): + """Test object with owner property.""" + + def __init__(self, name: str, owner: str | None = None, cluster=None): + _DynamicObject.__init__(self, kind="TEST", name=name, cluster=cluster) + self._init_owner(owner) + + +class SchemaTestObject(SchemaObjectMixin, _DynamicObject): + """Test object with schema property.""" + + def __init__(self, name: str, schema: str = "public", cluster=None): + _DynamicObject.__init__(self, kind="TEST", name=name, schema=schema, cluster=cluster) + self._init_schema(schema) + + +class TablespaceTestObject(TablespaceObjectMixin, _DynamicObject): + """Test object with tablespace property.""" + + def __init__(self, name: str, tablespace: str | None = None, cluster=None): + _DynamicObject.__init__(self, kind="TEST", name=name, cluster=cluster) + self._init_tablespace(tablespace) + + +class ComposedTestObject( + NamedObjectMixin, + OwnedObjectMixin, + SchemaObjectMixin, + TablespaceObjectMixin, + _DynamicObject, +): + """Test object with all four mixins.""" + + def __init__( + self, + name: str, + owner: str | None = None, + schema: str = "public", + tablespace: str | None = None, + cluster=None, + ): + _DynamicObject.__init__(self, kind="TEST", name=name, schema=schema, cluster=cluster) + self._init_name(name) + self._init_owner(owner) + self._init_schema(schema) + self._init_tablespace(tablespace) + + +class TestNamedObjectMixin: + """Tests for NamedObjectMixin.""" + + def test_init_name_sets_private_attribute(self): + """Test that _init_name sets the _name attribute.""" + obj = NamedTestObject("test_name") + assert obj._name == "test_name" + + def test_name_getter_returns_private_attribute(self): + """Test that name property getter returns _name.""" + obj = NamedTestObject("test_name") + assert obj.name == obj._name + assert obj.name == "test_name" + + def test_name_getter_with_various_values(self): + """Test name getter with different string values.""" + test_values = ["simple", "with-dash", "with_underscore", "with.dot", "123numeric"] + for value in test_values: + obj = NamedTestObject(value) + assert obj.name == value + + def test_name_setter_creates_change_tracking_entry(self, mock_cluster): + """Test that setting name creates a change tracking entry.""" + obj = NamedTestObject("original", cluster=mock_cluster) + obj.name = "new_name" + + assert "name" in obj._changes + assert obj._name == "new_name" + + def test_name_setter_with_same_value_no_change(self, mock_cluster): + """Test that setting name to same value doesn't create change.""" + obj = NamedTestObject("test_name", cluster=mock_cluster) + obj.name = "test_name" + + assert "name" not in obj._changes + + # Feature: mixin-based-inheritance, Property 1: Mixin Property Getters Return Private Attributes + @given(name=st.text(min_size=1, max_size=100)) + def test_property_name_getter_returns_private_attr(self, name): + """Property test: name getter returns _name for any valid name.""" + obj = NamedTestObject(name) + assert obj.name == obj._name + assert obj.name == name + + +class TestOwnedObjectMixin: + """Tests for OwnedObjectMixin.""" + + def test_init_owner_sets_private_attribute(self): + """Test that _init_owner sets the _owner attribute.""" + obj = OwnedTestObject("test", owner="test_owner") + assert obj._owner == "test_owner" + + def test_init_owner_with_none(self): + """Test that _init_owner accepts None.""" + obj = OwnedTestObject("test", owner=None) + assert obj._owner is None + + def test_init_owner_default_none(self): + """Test that _init_owner defaults to None.""" + obj = OwnedTestObject("test") + assert obj._owner is None + + def test_owner_getter_returns_private_attribute(self): + """Test that owner property getter returns _owner.""" + obj = OwnedTestObject("test", owner="test_owner") + assert obj.owner == obj._owner + assert obj.owner == "test_owner" + + def test_owner_getter_returns_none(self): + """Test that owner getter returns None when not set.""" + obj = OwnedTestObject("test") + assert obj.owner is None + + def test_owner_setter_creates_change_tracking_entry(self, mock_cluster): + """Test that setting owner creates a change tracking entry.""" + obj = OwnedTestObject("test", owner="original", cluster=mock_cluster) + obj.owner = "new_owner" + + assert "owner" in obj._changes + assert obj._owner == "new_owner" + + def test_owner_setter_from_none(self, mock_cluster): + """Test setting owner from None.""" + obj = OwnedTestObject("test", owner=None, cluster=mock_cluster) + obj.owner = "new_owner" + + assert "owner" in obj._changes + assert obj._owner == "new_owner" + + # Feature: mixin-based-inheritance, Property 1: Mixin Property Getters Return Private Attributes + @given(owner=st.one_of(st.none(), st.text(min_size=1, max_size=100))) + def test_property_owner_getter_returns_private_attr(self, owner): + """Property test: owner getter returns _owner for any valid owner.""" + obj = OwnedTestObject("test", owner=owner) + assert obj.owner == obj._owner + assert obj.owner == owner + + +class TestSchemaObjectMixin: + """Tests for SchemaObjectMixin.""" + + def test_init_schema_sets_private_attribute(self): + """Test that _init_schema sets the _schema attribute.""" + obj = SchemaTestObject("test", schema="test_schema") + assert obj._schema == "test_schema" + + def test_init_schema_defaults_to_public(self): + """Test that _init_schema defaults to 'public'.""" + obj = SchemaTestObject("test") + assert obj._schema == "public" + + def test_schema_getter_returns_private_attribute(self): + """Test that schema property getter returns _schema.""" + obj = SchemaTestObject("test", schema="test_schema") + assert obj.schema == obj._schema + assert obj.schema == "test_schema" + + def test_schema_getter_returns_default(self): + """Test that schema getter returns 'public' by default.""" + obj = SchemaTestObject("test") + assert obj.schema == "public" + + def test_schema_setter_creates_change_tracking_entry(self, mock_cluster): + """Test that setting schema creates a change tracking entry.""" + obj = SchemaTestObject("test", schema="original", cluster=mock_cluster) + obj.schema = "new_schema" + + assert "schema" in obj._changes + assert obj._schema == "new_schema" + + # Feature: mixin-based-inheritance, Property 8: Schema Default Value + @given(name=st.text(min_size=1, max_size=100)) + def test_property_schema_defaults_to_public(self, name): + """Property test: schema defaults to 'public' when not specified.""" + obj = SchemaTestObject(name) + assert obj.schema == "public" + assert obj._schema == "public" + + # Feature: mixin-based-inheritance, Property 1: Mixin Property Getters Return Private Attributes + @given(schema=st.text(min_size=1, max_size=100)) + def test_property_schema_getter_returns_private_attr(self, schema): + """Property test: schema getter returns _schema for any valid schema.""" + obj = SchemaTestObject("test", schema=schema) + assert obj.schema == obj._schema + assert obj.schema == schema + + +class TestTablespaceObjectMixin: + """Tests for TablespaceObjectMixin.""" + + def test_init_tablespace_sets_private_attribute(self): + """Test that _init_tablespace sets the _tablespace attribute.""" + obj = TablespaceTestObject("test", tablespace="test_tablespace") + assert obj._tablespace == "test_tablespace" + + def test_init_tablespace_with_none(self): + """Test that _init_tablespace accepts None.""" + obj = TablespaceTestObject("test", tablespace=None) + assert obj._tablespace is None + + def test_init_tablespace_default_none(self): + """Test that _init_tablespace defaults to None.""" + obj = TablespaceTestObject("test") + assert obj._tablespace is None + + def test_tablespace_getter_returns_private_attribute(self): + """Test that tablespace property getter returns _tablespace.""" + obj = TablespaceTestObject("test", tablespace="test_tablespace") + assert obj.tablespace == obj._tablespace + assert obj.tablespace == "test_tablespace" + + def test_tablespace_getter_returns_none(self): + """Test that tablespace getter returns None when not set.""" + obj = TablespaceTestObject("test") + assert obj.tablespace is None + + def test_tablespace_setter_creates_change_tracking_entry(self, mock_cluster): + """Test that setting tablespace creates a change tracking entry.""" + obj = TablespaceTestObject("test", tablespace="original", cluster=mock_cluster) + obj.tablespace = "new_tablespace" + + assert "tablespace" in obj._changes + assert obj._tablespace == "new_tablespace" + + def test_tablespace_setter_from_none(self, mock_cluster): + """Test setting tablespace from None.""" + obj = TablespaceTestObject("test", tablespace=None, cluster=mock_cluster) + obj.tablespace = "new_tablespace" + + assert "tablespace" in obj._changes + assert obj._tablespace == "new_tablespace" + + # Feature: mixin-based-inheritance, Property 1: Mixin Property Getters Return Private Attributes + @given(tablespace=st.one_of(st.none(), st.text(min_size=1, max_size=100))) + def test_property_tablespace_getter_returns_private_attr(self, tablespace): + """Property test: tablespace getter returns _tablespace for any valid tablespace.""" + obj = TablespaceTestObject("test", tablespace=tablespace) + assert obj.tablespace == obj._tablespace + assert obj.tablespace == tablespace + + +class TestMixinComposition: + """Tests for objects using multiple mixins.""" + + def test_composed_object_has_all_properties(self): + """Test that composed object has all mixin properties.""" + obj = ComposedTestObject( + name="test_name", + owner="test_owner", + schema="test_schema", + tablespace="test_tablespace", + ) + + assert obj.name == "test_name" + assert obj.owner == "test_owner" + assert obj.schema == "test_schema" + assert obj.tablespace == "test_tablespace" + + def test_composed_object_with_defaults(self): + """Test composed object with default values.""" + obj = ComposedTestObject(name="test_name") + + assert obj.name == "test_name" + assert obj.owner is None + assert obj.schema == "public" + assert obj.tablespace is None + + def test_composed_object_all_setters_work(self, mock_cluster): + """Test that all property setters work on composed object.""" + obj = ComposedTestObject( + name="original_name", + owner="original_owner", + schema="original_schema", + tablespace="original_tablespace", + cluster=mock_cluster, + ) + + obj.name = "new_name" + obj.owner = "new_owner" + obj.schema = "new_schema" + obj.tablespace = "new_tablespace" + + assert "name" in obj._changes + assert "owner" in obj._changes + assert "schema" in obj._changes + assert "tablespace" in obj._changes + + assert obj.name == "new_name" + assert obj.owner == "new_owner" + assert obj.schema == "new_schema" + assert obj.tablespace == "new_tablespace" + + # Feature: mixin-based-inheritance, Property 4: Mixin Composition Provides All Properties + @given( + name=st.text(min_size=1, max_size=50), + owner=st.one_of(st.none(), st.text(min_size=1, max_size=50)), + schema=st.text(min_size=1, max_size=50), + tablespace=st.one_of(st.none(), st.text(min_size=1, max_size=50)), + ) + def test_property_composition_all_properties_accessible(self, name, owner, schema, tablespace): + """Property test: all mixin properties are accessible on composed object.""" + obj = ComposedTestObject( + name=name, + owner=owner, + schema=schema, + tablespace=tablespace, + ) + + assert obj.name == name + assert obj.owner == owner + assert obj.schema == schema + assert obj.tablespace == tablespace + + +class TestMixinChangeTracking: + """Tests for change tracking behavior with mixin properties.""" + + def test_name_change_creates_sql_change(self, mock_cluster): + """Test that name change creates correct SQL change object.""" + obj = NamedTestObject("original", cluster=mock_cluster) + obj.name = "new_name" + + assert "name" in obj._changes + change = obj._changes["name"] + assert hasattr(change, "sql") + + def test_owner_change_creates_sql_change(self, mock_cluster): + """Test that owner change creates correct SQL change object.""" + obj = OwnedTestObject("test", owner="original", cluster=mock_cluster) + obj.owner = "new_owner" + + assert "owner" in obj._changes + change = obj._changes["owner"] + assert hasattr(change, "sql") + + def test_schema_change_creates_sql_change(self, mock_cluster): + """Test that schema change creates correct SQL change object.""" + obj = SchemaTestObject("test", schema="original", cluster=mock_cluster) + obj.schema = "new_schema" + + assert "schema" in obj._changes + change = obj._changes["schema"] + assert hasattr(change, "sql") + + def test_tablespace_change_creates_sql_change(self, mock_cluster): + """Test that tablespace change creates correct SQL change object.""" + obj = TablespaceTestObject("test", tablespace="original", cluster=mock_cluster) + obj.tablespace = "new_tablespace" + + assert "tablespace" in obj._changes + change = obj._changes["tablespace"] + assert hasattr(change, "sql") + + def test_multiple_changes_tracked_separately(self, mock_cluster): + """Test that multiple property changes are tracked separately.""" + obj = ComposedTestObject( + name="original_name", + owner="original_owner", + schema="original_schema", + tablespace="original_tablespace", + cluster=mock_cluster, + ) + + obj.name = "new_name" + obj.owner = "new_owner" + + assert len(obj._changes) == 2 + assert "name" in obj._changes + assert "owner" in obj._changes + + # Feature: mixin-based-inheritance, Property 2: Mixin Property Setters Create Change Tracking Entries + @given( + original_name=st.text(min_size=1, max_size=50), + new_name=st.text(min_size=1, max_size=50), + ) + def test_property_setter_creates_change_entry(self, original_name, new_name): + """Property test: setter creates change tracking entry for different values.""" + # Skip if values are the same (no change should be tracked) + if original_name == new_name: + return + + # Create a mock cluster inline instead of using fixture + from unittest.mock import Mock + + from pgmob.adapters.base import BaseAdapter + from pgmob.cluster import Cluster + + mock_cluster = Mock(spec=Cluster) + mock_cluster.adapter = Mock(spec=BaseAdapter) + + obj = NamedTestObject(original_name, cluster=mock_cluster) + obj.name = new_name + + assert "name" in obj._changes + assert obj._name == new_name + + +class TestMixinInitialization: + """Tests for mixin initialization patterns.""" + + def test_init_methods_called_in_constructor(self): + """Test that _init_* methods are called during object construction.""" + obj = ComposedTestObject( + name="test_name", + owner="test_owner", + schema="test_schema", + tablespace="test_tablespace", + ) + + # Verify all private attributes are set + assert hasattr(obj, "_name") + assert hasattr(obj, "_owner") + assert hasattr(obj, "_schema") + assert hasattr(obj, "_tablespace") + + def test_init_with_edge_case_empty_string_name(self): + """Test initialization with empty string (edge case).""" + # Empty strings are technically valid but unusual + obj = NamedTestObject("") + assert obj.name == "" + + def test_init_with_special_characters(self): + """Test initialization with special characters.""" + special_names = ["test-name", "test_name", "test.name", "test$name"] + for name in special_names: + obj = NamedTestObject(name) + assert obj.name == name + + # Feature: mixin-based-inheritance, Property 3: Mixin Initialization Methods Set Private Attributes + @given( + name=st.text(min_size=1, max_size=50), + owner=st.one_of(st.none(), st.text(min_size=1, max_size=50)), + schema=st.text(min_size=1, max_size=50), + tablespace=st.one_of(st.none(), st.text(min_size=1, max_size=50)), + ) + def test_property_init_methods_set_private_attrs(self, name, owner, schema, tablespace): + """Property test: _init_* methods set private attributes correctly.""" + obj = ComposedTestObject( + name=name, + owner=owner, + schema=schema, + tablespace=tablespace, + ) + + assert obj._name == name + assert obj._owner == owner + assert obj._schema == schema + assert obj._tablespace == tablespace diff --git a/src/tests/test_sql.py b/src/tests/test_sql.py index 5a3fd83..51c0f5b 100644 --- a/src/tests/test_sql.py +++ b/src/tests/test_sql.py @@ -15,7 +15,7 @@ def test_init(self): assert str(SQL("asd")) == 'SQL("asd")' with pytest.raises(TypeError): - SQL() + SQL() # type: ignore[call-arg] def test_format(self): result = SQL("SELECT {field} FROM {table}").format(field=Identifier("foo"), table=Identifier("bar")) @@ -52,7 +52,7 @@ def test_init(self): assert str(Identifier("asd")) == 'Identifier("asd")' with pytest.raises(TypeError): - Identifier() + Identifier() # type: ignore[call-arg] def test_compose(self): result = Identifier("asd").compose() @@ -78,7 +78,7 @@ def test_init(self): assert str(Literal("asd")) == 'Literal("asd")' with pytest.raises(TypeError): - Literal() + Literal() # type: ignore[call-arg] def test_value(self): assert Literal("asd").value() == "asd" diff --git a/src/tests/test_util.py b/src/tests/test_util.py index f5ba0f6..03163dd 100644 --- a/src/tests/test_util.py +++ b/src/tests/test_util.py @@ -69,23 +69,23 @@ def test_exceptions(self): class TestUtil: def test_get_sql(self): assert isinstance(get_sql("get_database"), SQL) - assert re.search("datname", get_sql("get_database").value()) + assert re.search("datname", str(get_sql("get_database").value())) is not None assert isinstance(get_sql("get_procedure"), SQL) - assert re.search("proiswindow", get_sql("get_procedure").value()) - assert not re.search("p\\.prokind", get_sql("get_procedure").value()) + assert re.search("proiswindow", str(get_sql("get_procedure").value())) is not None + assert not re.search("p\\.prokind", str(get_sql("get_procedure").value())) assert isinstance(get_sql("get_procedure", Version("10.0")), SQL) - assert re.search("proiswindow", get_sql("get_procedure", Version("10.0")).value()) - assert not re.search("p\\.prokind", get_sql("get_procedure", Version("10.0")).value()) + assert re.search("proiswindow", str(get_sql("get_procedure", Version("10.0")).value())) is not None + assert not re.search("p\\.prokind", str(get_sql("get_procedure", Version("10.0")).value())) assert isinstance(get_sql("get_procedure", Version("11.0")), SQL) - assert re.search("p\\.prokind", get_sql("get_procedure", Version("11.0")).value()) - assert not re.search("proiswindow", get_sql("get_procedure", Version("11.0")).value()) + assert re.search("p\\.prokind", str(get_sql("get_procedure", Version("11.0")).value())) is not None + assert not re.search("proiswindow", str(get_sql("get_procedure", Version("11.0")).value())) assert isinstance(get_sql("get_procedure", Version("12.0")), SQL) - assert re.search("p\\.prokind", get_sql("get_procedure", Version("12.0")).value()) - assert not re.search("proiswindow", get_sql("get_procedure", Version("12.0")).value()) + assert re.search("p\\.prokind", str(get_sql("get_procedure", Version("12.0")).value())) is not None + assert not re.search("proiswindow", str(get_sql("get_procedure", Version("12.0")).value())) def test_group_by(self): Seq = namedtuple("Seq", "a b c d") diff --git a/uv.lock b/uv.lock index 12cc858..5bb5772 100644 --- a/uv.lock +++ b/uv.lock @@ -136,6 +136,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] +[[package]] +name = "hypothesis" +version = "6.151.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/c40dcd401cc360d8d084e584ffb7ab17255fde22e2b9cf2b53bf25aed629/hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783", size = 475923, upload-time = "2026-02-03T19:33:55.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/d9/53a8b53e75279a953fae608bd01025d9afcf393406c0da1dda1b7f5693c5/hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13", size = 543338, upload-time = "2026-02-03T19:33:54.583Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -174,6 +186,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "docker" }, + { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -192,6 +205,7 @@ psycopg2-binary = [ [package.metadata] requires-dist = [ { name = "docker", marker = "extra == 'dev'", specifier = ">=7.1.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "packaging", specifier = ">=26.0" }, { name = "psycopg2", marker = "extra == 'psycopg2'", specifier = ">=2.9.11,<3" }, { name = "psycopg2-binary", marker = "extra == 'psycopg2-binary'", specifier = ">=2.9.11,<3" }, @@ -406,6 +420,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "ty" version = "0.0.15"