diff --git a/.claude/version_control.md b/.claude/version_control.md new file mode 100644 index 00000000..b162bb9f --- /dev/null +++ b/.claude/version_control.md @@ -0,0 +1,3510 @@ +# Version Control Principles for rhosocial-activerecord + +## 1. Version Management Principles + +### Package Ecosystem Architecture + +The project consists of three main package types with distinct versioning strategies: + +``` +rhosocial-activerecord (Core) + └── Provides: Base ActiveRecord, interfaces, backend abstraction + └── Version: Independent semantic versioning + └── Dependencies: Pydantic only + +rhosocial-activerecord-testsuite (Test Suite) + └── Provides: Standardized test contracts, provider interfaces + └── Version: Tracks core package for API compatibility + └── Dependencies: Core package, pytest + +rhosocial-activerecord-{backend} (Backend Extensions) + └── Provides: Database-specific implementations + └── Version: MAJOR synced with core, MINOR/PATCH independent + └── Dependencies: Core package, native database drivers +``` + +### Python Version Support Policy + +#### Current Support Matrix + +**Version 1.0.x** (Current): +- **Supported**: Python 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 +- **Free-threaded builds**: Python 3.13t, 3.14t +- **Tested on CI**: All above versions +- **Pydantic compatibility**: 2.10.x (last version supporting Python 3.8) + +**Version 1.1.x and later** (Planned): +- **Minimum version**: To be determined (likely Python 3.10+) +- **Reason**: Python 3.8 and 3.9 reaching end-of-life, newer Pydantic versions requiring Python 3.10+ +- **Decision timeline**: Will be announced at least 6 months before 1.1.0 release + +#### Support Lifecycle + +**Python Version End-of-Life Policy**: + +- **Active Support**: Major versions actively maintained by Python core team +- **Security-only Support**: 6 months after Python version reaches EOL +- **Dropped Support**: After security-only period ends + +**Current Python EOL Schedule**: + +``` +Python Version | EOL Date | rhosocial-activerecord Support Status +---------------|-------------|-------------------------------------- +3.8 | 2024-10 | Supported in 1.0.x only (EOL reached) +3.9 | 2025-10 | Supported in 1.0.x, may drop in 1.1.x +3.10 | 2026-10 | Full support +3.11 | 2027-10 | Full support +3.12 | 2028-10 | Full support +3.13 | 2029-10 | Full support +3.14 | 2030-10 | Full support +``` + +**Note**: Python 3.8 has reached end-of-life as of October 2024. Version 1.0.x continues to support it due to widespread usage, but 1.1.x and later will likely drop support. + +#### Dependency Constraints + +**Python 3.8 Constraints**: + +- Pydantic: Limited to 2.10.x (last version supporting Python 3.8) +- Pydantic 2.11+ requires Python 3.9+ +- Separate requirements files: `requirements-3.8.txt` for Python 3.8 users + +**Python 3.14+ Enhancements**: + +- Free-threaded mode support (PEP 703) +- Requires Pydantic 2.12+ for compatibility +- Enhanced performance with removal of GIL + +**Upgrade Path**: + +```bash +# For Python 3.8 users on version 1.0.x +pip install rhosocial-activerecord~=1.0.0 + +# For Python 3.9+ users +pip install rhosocial-activerecord>=1.0.0 + +# Future: Python 3.10+ requirement for 1.1.x +pip install rhosocial-activerecord>=1.1.0 # Will require Python 3.10+ +``` + +#### Migration Strategy + +**For Users on Python 3.8**: + +1. **Current state** (version 1.0.x): Fully supported +2. **6-month notice**: Before 1.1.0 release, minimum Python version will be announced +3. **Version 1.0.x LTS**: Will receive security patches for 12 months after 1.1.0 release +4. **Migration window**: 18+ months to upgrade Python version + +**For Backend Developers**: + +- Backends must declare minimum Python version in `pyproject.toml` +- Test matrix should cover all supported Python versions +- Use feature detection for Python version-specific features + +**Version Support Declaration**: + +```python +# pyproject.toml +[project] +requires-python = ">=3.8" # Version 1.0.x +# requires-python = ">=3.10" # Version 1.1.x and later (planned) +``` + +### Core Libraries + +#### Package Dependencies + +- **Core Package**: Only depends on Pydantic for data validation and model definition +- **No ORM Dependencies**: Built from scratch without SQLAlchemy, Django ORM, or other ORMs +- **Backend Agnostic**: Core ActiveRecord functionality remains independent of specific database backends +- **Namespace Package Structure**: Uses namespace packages to allow distributed backend implementations + +#### Version Numbering Rules + +All packages MUST follow **PEP 440** compliance with **strict semantic versioning interpretation**: + +**Full Format**: `[EPOCH!]RELEASE[-PRE][.postPOST][.devDEV][+LOCAL]` + +**Semantic Versioning Rules**: + +1. **MAJOR version (X.0.0)**: Incompatible API changes breaking backward compatibility + - Breaking changes to public interfaces + - Removal of deprecated features + - Major architectural redesigns + - Pydantic major version upgrades + - **Python minimum version increases** (e.g., dropping Python 3.8 support) + - **MUST** be incremented when backward compatibility is broken + +2. **MINOR version (1.X.0)**: New features in backward-compatible manner + - New functionality additions + - New field types or query methods + - Enhanced capabilities + - **MUST NOT** break backward compatibility + - **MUST NOT** change minimum Python version + +3. **PATCH version (1.0.X)**: Backward-compatible bug fixes only + - Bug fixes and corrections + - Security patches + - Performance improvements + - Documentation updates + - **MUST NOT** introduce new features or break compatibility + +**Examples**: + +```python +__version__ = "1.0.0" # Final release +__version__ = "2!1.0.0a1" # Epoch + Alpha +__version__ = "1.0.0-beta.2.post3" # Beta with post-release +__version__ = "1.0.0.dev4+local.1" # Dev version + local build +``` + +#### Version Components + +1. **Epoch (Optional)**: Format `[N!]` (e.g., `2!1.0.0`) + - Purpose: Resets version numbering for major compatibility breaks + - Defaults to `0!` if omitted + - Example: Migrating from Pydantic v1 to v2 could warrant epoch increment + +2. **Release Segments**: Format `N(.N)*` (e.g., `1`, `1.2`, `1.0.0`) + - Rules: At least one numeric segment + - Major version increments indicate incompatible API changes + +3. **Pre-release (Optional)**: Format `[-._]{a|alpha|b|beta|rc|pre|preview}[N]` + - Short aliases: `a` = alpha, `b` = beta, `rc` = release candidate + - Examples: `1.0a1`, `1.0-beta.2`, `1.0.0-rc.3` + +4. **Post-release (Optional)**: Format `.postN` (e.g., `1.0.0.post1`) + - Purpose: Bug fixes without altering the main release + +5. **Dev-release (Optional)**: Format `.devN` (e.g., `1.0.0.dev2`) + - Purpose: Marks in-development versions + +6. **Local Version (Optional)**: Format `+[alphanum][._-alphanum]*` + - Purpose: Identifies unofficial builds (ignored in version comparisons) + +### Pre-release Development Cycle + +Every new MAJOR.MINOR version MUST follow this standardized pre-release cycle: + +**Development Phases**: + +``` +dev → alpha → beta → rc → release +``` + +#### Phase 1: Development (dev) + +**Version Format**: `X.Y.0.devN` (e.g., `1.2.0.dev1`, `1.2.0.dev2`) + +**Purpose**: Active feature development and early integration + +**Characteristics**: + +- **No fixed release count**: Release as frequently as needed for integration +- **No fixed time interval**: Can release multiple times per day if necessary +- **Focus**: Feature implementation, API design, initial testing +- **Stability**: Unstable, breaking changes allowed between dev releases +- **Branch**: Work happens on `release/vX.Y.0.devN` branch + +**Guidelines**: + +- Create dev releases for sharing work in progress +- Use for continuous integration testing +- No guarantee of stability or API consistency +- Can skip directly to alpha when feature set is complete + +**Example Timeline**: + +``` +Day 1: 1.2.0.dev1 - Initial feature A implementation +Day 3: 1.2.0.dev2 - Feature B added, API refined +Day 5: 1.2.0.dev3 - Integration fixes +Day 8: 1.2.0.dev4 - Ready for alpha phase +``` + +#### Phase 2: Alpha (alpha/a) + +**Version Format**: `X.Y.0aN` (e.g., `1.2.0a1`, `1.2.0a2`, `1.2.0a3`) + +**Purpose**: Feature complete, internal testing and stabilization + +**Characteristics**: + +- **Target release count**: ~3 releases +- **Release interval**: ~1 week between releases +- **Maximum phase duration**: 1 month +- **Focus**: Feature freeze, bug fixes, internal testing +- **Stability**: Moderately stable, API changes still possible +- **Branch**: Releases from `release/vX.Y.0` branch + +**Guidelines**: + +- Feature set is frozen (no new features) +- API can still be adjusted based on testing feedback +- Focus on bug fixes and stabilization +- Skip if no critical issues found within 1 week +- If no new release needed after 1 month, advance to beta + +**Example Timeline**: + +``` +Week 1: 1.2.0a1 - Feature complete, initial testing +Week 2: 1.2.0a2 - Bug fixes from testing +Week 3: 1.2.0a3 - API refinements +Week 4: Ready for beta phase +``` + +#### Phase 3: Beta (beta/b) + +**Version Format**: `X.Y.0bN` (e.g., `1.2.0b1`, `1.2.0b2`, `1.2.0b3`) + +**Purpose**: External testing, API freeze, comprehensive testing + +**Characteristics**: + +- **Target release count**: ~3 releases +- **Release interval**: ~1 week between releases +- **Maximum phase duration**: 1 month +- **Focus**: API frozen, bug fixes only, external testing +- **Stability**: Stable for testing, only critical bug fixes +- **Branch**: Releases from `release/vX.Y.0` branch + +**Guidelines**: + +- API is frozen (no API changes unless critical) +- Only bug fixes and documentation improvements +- External testers and early adopters can use +- Skip if no issues found within 1 week +- If no new release needed after 1 month, advance to RC + +**Example Timeline**: + +``` +Week 5: 1.2.0b1 - API frozen, external testing begins +Week 6: 1.2.0b2 - Critical bug fixes +Week 7: 1.2.0b3 - Edge case fixes +Week 8: Ready for RC phase +``` + +#### Phase 4: Release Candidate (rc) + +**Version Format**: `X.Y.0rcN` (e.g., `1.2.0rc1`, `1.2.0rc2`, `1.2.0rc3`) + +**Purpose**: Final validation, production readiness verification + +**Characteristics**: + +- **Target release count**: ~3 releases (ideally 1-2) +- **Release interval**: ~1 week between releases +- **Maximum phase duration**: 1 month +- **Focus**: Final validation, critical bug fixes only +- **Stability**: Production-ready quality +- **Branch**: Releases from `release/vX.Y.0` branch + +**Guidelines**: + +- Only critical bug fixes (security, data loss, crashes) +- No feature changes, no API changes +- Should be identical to final release if no issues found +- Skip if RC1 passes all validation +- If no critical issues after 1 month, release as final + +**Example Timeline**: + +``` +Week 9: 1.2.0rc1 - Release candidate for final validation +Week 10: 1.2.0rc2 - Critical security fix (if needed) +Week 11: 1.2.0rc3 - Final validation (if needed) +Week 12: Ready for final release +``` + +#### Phase 5: Final Release + +**Version Format**: `X.Y.0` (e.g., `1.2.0`) + +**Purpose**: Production release + +**Characteristics**: + +- **No suffix**: Clean version number +- **Focus**: Production deployment +- **Stability**: Production-ready +- **Support**: Full support begins + +**Guidelines**: + +- Tag with version number +- Publish to PyPI +- Update documentation +- Announce release +- Begin support lifecycle + +### Phase Transition Rules + +**Skip Rules**: + +1. **Skip intermediate releases**: If no bugs found during 1-week interval, do not release +2. **Skip entire phase**: If no bugs found after 1 month in a phase, advance to next phase +3. **Direct advancement**: Can advance earlier if highly confident (e.g., alpha1 → beta1) + +**Mandatory Delays**: + +- Minimum 1 week between phase transitions (to allow for testing) +- Exception: Critical security issues may fast-track + +**Example Fast-Track**: + +``` +1.2.0.dev4 → 1.2.0a1 (1 week) → 1.2.0b1 (1 week, a2/a3 skipped) +→ 1.2.0rc1 (1 week, b2/b3 skipped) → 1.2.0 (1 week, rc2/rc3 skipped) +Total: 4 weeks minimum fast-track +``` + +**Example Full Cycle**: + +``` +1.2.0.dev1-4 (2 weeks) → 1.2.0a1-3 (3 weeks) → 1.2.0b1-3 (3 weeks) +→ 1.2.0rc1-3 (3 weeks) → 1.2.0 +Total: 11 weeks full cycle +``` + +## 2. Branching and Release Strategy + +### Branch Structure + +**Main Branch** (`main`): + +- **Purpose**: Current production-ready state +- **Protection**: Required reviews, all CI checks must pass +- **Merge Strategy**: Squash and merge for features, rebase for hotfixes +- **Source of Truth**: Always represents the latest acceptable release state +- **Continuous Integration**: Must pass all tests at all times + +**Pre-release Version Branches** (`release/vX.Y.Z[.devN|aN|bN|rcN]`): + +- **Purpose**: Staging ground for specific version development +- **Creation**: Branched from `main` when starting new version development +- **Naming**: Uses full version number including pre-release suffix with `v` prefix +- **Lifecycle**: From dev phase through to final release +- **Examples**: + - `release/v1.2.0.dev1` - Development branch + - `release/v1.2.0` - Unified branch for alpha/beta/rc/final (reused across phases) + +**Development Branches** (from pre-release branch): + +- **Feature Branches**: `feature/{ticket-number}-{description}` or `feature/{description}` +- **Bug Fix Branches**: `fix/{ticket-number}-{description}` or `bugfix/{description}` +- **Documentation Branches**: `docs/{description}` +- **Test Branches**: `test/{description}` + +**Hotfix Branches** (`hotfix/{ticket-number}-{description}` or `hotfix/{version}`): + +- **Purpose**: Critical fixes to production releases +- **Source**: Branched from `main` or production tag +- **Target**: Merge back to `main` and active `release/` branches +- **Fast-tracked**: Emergency review and merge process + +### Feature Branch Management + +#### Creating Feature Branches + +**Best Practices**: + +- **Branch early, merge often**: Create feature branch as soon as work begins +- **Keep branches focused**: One feature/fix per branch +- **Small, incremental changes**: Easier to review and less conflict-prone +- **Regular synchronization**: Sync with pre-release branch frequently +- **Push early for CI feedback**: Get automated test results quickly + +**Creation Process**: + +```bash +# 1. Ensure pre-release branch is up to date +git checkout release/v1.2.0.dev1 +git pull origin release/v1.2.0.dev1 + +# 2. Create focused feature branch +git checkout -b feature/ar-123-recursive-cte + +# 3. Work in small, logical commits +# ... make changes ... +git add -p # Stage changes selectively +git commit -m "feat(query): add recursive CTE parser" + +# 4. Push early for visibility and CI feedback +git push origin feature/ar-123-recursive-cte + +# 5. Create draft PR immediately for CI validation +# - Navigate to GitHub +# - Create draft PR to release/v1.2.0.dev1 +# - CI runs automatically +# - Monitor CI results while developing +``` + +**CI-Driven Development**: + +```bash +# Check CI status after push +# GitHub UI shows: ✓ or ✗ for each check + +# If CI fails: +# 1. Review failure logs in GitHub Actions +# 2. Fix locally +git add . +git commit -m "fix: address test failure in CI" +git push # Triggers new CI run + +# 3. Wait for green checkmark before requesting review +``` + +#### Keeping Feature Branches Current + +**Scenario 1: Another feature merged, your branch is behind** + +```bash +# Your feature branch: feature/ar-123-recursive-cte +# Another feature was merged: feature/ar-124-window-functions + +# Option A: Rebase (preferred for clean history) +git checkout feature/ar-123-recursive-cte +git fetch origin +git rebase origin/release/v1.2.0.dev1 + +# Resolve conflicts if any +# ... fix conflicts ... +git add . +git rebase --continue + +# Force push (since history was rewritten) +git push origin feature/ar-123-recursive-cte --force-with-lease + +# Option B: Merge (preserves all history) +git checkout feature/ar-123-recursive-cte +git fetch origin +git merge origin/release/v1.2.0.dev1 + +# Resolve conflicts if any +# ... fix conflicts ... +git add . +git commit -m "merge: sync with release/v1.2.0.dev1" + +# Push normally +git push origin feature/ar-123-recursive-cte +``` + +**When to use Rebase vs Merge**: + +- **Rebase**: For local branches not shared, or when you want linear history +- **Merge**: For shared branches, or when preserving exact history is important +- **Team Convention**: Agree on one approach per project + +**Recommended Practice**: Rebase for feature branches, especially before final PR + +#### Synchronization Schedule + +**Frequency Guidelines**: + +- **Daily**: If pre-release branch is very active (>5 commits/day) +- **Before PR**: Always sync immediately before creating pull request +- **After Major Merges**: When another large feature is merged +- **At Phase Transitions**: When moving from dev → alpha → beta → rc + +**Synchronization Checklist**: + +```bash +# 1. Commit your current work +git add . +git commit -m "wip: checkpoint before sync" + +# 2. Fetch latest changes +git fetch origin + +# 3. Check what changed in pre-release branch +git log HEAD..origin/release/v1.2.0.dev1 --oneline + +# 4. Sync (rebase or merge) +git rebase origin/release/v1.2.0.dev1 + +# 5. Run tests locally +pytest tests/ + +# 6. Force push if rebased +git push origin feature/ar-123-recursive-cte --force-with-lease +``` + +#### Handling Abandoned Feature Branches + +**Scenario: Feature branch will not be merged** + +This can happen when: + +- Feature is cancelled or postponed +- Alternative approach chosen +- Feature duplicates merged work + +**Cleanup Process**: + +```bash +# 1. Document why feature is abandoned +# Add comment to issue tracker explaining decision + +# 2. Delete remote branch +git push origin --delete feature/ar-123-recursive-cte + +# 3. Delete local branch +git checkout release/v1.2.0.dev1 +git branch -D feature/ar-123-recursive-cte + +# 4. Remove changelog fragment (if created) +rm changelog.d/123.added.md +git add changelog.d/ +git commit -m "chore: remove changelog fragment for cancelled feature AR-123" +git push origin release/v1.2.0.dev1 + +# 5. Mark issue as "Won't Fix" or "Postponed" in issue tracker +``` + +**Preventing Fragment Conflicts**: + +- Fragments are deleted along with abandoned branches +- No fragment enters CHANGELOG.md until release +- Only merged features contribute to release notes + +**Communication**: + +- Notify team via issue tracker +- Update project board/sprint planning +- Document decision for future reference + +#### Long-Running Feature Branches + +**Definition**: Feature branches that span multiple weeks or cross version boundaries + +**Problems**: + +- Accumulate merge conflicts +- Diverge significantly from pre-release branch +- Difficult to review when PR is finally created +- May block other work + +**Strategies to Avoid**: + +**1. Break Down Large Features**: + +```bash +# Instead of one large branch: +feature/ar-123-full-graphql-support # BAD: too large + +# Break into smaller, mergeable pieces: +feature/ar-123-1-graphql-parser # Part 1: Parser +feature/ar-123-2-graphql-resolver # Part 2: Resolver +feature/ar-123-3-graphql-mutations # Part 3: Mutations +``` + +**2. Feature Flags**: + +```python +# Merge incomplete features behind flags +class QueryBuilder: + def graphql_query(self, query: str): + if not FEATURE_FLAGS.get('graphql_support'): + raise NotImplementedError("GraphQL support not yet enabled") + # Implementation... +``` + +**3. Regular Synchronization**: + +```bash +# Set calendar reminder to sync every 3 days +git fetch origin +git rebase origin/release/v1.2.0.dev1 +``` + +**4. Progressive PRs**: + +```bash +# Create draft PR early for visibility and feedback +# Update PR with each commit +# Request incremental reviews +``` + +**If Feature Crosses Version Boundary**: + +```bash +# Scenario: Working on feature for 1.2.0, but 1.2.0 is released +# and work continues on 1.3.0 + +# Option A: Rebase onto new version's pre-release branch +git checkout feature/ar-123-recursive-cte +git rebase --onto release/v1.3.0.dev1 release/v1.2.0.dev1 + +# Option B: Complete for 1.2.1 as bug fix if critical +# Convert to hotfix branch + +# Option C: Postpone to next minor version +# Continue on 1.3.0 pre-release branch +``` + +#### Multiple Developers on Same Feature + +**Scenario**: Large feature requires multiple developers + +**Recommended Approach**: + +```bash +# 1. Create shared feature branch +git checkout release/v1.2.0.dev1 +git checkout -b feature/ar-123-graphql-support +git push origin feature/ar-123-graphql-support + +# 2. Each developer creates sub-branch +# Developer A: +git checkout feature/ar-123-graphql-support +git checkout -b feature/ar-123-parser-alice + +# Developer B: +git checkout feature/ar-123-graphql-support +git checkout -b feature/ar-123-resolver-bob + +# 3. Merge sub-branches to shared feature branch +git checkout feature/ar-123-graphql-support +git merge feature/ar-123-parser-alice +git push origin feature/ar-123-graphql-support + +# 4. Sync shared feature branch regularly +git fetch origin +git merge origin/release/v1.2.0.dev1 + +# 5. Finally merge shared feature branch to pre-release +git checkout release/v1.2.0.dev1 +git merge --squash feature/ar-123-graphql-support +git commit -m "feat: add GraphQL support (#123)" +``` + +**Branch Hierarchy**: + +``` +release/v1.2.0.dev1 + └── feature/ar-123-graphql-support (shared) + ├── feature/ar-123-parser-alice (individual) + ├── feature/ar-123-resolver-bob (individual) + └── feature/ar-123-mutations-charlie (individual) +``` + +#### Branch Lifecycle Best Practices + +**General Guidelines**: + +1. **Short-lived branches**: Aim for <1 week from creation to merge +2. **Focused scope**: Single responsibility per branch +3. **Regular updates**: Sync with pre-release branch at least 2x/week +4. **Early PRs**: Create draft PR within 24 hours of branch creation +5. **Clean history**: Squash trivial commits before final PR +6. **Delete after merge**: Remove branches immediately after merging +7. **Changelog fragments**: Create early, delete if branch abandoned + +**Commit Frequency**: + +- **Local commits**: As often as needed (can be messy) +- **Push commits**: When logical unit is complete +- **PR commits**: Squashed into logical story before merge + +**Communication**: + +- **Branch name**: Should clearly indicate purpose +- **WIP commits**: Use "wip:" prefix for work-in-progress +- **Blockers**: Label issues when blocked by other work +- **Status updates**: Comment on issue tracker regularly + +### Pre-release Branch Workflow + +#### Creating Pre-release Version Branch + +```bash +# 1. Ensure main is up to date +git checkout main +git pull origin main + +# 2. Create pre-release branch for new version (dev phase) +git checkout -b release/v1.2.0.dev1 + +# 3. First commit: Bump version number +# Edit pyproject.toml to update the `version` property +# version = "1.2.0.dev1" + +git add pyproject.toml +git commit -m "chore: bump version to 1.2.0.dev1" +git push origin release/v1.2.0.dev1 +``` + +#### Development Branch Workflow + +```bash +# Developer working on feature +# 1. Create feature branch from pre-release branch +git checkout release/v1.2.0.dev1 +git pull origin release/v1.2.0.dev1 +git checkout -b feature/ar-123-recursive-cte + +# 2. Develop feature +# ... make changes ... +git add . +git commit -m "feat: implement recursive CTE support" + +# 3. Create changelog fragment +cat > changelog.d/123.added.md << 'EOF' +Added support for recursive CTEs in query builder, enabling hierarchical data queries. +EOF +git add changelog.d/123.added.md +git commit -m "docs: add changelog fragment for AR-123" + +# 4. Push and create pull request to pre-release branch +git push origin feature/ar-123-recursive-cte +# Create PR: feature/ar-123-recursive-cte → release/v1.2.0.dev1 +``` + +#### Merging Development Branches + +```bash +# After PR approval +# Merge feature into pre-release branch +git checkout release/v1.2.0.dev1 +git pull origin release/v1.2.0.dev1 +git merge --squash feature/ar-123-recursive-cte +git commit -m "feat: implement recursive CTE support (#123) + +Changelog fragment included." +git push origin release/v1.2.0.dev1 + +# After merge, verify CI passes on target branch +# Navigate to GitHub Actions for release/v1.2.0.dev1 +# Ensure all checks are green before proceeding + +# Delete merged branch +git push origin --delete feature/ar-123-recursive-cte +git branch -D feature/ar-123-recursive-cte +``` + +#### Phase Transitions + +**Dev to Alpha Transition**: + +```bash +# Rename branch or create new alpha branch +git checkout release/v1.2.0.dev4 +git pull origin release/v1.2.0.dev4 + +# Create unified release branch (will be reused for all phases) +git checkout -b release/v1.2.0 +git push origin release/v1.2.0 + +# Bump version to alpha +__version__ = "1.2.0a1" +git add src/rhosocial/activerecord/__init__.py +git commit -m "chore: bump version to 1.2.0a1 (alpha phase)" +git push origin release/v1.2.0 + +# Delete dev branch (optional) +git push origin --delete release/v1.2.0.dev4 +``` + +**Alpha/Beta/RC Transitions** (same branch reused): + +```bash +# Continue using release/v1.2.0 branch +git checkout release/v1.2.0 +git pull origin release/v1.2.0 + +# Bump version within same branch +# Alpha to Beta: +__version__ = "1.2.0b1" + +# Beta to RC: +__version__ = "1.2.0rc1" + +# RC to Final: +__version__ = "1.2.0" + +git add src/rhosocial/activerecord/__init__.py +git commit -m "chore: bump version to 1.2.0b1 (beta phase)" +git push origin release/v1.2.0 +``` + +#### Merging to Main + +```bash +# When ready for final release +git checkout main +git pull origin main + +# Build changelog (Towncrier) +git checkout release/v1.2.0 +towncrier build --version 1.2.0 --yes + +# Commit changelog +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.0" +git push origin release/v1.2.0 + +# **Wait for CI to pass before merging** +# Check GitHub Actions status: all checks must be green + +# Create merge commit to preserve release branch history +git checkout main +git merge --no-ff release/v1.2.0 + +# **CI runs again after merge to main** +git push origin main + +# **Only tag and publish if CI passes on main** +# Check GitHub Actions for main branch + +# Tag the release +git tag -a v1.2.0 -m "Release version 1.2.0" +git push origin v1.2.0 + +# CI runs on tag (for release verification) + +# Keep release branch for potential patches +# Or delete if no longer needed +git push origin --delete release/v1.2.0 # Optional +``` + +### Branch Protection Rules + +**Linear History Requirement (All Branches)**: + +- **CRITICAL**: All branches MUST maintain linear history - no merge commits, no forks +- Applies to `main`, `release/*`, and all development branches +- Applies equally to core team and external contributors +- Developers MUST manually sync with upstream before pushing +- DO NOT rely on CI auto-merge features + +**Commit Strategy**: + +- **Squash related commits**: Multiple commits for same purpose MUST be squashed into one +- Keep history clean and reviewable +- Each commit should represent one logical change +- Clear commit messages that explain the "why" +- Avoid confusing reviewers with messy commit history + +**CI Requirements (All Protected Branches)**: + +- **MANDATORY**: All status checks must pass before merge +- **No exceptions**: Even administrators must wait for CI (except documented emergencies) +- **Automatic enforcement**: GitHub prevents merge if CI fails + +**main branch**: + +- Require pull request reviews (minimum 1 approval) +- **Require status checks to pass**: + - `test-with-coverage` (Python 3.14 with coverage ≥90%) + - `test-other-versions` (Python 3.8-3.13 compatibility) + - `test-free-threaded` (Python 3.13t, 3.14t) +- **Require branches to be up to date before merge** +- **Require linear history** (no merge commits) +- Include administrators in restrictions +- No force pushes allowed +- No deletions allowed +- Enforce squash and merge for feature branches +- Enforce rebase for hotfixes to maintain linearity + +**release/v* branches**: + +- Require pull request reviews for merges from feature branches +- **Require status checks to pass**: + - All tests from main branch requirements +- **Require linear history** (no merge commits) +- Require branches to be up to date before merge +- **Allow maintainers to bypass ONLY for**: + - Version bump commits + - Changelog builds (towncrier) + - Documentation-only updates +- No force pushes allowed +- Enforce squash and merge for development branches + +**Development Best Practices**: + +```bash +# Before starting work - sync with upstream +git checkout main +git pull origin main +git checkout your-branch +git rebase main + +# Before creating PR - ensure up to date +git fetch origin +git rebase origin/main + +# Squash multiple WIP commits +git rebase -i HEAD~3 # Interactive rebase last 3 commits +# Mark commits as 'squash' or 'fixup' in editor + +# Keep your branch updated during development +git fetch origin +git rebase origin/main # NOT merge! +``` + +**Why Linear History Matters**: + +- **Clean audit trail**: Easy to understand what changed and why +- **Simplified debugging**: `git bisect` works reliably +- **Clear code review**: Reviewers see logical changes, not noise +- **Easy rollback**: Simple to revert entire features +- **Professional quality**: Industry standard for serious projects + +### Development Workflow Example + +Complete workflow for developing version 1.2.0: + +```bash +# Week 1: Start development phase +git checkout main +git pull origin main +git checkout -b release/v1.2.0.dev1 +# Bump version to 1.2.0.dev1 +git push origin release/v1.2.0.dev1 + +# Developer creates feature branch +git checkout release/v1.2.0.dev1 +git checkout -b feature/ar-123-recursive-cte +# ... develop feature ... +# ... create changelog fragment ... +git push origin feature/ar-123-recursive-cte +# Create PR → release/v1.2.0.dev1 + +# Week 2: More dev releases as needed +# Bump to 1.2.0.dev2, dev3, dev4... + +# Week 3: Transition to alpha +git checkout release/v1.2.0.dev4 +git checkout -b release/v1.2.0 +# Bump version to 1.2.0a1 +git push origin release/v1.2.0 + +# Weeks 4-6: Alpha testing and bug fixes +# Fix branches → release/v1.2.0 +# Bump to 1.2.0a2, a3 as needed + +# Weeks 7-9: Beta testing +# Bump to 1.2.0b1, b2, b3 as needed + +# Weeks 10-12: Release candidates +# Bump to 1.2.0rc1, rc2, rc3 as needed + +# Week 13: Final release +git checkout main +# Build changelog +git checkout release/v1.2.0 +towncrier build --version 1.2.0 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.0" +git push origin release/v1.2.0 + +# Merge to main +git checkout main +git merge --no-ff release/v1.2.0 +# Bump version to 1.2.0 (final) +git tag -a v1.2.0 -m "Release 1.2.0" +git push origin main --tags +``` + +### Hotfix Workflow + +For critical production issues: + +```bash +# Create hotfix from main +git checkout main +git pull origin main +git checkout -b hotfix/ar-999-critical-fix + +# Implement fix +# ... make changes ... +git commit -m "fix: critical security issue (AR-999)" + +# Create changelog fragment +cat > changelog.d/999.security.md << 'EOF' +**SECURITY**: Fixed SQL injection vulnerability in parameterized queries. CVE-2024-XXXXX. +EOF +git add changelog.d/999.security.md +git commit -m "docs: add security changelog fragment" + +# Create PR to main +git push origin hotfix/ar-999-critical-fix +# Fast-track review and merge + +# After merge to main, also merge to active release branches +git checkout release/v1.3.0 # If in development +git merge main # Or cherry-pick specific commits +``` + +## 3. Continuous Integration Strategy + +### 3.1 Overview + +All critical branches MUST have continuous integration enabled to ensure code quality and prevent regressions. CI checks run automatically on: + +- Direct commits to protected branches +- Pull requests to protected branches +- Release candidate builds + +**Purpose**: +- Catch bugs before merge +- Verify cross-version compatibility +- Ensure code coverage standards +- Validate documentation builds +- Enforce code quality standards + +### 3.2 Monitored Branches + +**Primary Branches**: +- `main` - Production-ready code +- `release/v**` - All pre-release versions (dev, alpha, beta, rc) +- `maint/**` - Maintenance branches for LTS versions + +**Development Branches** (indirect monitoring): +- Feature, bugfix, and hotfix branches tested via PRs to target branches + +### 3.3 CI Pipeline Configuration + +#### Test Matrix + +All commits and PRs must pass tests across: + +**Python Versions**: +- 3.8 (using requirements-3.8.txt) +- 3.9, 3.10, 3.11, 3.12 +- 3.13, 3.14 (latest stable) +- 3.13t, 3.14t (free-threaded builds) + +**Test Categories**: +- Unit tests (local backend tests) +- Integration tests (with testsuite) +- Coverage analysis (minimum 90% for new code) + +**Test Execution**: +- Retry flaky tests up to 3 times +- 1-second delay between retries +- Parallel execution where possible + +#### Example Workflow Configuration + +See `.github/workflows/test.yml` for the complete configuration. Key features: + +```yaml +on: + push: + branches: + - main + - 'release/v**' + - 'maint/**' + pull_request: + branches: + - main + - 'release/v**' + - 'maint/**' +``` + +**Job Structure**: + +1. **test-with-coverage**: Python 3.14 with full coverage reporting +2. **test-other-versions**: Python 3.8-3.13 compatibility testing +3. **test-free-threaded**: Python 3.13t, 3.14t testing + +### 3.4 CI Check Requirements + +#### For All Branches + +**Mandatory Checks**: +- [ ] All tests pass across Python 3.8-3.14 +- [ ] Code coverage ≥90% for modified files +- [ ] No regressions in existing tests +- [ ] Documentation builds successfully (if applicable) + +**Pre-release Specific**: +- [ ] Testsuite compatibility tests pass +- [ ] Backend capability tests pass +- [ ] Performance benchmarks show no >5% regression (if applicable) + +#### For Pull Requests + +**Before Merge**: +1. CI status must be green (all checks passed) +2. Minimum 1 approving review +3. Branch must be up-to-date with target +4. No merge conflicts + +**Handling CI Failures**: + +```bash +# If CI fails on your branch +git checkout feature/ar-123-your-feature + +# 1. Investigate failure in CI logs +# 2. Fix the issue locally +git add . +git commit -m "fix: address CI test failure" + +# 3. Push and wait for re-run +git push origin feature/ar-123-your-feature + +# CI automatically re-runs on push +``` + +### 3.5 Branch Protection with CI + +Update branch protection rules to enforce CI: + +**main branch**: +``` +Protection Rules: +✓ Require pull request reviews (1 approval) +✓ Require status checks to pass before merging: + - test-with-coverage + - test-other-versions (all Python versions) + - test-free-threaded +✓ Require branches to be up to date +✓ Require linear history +✓ Include administrators +``` + +**release/v* branches**: +``` +Protection Rules: +✓ Require pull request reviews (1 approval for features) +✓ Require status checks to pass: + - test-with-coverage + - test-other-versions + - test-free-threaded +✓ Require linear history +⚠ Allow maintainers to bypass for version bumps only +``` + +**maint/* branches**: +``` +Protection Rules: +✓ Require pull request reviews (1 approval) +✓ Require status checks to pass: + - test-with-coverage + - test-other-versions +✓ Require linear history +✓ Include administrators +``` + +### 3.6 CI in Development Workflow + +#### Feature Development Flow + +```bash +# 1. Create feature branch +git checkout release/v1.2.0.dev1 +git checkout -b feature/ar-123-new-feature + +# 2. Develop and commit +# ... make changes ... +git commit -m "feat: implement new feature" + +# 3. Push early for CI feedback +git push origin feature/ar-123-new-feature + +# 4. Create draft PR immediately +# - CI runs automatically on PR creation +# - Get early feedback on test failures + +# 5. Iterate based on CI results +# - Fix any failing tests +# - Maintain >90% coverage +# - Address linting issues + +# 6. Mark PR ready when CI is green +# - All checks passed +# - Ready for human review +``` + +#### Pre-release Version Flow + +```bash +# CI runs on every commit to release branch +git checkout release/v1.2.0 +git pull origin release/v1.2.0 + +# Version bump triggers CI +__version__ = "1.2.0a1" +git commit -m "chore: bump version to 1.2.0a1" +git push origin release/v1.2.0 + +# CI verifies: +# - All tests still pass with new version +# - Documentation builds correctly +# - Package can be built + +# Only proceed to next phase if CI is green +``` + +### 3.7 Fast-tracking Hotfixes + +Security and critical hotfixes MAY bypass some CI checks with approval: + +```bash +# Create security hotfix +git checkout v1.2.0 +git checkout -b hotfix/ar-999-security + +# Implement fix with tests +git commit -m "security: fix critical vulnerability" + +# Create URGENT PR +git push origin hotfix/ar-999-security + +# Fast-track options: +# 1. Full CI (recommended): Wait for all checks +# 2. Partial CI (urgent): Require only critical tests +# 3. Post-merge CI (emergency): Merge with admin override, verify after +``` + +**Emergency Override Process**: +- Requires 2 maintainer approvals +- Document reason in PR +- Schedule immediate follow-up CI run after merge +- Revert if post-merge CI fails + +### 3.8 Monitoring CI Health + +**Regular Maintenance**: +- Review CI logs weekly for flaky tests +- Update test timeouts if needed +- Monitor CI execution time (target <10 minutes) +- Keep Python versions current + +**Handling Flaky Tests**: +```yaml +# Tests configured with retry logic +- name: Run tests with retries + run: | + export PYTHONPATH=src + pytest tests/ --reruns 3 --reruns-delay 1 +``` + +**CI Performance Optimization**: +- Use caching for dependencies +- Parallelize test execution where possible +- Run expensive tests only on main/release branches + +### 3.9 Changelog Fragment Validation + +Ensure all PRs include appropriate changelog fragments unless explicitly exempted. + +#### Workflow Configuration + +See `.github/workflows/changelog-check.yml`: + +```yaml +name: Changelog Fragment Check + +on: + pull_request: + branches: + - main + - 'release/v**' + - 'maint/**' + +jobs: + check-fragment: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Check for changelog fragment + run: | + # Get changed files + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...${{ github.head_ref }}) + + # Check if any changelog fragment was added + if echo "$CHANGED_FILES" | grep -q "^changelog.d/[0-9].*\.md$"; then + echo "✓ Changelog fragment found" + exit 0 + fi + + # Check if this is an internal change (exempt from fragment requirement) + if echo "$CHANGED_FILES" | grep -qE "^(tests/|docs/|\.github/)"; then + echo "✓ Internal change, fragment not required" + exit 0 + fi + + # Check if PR is marked as trivial + if [[ "${{ github.event.pull_request.title }}" =~ \[trivial\] ]]; then + echo "✓ PR marked as trivial, fragment not required" + exit 0 + fi + + echo "✗ Missing changelog fragment" + echo "Please add a changelog fragment in changelog.d/" + echo "Example: changelog.d/${{ github.event.number }}.fixed.md" + exit 1 +``` + +**Fragment Exemptions**: + +PRs are exempt from changelog fragments if: +- Only changes to `tests/`, `docs/`, or `.github/` directories +- PR title contains `[trivial]` marker +- PR explicitly marked with `no-changelog-needed` label (manual override) + +### 3.10 Complete CI Workflow Example + +**Scenario**: Developer adding a new feature to version 1.2.0 + +```bash +# Day 1: Start feature development +git checkout release/v1.2.0.dev1 +git checkout -b feature/ar-456-window-functions + +# Make initial changes +git commit -m "feat: add window function parser" + +# Push immediately +git push origin feature/ar-456-window-functions + +# Create draft PR on GitHub +# → CI runs automatically (5-10 minutes) +# → Check GitHub Actions tab + +# Day 2: CI found an issue with Python 3.8 compatibility +# Review logs in GitHub Actions +# Fix the issue locally +git commit -m "fix: ensure Python 3.8 compatibility" +git push + +# → CI re-runs automatically +# → All checks pass ✓ + +# Day 3: Request review +# Mark PR as "Ready for review" (no longer draft) +# Reviewer approves +# → Merge button enabled only because CI is green + +# Merge via GitHub UI (squash and merge) +# → CI runs on release/v1.2.0.dev1 after merge +# → Confirms merge didn't break anything + +# Day 10: Ready for alpha release +git checkout release/v1.2.0.dev1 +git checkout -b release/v1.2.0 + +__version__ = "1.2.0a1" +git commit -m "chore: bump version to 1.2.0a1" +git push origin release/v1.2.0 + +# → CI runs on release branch +# → Wait for green before announcing alpha + +# Final release day +git checkout release/v1.2.0 +towncrier build --version 1.2.0 --yes +git commit -m "docs: update CHANGELOG for 1.2.0" +git push origin release/v1.2.0 + +# → CI runs, verifies final state +# → Only merge to main if CI passes + +git checkout main +git merge --no-ff release/v1.2.0 +git push origin main + +# → CI runs on main (final verification) +# → Tag only if CI passes + +git tag -a v1.2.0 -m "Release 1.2.0" +git push origin v1.2.0 +``` + +**Key Points**: +- CI runs at EVERY significant step +- Never proceed if CI fails +- Green checkmarks required before: + - Marking PR ready for review + - Merging PRs + - Announcing pre-releases + - Tagging final releases +- Emergency override requires documented justification + +## 4. Git Commit Message Standards + +### Commit Message Format + +All commit messages MUST follow the Conventional Commits specification with the following structure: + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +**Examples**: + +``` +feat: add recursive CTE support +feat(query): implement window functions for MySQL backend +fix: resolve SQL injection vulnerability in parameterized queries +docs: update installation guide for Python 3.14 +chore: bump version to 1.2.0 +``` + +### Complex Commit Messages via File + +When dealing with complex commit messages or messages containing special characters that may conflict with shell escaping (such as backticks, quotes, or regular expression patterns), you SHOULD write the commit message to a temporary file in `.claude/tmp/` and use the file option with git commit. + +**When to use this approach:** + +- Commit message contains complex formatting or code examples +- Message includes special shell characters (`$`, `` ` ``, `"`, `'`, `\`, `|`, `>`, `<`) +- Multi-line descriptions that are difficult to format inline +- Regular expressions or JSON examples in the commit message +- Any situation where command-line escaping becomes error-prone + +**Workflow:** + +```bash +# 1. Create a commit message file +cat > .claude/tmp/my-commit-message.txt << 'EOF' +feat(query): add support for regex operators in WHERE clauses + +Implement regular expression matching operators: +- ~ (case-sensitive match) +- ~* (case-insensitive match) +- !~ (case-sensitive non-match) +- !~* (case-insensitive non-match) + +Example usage: + User.where(name__regex__match=r'^[A-Z][a-z]+$') + Post.where(content__regex__imatch=r'python|javascript') + +Note: Backend support varies. PostgreSQL has full support, +while SQLite requires the REGEXP extension. + +Closes AR-789 +EOF + +# 2. Commit using the file +git commit -F .claude/tmp/my-commit-message.txt + +# 3. Clean up (optional) +rm .claude/tmp/my-commit-message.txt +``` + +**Best Practices:** + +- Use descriptive filenames (e.g., `ar-123-fix-commit.txt`) +- Delete temporary files after use (they are gitignored) +- Template files are available in `.claude/tmp/`: + - `commit-message-template.txt` - Basic template + - `commit-message-example-complex.txt` - Complex example with special characters + +**Benefits:** + +- No shell escaping issues with special characters +- Easier to edit and review multi-line messages +- Better handling of code examples and regex patterns +- Can use your preferred editor to compose messages + +### Commit Message Language + +- **Default Language**: Commit messages MUST primarily be written in English. +- **Non-English Messages**: If a commit message is written in a language other than English (e.g., for specific local context or internal project branches), it MUST be accompanied by an English translation in the commit body. The reason for using a non-English message should also be briefly explained in the commit body (e.g., "Reason: Internal project, localized message for team's convenience."). + +### Commit Types + +| Type | Purpose | Example | +|------|---------|---------| +| `feat` | New feature | `feat: add JSON field type support` | +| `fix` | Bug fix | `fix: resolve connection leak in MySQL backend` | +| `docs` | Documentation only | `docs: add CTE usage examples` | +| `style` | Code style/formatting | `style: apply black formatting` | +| `refactor` | Code restructuring | `refactor: simplify query builder logic` | +| `perf` | Performance improvement | `perf: optimize bulk insert for Postgres` | +| `test` | Add or modify tests | `test: add coverage for recursive CTE` | +| `chore` | Maintenance tasks | `chore: update dependencies` | +| `ci` | CI/CD changes | `ci: add Python 3.14 to test matrix` | +| `build` | Build system changes | `build: configure pyproject.toml for namespace packages` | +| `revert` | Revert previous commit | `revert: undo "feat: add feature X"` | + +### Scope Guidelines + +**Optional but recommended** for multi-component projects: +See the [.gemini/feature_points.md](.gemini/feature_points.md) document for a complete list and description of valid scopes. + +**Core Package Scopes**: + +- `query`: Query building and execution +- `backend`: Backend abstraction layer +- `field`: Field types and validation +- `relation`: Relationship management +- `event`: Event hooks system +- `mixin`: Mixin functionality + +**Backend Package Scopes**: + +- `mysql`: MySQL-specific +- `postgres`: Postgres-specific +- `sqlite`: SQLite-specific +- `dialect`: SQL dialect handling +- Separate multiple scopes with commas: `feat(query,backend): add new query optimization feature` +- Scopes can be further subdivided for more detail, e.g., `backend-cli` for backend command-line tools or `backend-adapters` for type adapters. + +**Testsuite Scopes**: + +- `feature`: Feature tests +- `realworld`: Real-world scenarios +- `benchmark`: Performance tests +- `provider`: Provider interface + +### Description Guidelines + +**DO**: + +- Use imperative mood: "add" not "added" or "adds" +- Start with lowercase letter +- Keep under 72 characters +- Be specific and descriptive +- Reference issue numbers when applicable + +**DON'T**: + +- End with period +- Use vague descriptions like "fix bug" or "update code" +- Include implementation details (save for body) + +**Examples**: + +``` +✅ Good: +feat: implement optimistic locking for concurrent updates +fix: prevent duplicate records in bulk insert (AR-456) +docs: clarify transaction isolation level usage + +❌ Bad: +feat: Added new feature. +fix: fixed bug +docs: Updates +``` + +### Commit Body + +**When to include**: + +- Complex changes requiring explanation +- Breaking changes +- Migration steps needed +- Design decisions context + +**Format**: + +- Separate from description with blank line +- Wrap at 72 characters +- Use bullet points for multiple items +- Explain "why" not "what" (code shows "what") + +**Example**: + +``` +feat: add connection pooling to backend interface + +Connection pooling significantly improves performance for +high-traffic applications by reusing database connections. + +- Default pool size: 5 connections +- Configurable via ConnectionConfig +- Thread-safe implementation +- Automatic cleanup on disconnect + +Closes AR-123 +``` + +### Commit Footer + +**Breaking Changes**: + +``` +feat!: remove deprecated find_by_id method + +BREAKING CHANGE: find_by_id() removed, use find(id=...) instead. + +Migration: +- Old: User.find_by_id(123) +- New: User.find_one(123) +``` + +**Issue References**: + +``` +Fixes AR-456 +Closes #789 +Resolves AR-123, AR-124 +See also AR-100 +``` + +**Co-authorship**: + +``` +Co-authored-by: Name +``` + +### Special Commit Messages + +**Version Bumps**: + +``` +chore: bump version to 1.2.0 +chore: bump version to 1.2.0a1 (alpha phase) +chore: bump version to 1.2.0 - SECURITY RELEASE +``` + +**Merge Commits** (when absolutely necessary): + +``` +Merge branch 'release/v1.2.0' into main + +Release version 1.2.0 with following changes: +- Feature A +- Feature B +- Critical fix C +``` + +**Reverts**: + +Although revert commits are allowed, they should rarely occur in practice. By maintaining good development habits on feature and bugfix branches: + +- Keep linear commits throughout development +- Squash and fix issues before pushing +- Resolve problems locally using rebase or amend + +Reversions typically only appear on protected branches (main, release/*, hotfix/*) when: +- A critical issue is discovered after merge +- Quick rollback with explicit history is needed + +In most cases, if development branches follow good practices, reversions will not be necessary on main or release branches. + +``` +revert: undo "feat: add recursive CTE support" + +This reverts commit a1b2c3d4. + +Reason: Feature causes performance regression in MySQL 5.7. +Reopens AR-456. +``` + +### Security-Related Commits + +**Format**: + +``` +security: fix SQL injection in query parameter handling + +SECURITY: Critical vulnerability allowing SQL injection through +parameterized queries when using untrusted input. + +CVE: CVE-2024-XXXXX (if applicable) +Severity: Critical +Impact: SQL injection in all query methods +Affected versions: 1.0.0 - 1.2.0 + +Fixed by properly escaping parameters before query execution. + +Fixes AR-999 +``` + +### Pre-commit Checklist + +Before committing, verify: + +- [ ] Commit type is correct +- [ ] Description is clear and under 72 characters +- [ ] Breaking changes are marked with `!` and explained +- [ ] Issue numbers referenced (if applicable) +- [ ] Multiple unrelated changes are split into separate commits +- [ ] Related changes are squashed into single commit +- [ ] Commit message follows imperative mood +- [ ] No sensitive information in message + +### Commit History Examples + +**Good History** (linear, clean): + +``` +* a1b2c3d feat: add window function support for Postgres +* b2c3d4e fix: resolve connection timeout in MySQL backend +* c3d4e5f docs: add examples for CTE usage +* d4e5f6g test: increase coverage for query builder +* e5f6g7h chore: bump version to 1.2.0 +``` + +**Bad History** (messy, unclear): + +``` +* a1b2c3d Update code +* b2c3d4e Fix bug +* c3d4e5f WIP +* d4e5f6g Fix typo +* e5f6g7h Merge branch 'develop' +* f6g7h8i More fixes +``` + +### Tools and Automation + +**Commit Message Template**: + +```bash +# .gitmessage template +# [optional scope]: +# +# [optional body] +# +# [optional footer(s)] +# +# Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert +# Scope: query, backend, field, relation, event, mixin, etc. +# Description: imperative mood, lowercase, no period, <72 chars +# +# Body: explain "why" not "what", wrap at 72 chars +# +# Footer: Fixes AR-XXX, BREAKING CHANGE, Co-authored-by + +# Configure git to use template: +# git config commit.template .gitmessage +``` + +**Commit Linting**: + +```bash +# Install commitlint (optional but recommended) +npm install -g @commitlint/cli @commitlint/config-conventional + +# .commitlintrc.json +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "type-enum": [ + 2, + "always", + ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build", "revert"] + ], + "subject-case": [2, "always", "lower-case"], + "subject-full-stop": [2, "never", "."] + } +} +``` + +**Pre-commit Hook Example**: + +```bash +#!/bin/sh +# .git/hooks/commit-msg + +# Check commit message format +if ! head -1 "$1" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+?\))?: .{1,72}$"; then + echo "ERROR: Commit message does not follow Conventional Commits format" + echo "Format: [optional scope]: " + echo "Example: feat: add recursive CTE support" + exit 1 +fi +``` + +## 5. Changelog Management with Towncrier + +### Overview + +We use [Towncrier](https://towncrier.readthedocs.io/) to manage our CHANGELOG.md. This ensures: + +- **Fragment-based workflow**: Changes documented in separate files +- **Consistent format**: Automated generation with standard structure +- **Easy collaboration**: Multiple developers can work without conflicts +- **Abandoned features handled**: Fragments deleted with abandoned branches +- **Clean releases**: Changelog assembled only during release + +### Installation and Setup + +**Install Towncrier**: + +```bash +pip install towncrier +``` + +**Configuration** (`pyproject.toml`): + +```toml +[tool.towncrier] +package = "rhosocial.activerecord" +package_dir = "src" +filename = "CHANGELOG.md" +directory = "changelog.d" +title_format = "## [{version}] - {project_date}" +template = "changelog.d/template.md" +start_string = "\n" +underlines = ["", "", ""] +issue_format = "[#{issue}](https://github.com/rhosocial/python-activerecord/issues/{issue})" + +[[tool.towncrier.type]] +directory = "security" +name = "Security" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "performance" +name = "Performance" +showcontent = true + +[[tool.towncrier.type]] +directory = "docs" +name = "Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "internal" +name = "Internal" +showcontent = true +``` + +### Fragment Directory Structure + +``` +changelog.d/ +├── template.md # Towncrier template +├── .gitkeep # Keep directory in git +├── 123.added.md # New feature fragment +├── 124.fixed.md # Bug fix fragment +├── 125.security.md # Security fix fragment +└── README.md # Instructions for contributors +``` + +### Fragment Naming Convention + +**Format**: `{issue_number}.{type}.md` + +**Examples**: + +- `123.added.md` - New feature from issue #123 +- `456.fixed.md` - Bug fix from issue #456 +- `789.security.md` - Security fix from issue #789 +- `999+1000.changed.md` - Change affecting multiple issues + +**Fragment Types**: + +| Type | Usage | +|------|-------| +| `security` | Security vulnerability fixes (CVEs) | +| `removed` | Removed features or APIs (breaking changes) | +| `deprecated` | Deprecated features (will be removed) | +| `added` | New features and functionality | +| `changed` | Changes to existing functionality (non-breaking) | +| `fixed` | Bug fixes | +| `performance` | Performance improvements | +| `docs` | Documentation changes (significant only) | +| `internal` | Internal changes (refactoring, testing) | + +### Creating Changelog Fragments + +**During Development**: + +Every PR SHOULD include a changelog fragment unless: + +- The change is purely internal (refactoring without behavior change) +- The change is a trivial fix (typo in code comment) +- The PR is part of a larger feature (fragment added in final PR) + +**Creating a Fragment**: + +```bash +# After creating PR #123 for a new feature +cd changelog.d/ + +# Create fragment file +cat > 123.added.md << 'EOF' +Added support for recursive CTEs in query builder, enabling hierarchical data queries. +EOF + +# Or use towncrier create (if available) +towncrier create 123.added.md --content "Added support for recursive CTEs." + +# Commit fragment with your PR +git add changelog.d/123.added.md +git commit -m "feat: add recursive CTE support + +Changelog fragment added." +``` + +**Fragment Content Guidelines**: + +1. **Write in past tense**: "Added support for..." not "Add support for..." +2. **Be specific but concise**: Include what changed and why it matters +3. **Focus on user impact**: What can users do now that they couldn't before? +4. **Link related issues**: Use issue number in filename +5. **One change per fragment**: If multiple changes, create multiple fragments + +**Good Examples**: + +```markdown + +Added support for recursive CTEs in SQLite 3.8.3+, enabling hierarchical data queries like organizational charts and category trees. +``` + +```markdown + +Fixed memory leak in connection pool that occurred when connections were not properly released after query timeout. +``` + +```markdown + +**SECURITY**: Fixed SQL injection vulnerability in parameterized queries when using list parameters. CVE-2024-XXXXX. +``` + +**Bad Examples**: + +```markdown + +Fixed bug in queries. +``` + +```markdown + +Refactored QueryBuilder._build_where() method to use visitor pattern. +``` + +```markdown + +Added CTE support, fixed connection leak, updated documentation. +``` + +### Handling Abandoned Features + +**Scenario**: Feature branch is abandoned and will not be merged + +**Process**: + +```bash +# 1. Branch with fragment exists +changelog.d/123.added.md # Fragment for abandoned feature + +# 2. Delete the branch +git push origin --delete feature/ar-123-recursive-cte + +# 3. Remove the fragment from pre-release branch +git checkout release/v1.2.0.dev1 +rm changelog.d/123.added.md +git add changelog.d/123.added.md +git commit -m "chore: remove changelog fragment for abandoned feature AR-123" +git push origin release/v1.2.0.dev1 +``` + +**Key Point**: Since fragments are NOT in CHANGELOG.md yet, abandoned features simply have their fragments deleted and never appear in release notes. + +### Building Changelog + +**During Release**: + +```bash +# 1. Check current fragments (preview) +towncrier build --draft --version 1.2.0 + +# 2. Review the output, ensure all fragments are present +# Verify no fragments for abandoned features remain + +# 3. Build actual changelog (removes fragments) +towncrier build --version 1.2.0 --yes + +# This command: +# - Reads all fragments from changelog.d/ +# - Generates formatted changelog entries +# - Inserts them at top of CHANGELOG.md +# - Deletes the processed fragment files + +# 4. Review CHANGELOG.md changes +git diff CHANGELOG.md + +# 5. Commit changelog (this is the last commit before release tag) +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.0" +git push origin release/v1.2.0 +``` + +**Changelog Location**: + +Towncrier inserts new release notes at the top of CHANGELOG.md, below the "towncrier release notes start" marker: + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + + + +## [1.2.0] - 2024-10-29 + +### Security + +- **SECURITY**: Fixed SQL injection vulnerability... ([#789](https://github.com/rhosocial/python-activerecord/issues/789)) + +### Added + +- Added support for recursive CTEs... ([#123](https://github.com/rhosocial/python-activerecord/issues/123)) + +### Fixed + +- Fixed memory leak in connection pool... ([#456](https://github.com/rhosocial/python-activerecord/issues/456)) + +## [1.1.0] - 2024-09-15 + +... +``` + +### Pre-release Changelog Builds + +**Important**: Fragments remain separate until final release + +```bash +# During alpha/beta/rc phases: +# - Fragments accumulate in changelog.d/ +# - CHANGELOG.md is NOT modified +# - Only at final release are fragments compiled + +# Preview what release notes will look like +towncrier build --draft --version 1.2.0a1 + +# Do NOT build changelog during pre-release phases +# Fragments stay in changelog.d/ until final release +``` + +### Integration with Release Process + +**Pre-release Checklist Update**: + +```markdown +**For All Packages**: +- [ ] All tests pass on CI +- [ ] **Changelog fragments reviewed** (towncrier build --draft) +- [ ] **CHANGELOG.md built** (towncrier build --version X.Y.Z --yes) +- [ ] Fragments removed after build +- [ ] Documentation updated and builds successfully +- [ ] Version number updated in `__init__.py` +- [ ] Git tag created with version number +- [ ] Release notes drafted +``` + +**Release Commit Sequence**: + +```bash +# 1. Last pre-release commit is version bump +__version__ = "1.2.0rc3" +git commit -m "chore: bump version to 1.2.0rc3" + +# 2. When ready for final release, build changelog +__version__ = "1.2.0" +git add src/rhosocial/activerecord/__init__.py +git commit -m "chore: bump version to 1.2.0 (final release)" + +towncrier build --version 1.2.0 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.0" + +# 3. Merge to main and tag +git checkout main +git merge --no-ff release/v1.2.0 +git tag -a v1.2.0 -m "Release version 1.2.0" +git push origin main v1.2.0 +``` + +### Automated Checks + +Add CI check to ensure PRs include changelog fragments (see section 3.9). + +### Maintenance Branch Changelog + +For maintenance branches (e.g., `maint/1.2.x`), changelog fragments are managed independently: + +```bash +# When backporting to maint/1.2.x +git checkout maint/1.2.x +git checkout -b backport/1.2.x/ar-456-fix + +# Add fragment to maintenance branch +cat > changelog.d/456.fixed.md << 'EOF' +Fixed query null handling issue in WHERE clauses (backported from 1.3.1). +EOF + +git add changelog.d/456.fixed.md +git commit -m "fix: backport query null handling fix + +(cherry picked from commit )" +``` + +**Building Changelog for Patch Release**: + +```bash +# On maintenance branch (maint/1.2.x) +# After all backports are merged + +# Build changelog for patch version +towncrier build --version 1.2.6 --yes + +# Commit +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.6" + +# Tag +git tag -a v1.2.6 -m "Release v1.2.6" +git push origin maint/1.2.x v1.2.6 +``` + +### Fragment Examples by Type + +**Security**: + +```markdown + +**SECURITY**: Fixed SQL injection vulnerability in query parameter handling. All users should upgrade immediately. CVE-2024-XXXXX. +``` + +**Breaking Change (Removed)**: + +```markdown + +**BREAKING**: Removed deprecated `Model.find_by()` method. Use `Model.where()` instead. This method was deprecated in v1.1.0. +``` + +**Deprecation**: + +```markdown + +Deprecated `QueryBuilder.filter()` method in favor of `QueryBuilder.where()`. The old method will be removed in v2.0.0. +``` + +**New Feature**: + +```markdown + +Added support for window functions (ROW_NUMBER, RANK, LAG, LEAD) in Postgres and SQLite 3.25+. See documentation for usage examples. +``` + +**Behavior Change**: + +```markdown + +Changed default connection pool size from 5 to 10 for better concurrency. Override with `pool_size` configuration parameter. +``` + +**Bug Fix**: + +```markdown + +Fixed race condition in connection pool that could cause deadlocks under high concurrent load. +``` + +**Performance**: + +```markdown + +Improved bulk insert performance by 3x through batched operations. Inserts of 1000+ records now use database-specific bulk insert syntax. +``` + +**Documentation** (significant only): + +```markdown + +Added comprehensive guide for implementing custom type converters with examples for JSON, UUID, and encrypted fields. +``` + +### Contributor Guide + +**For Contributors** (`changelog.d/README.md`): + +```markdown +# Changelog Fragments + +We use Towncrier to manage our changelog. Each significant change should have a corresponding fragment file. + +## Creating a Fragment + +1. **Filename**: `{issue_number}.{type}.md` + - Example: `123.added.md` + +2. **Types**: + - `security` - Security fixes (always significant) + - `removed` - Removed features (breaking changes) + - `deprecated` - Deprecation notices + - `added` - New features + - `changed` - Behavior changes + - `fixed` - Bug fixes + - `performance` - Performance improvements + - `docs` - Documentation (significant changes only) + - `internal` - Internal changes (optional) + +3. **Content**: + - Write in past tense + - Be specific but concise + - Focus on user impact + - One change per fragment + +## Fragment Lifecycle + +- **Created**: When feature/fix branch is created +- **Merged**: Fragment merges with the code +- **Compiled**: During final release (not pre-releases) +- **Deleted**: Automatically removed after compilation +- **Abandoned**: Manually deleted if feature is abandoned + +## Good Examples + +```markdown + +Added support for recursive CTEs in query builder, enabling hierarchical queries. +``` + +## Bad Examples + +```markdown + +Added CTE. +``` + +## When to Skip + +- Internal refactoring (no behavior change) +- Trivial fixes (typos in comments) +- Work-in-progress (fragment in final PR) + +## If Feature is Abandoned + +Simply delete the fragment file: + +```bash +rm changelog.d/123.added.md +``` + +The fragment never enters CHANGELOG.md, so no cleanup needed there. + +## Commands + +```bash +# Preview changelog +towncrier build --draft --version X.Y.Z + +# Build changelog (removes fragments) +towncrier build --version X.Y.Z --yes +``` +``` + +## 6. Version Increment Guidelines + +### Core Package (rhosocial-activerecord) + +**MAJOR version** (X.0.0): +- Incompatible API changes +- Breaking changes to public interfaces +- Major architectural redesigns +- Pydantic major version upgrades +- Examples: + - Changing `ActiveRecord.save()` signature + - Removing deprecated methods + - Restructuring module organization + +**MINOR version** (1.X.0): +- New features in backward-compatible manner +- New field types or query methods +- Enhanced functionality +- Examples: + - Adding new query builder methods + - Introducing new mixin classes + - Supporting new Python versions + +**PATCH version** (1.0.X): +- Backward-compatible bug fixes +- Security patches +- Performance improvements +- Documentation updates +- Examples: + - Fixing query generation bugs + - Correcting type annotations + - Updating docstrings + +### Test Suite Package (rhosocial-activerecord-testsuite) + +**Version Synchronization Strategy**: +- **MAJOR.MINOR** must match core package for API compatibility +- **PATCH** can be independent for test-specific fixes +- Version format: `{core_major}.{core_minor}.{testsuite_patch}` + +**Examples**: +```python +Core: 1.2.0 → Testsuite: 1.2.0 # Initial release +Core: 1.2.0 → Testsuite: 1.2.1 # Test bug fix +Core: 1.2.1 → Testsuite: 1.2.1 # Core patch, no test changes +Core: 1.3.0 → Testsuite: 1.3.0 # New core features require new tests +``` + +### Backend Extension Packages (rhosocial-activerecord-{backend}) + +**Version Synchronization Rules**: + +- **MAJOR** must be synchronized with core package +- **MINOR** can be independent for backend-specific features +- **PATCH** is independent for each backend's bug fixes + +**Dependency Specification**: + +```python +# In backend package's pyproject.toml +dependencies = [ + "rhosocial-activerecord>=1.2.0,<2.0.0", # Compatible with 1.x + "mysql-connector-python>=8.0.0", # Native driver only +] +``` + +## 7. Post-Release Operations + +### Publishing Release + +After merging release branch to main, complete the following operations: + +```bash +# After merge to main and changelog build +git checkout main +git pull origin main + +# Verify version is final (e.g., 1.2.0) +# Verify CHANGELOG.md is updated +# Verify changelog.d/ fragments are removed + +# Tag should already be created during merge +# If not, create now: +git tag -a v1.2.0 -m "Release version 1.2.0 + +See CHANGELOG.md for full details." + +# Push tag +git push origin v1.2.0 + +# Build and publish to PyPI +python -m build +python -m twine upload dist/rhosocial-activerecord-1.2.0* +``` + +### Update Documentation + +```bash +# Update version in documentation +# Update compatibility matrix +# Publish documentation for new version +# Update "latest" documentation link +``` + +### Release Announcement + +- CHANGELOG.md already updated (by Towncrier) +- Create GitHub Release with tag +- Announce on project channels +- Update project website/homepage + +### Post-Release Branch Management + +**Keep release branch temporarily**: + +```bash +# Keep release/v1.2.0 branch for potential hotfixes +# Do NOT delete immediately +``` + +**When to delete**: + +- After next MINOR/MAJOR release is stable +- After all supported PATCH versions are released +- Typically keep for 2-4 weeks post-release + +## 8. Handling Post-Release Defects + +### PATCH Version Releases (1.2.X) + +**For bugs discovered after release**: + +#### Scenario 1: Minor Bug (Non-Critical) + +```bash +# Create bugfix branch from main (or from tag if main has moved on) +git checkout v1.2.0 # Or main if v1.2.0 is still HEAD +git checkout -b fix/ar-456-query-bug + +# Fix the bug +# ... make changes ... +git commit -m "fix: resolve query null handling issue (AR-456)" + +# Create changelog fragment +cat > changelog.d/456.fixed.md << 'EOF' +Fixed query null handling issue in WHERE clauses. +EOF +git add changelog.d/456.fixed.md +git commit -m "docs: add changelog fragment for AR-456" + +# Push and create PR to main +git push origin fix/ar-456-query-bug +# Create PR → main + +# After merge, prepare PATCH release +git checkout main +git pull origin main + +# Bump version to 1.2.1 +__version__ = "1.2.1" +git add src/rhosocial/activerecord/__init__.py +git commit -m "chore: bump version to 1.2.1" + +# Build changelog +towncrier build --version 1.2.1 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.1" +git push origin main + +# Tag and release +git tag -a v1.2.1 -m "Release version 1.2.1 - Bug fixes" +git push origin v1.2.1 +python -m build && python -m twine upload dist/* +``` + +#### Scenario 2: Multiple Bugs Accumulation + +```bash +# Collect multiple bug fixes in main +# Each with its own changelog fragment + +# When ready for PATCH release: +git checkout main +git pull origin main + +# Bump to next PATCH version +__version__ = "1.2.2" +git commit -m "chore: bump version to 1.2.2" + +# Build changelog (will include all accumulated fragments) +towncrier build --version 1.2.2 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: update CHANGELOG for 1.2.2" + +# Tag and publish +git tag -a v1.2.2 -m "Release version 1.2.2" +git push origin main --tags +python -m build && python -m twine upload dist/* +``` + +### Security Patch Releases + +**For critical security issues**: + +```bash +# Create hotfix branch from affected version tag +git checkout v1.2.0 +git checkout -b hotfix/ar-999-security-fix + +# Implement security fix +# ... make changes ... +git commit -m "security: fix SQL injection vulnerability (AR-999) + +SECURITY: This fixes a critical SQL injection vulnerability +in query parameter handling. + +CVE: CVE-2024-XXXXX (if applicable) +Severity: Critical +Impact: SQL injection in parameterized queries" + +# Create security fragment +cat > changelog.d/999.security.md << 'EOF' +**SECURITY**: Fixed SQL injection vulnerability in query parameter handling. All users should upgrade immediately. CVE-2024-XXXXX. +EOF +git add changelog.d/999.security.md +git commit -m "docs: add security changelog fragment" + +# Push and create URGENT PR +git push origin hotfix/ar-999-security-fix +# Fast-track review (within 24-72 hours) + +# After merge to main +git checkout main +git pull origin main + +# Immediate PATCH release +__version__ = "1.2.1" +git add src/rhosocial/activerecord/__init__.py +git commit -m "chore: bump version to 1.2.1 - SECURITY RELEASE" + +# Build changelog +towncrier build --version 1.2.1 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: SECURITY RELEASE - update CHANGELOG for 1.2.1" + +# Tag with security note +git tag -a v1.2.1 -m "SECURITY RELEASE v1.2.1 + +Critical security fix for SQL injection vulnerability. +All users should upgrade immediately. + +CVE: CVE-2024-XXXXX +See SECURITY.md and CHANGELOG.md for details." + +git push origin main --tags + +# Expedited release +python -m build && python -m twine upload dist/* + +# Security advisory +# - Create GitHub Security Advisory +# - Email notification to users +# - Update security documentation +``` + +### PATCH Release Cadence + +**Regular PATCH releases**: + +- Collect bug fixes over 2-4 weeks +- Release when significant bugs accumulated +- Or release immediately for critical issues + +**Emergency PATCH releases**: + +- Security vulnerabilities: Within 24-72 hours +- Data loss bugs: Within 1 week +- Critical crashes: Within 1 week + +**PATCH release checklist**: + +- [ ] All bug fixes merged to main +- [ ] Changelog fragments present for all fixes +- [ ] Version number bumped +- [ ] CHANGELOG.md built with Towncrier +- [ ] Fragments removed after build +- [ ] Tests pass on CI +- [ ] Tag created and pushed +- [ ] Published to PyPI +- [ ] Documentation updated (if needed) +- [ ] Users notified (for security issues) + +## 9. Multi-Version Support and Backporting + +### Version Support Policy + +**Support Tiers**: + +1. **Latest Stable** (main branch): + - Full feature development + - All bug fixes + - Security patches + - Performance improvements + +2. **LTS (Long-Term Support)** versions: + - Selected MAJOR.MINOR versions marked as LTS + - Bug fixes and security patches only + - No new features + - Extended support period + +3. **Legacy Versions**: + - Security patches only (critical vulnerabilities) + - No bug fixes unless security-related + - Limited support period + +**Support Windows**: + +``` +Version Type | Full Support | Security Only | Total Lifecycle +----------------|--------------|---------------|---------------- +Regular Release | 12 months | +6 months | 18 months +LTS Release | 24 months | +12 months | 36 months +Legacy Version | 0 months | 6 months | 6 months +``` + +### Maintenance Branch Strategy + +**Creating Maintenance Branches**: + +When a new MINOR version is released, create a maintenance branch for the previous version if it requires continued support: + +```bash +# After releasing 1.3.0, create maintenance branch for 1.2.x +git checkout v1.2.5 # Last 1.2.x release +git checkout -b maint/1.2.x +git push origin maint/1.2.x +``` + +### Backporting Workflow + +#### Single-Version Backport + +```bash +# Bug affects 1.2.x (LTS version) +# Fix merged to main (current: 1.3.0) + +# Checkout maintenance branch +git checkout maint/1.2.x +git pull origin maint/1.2.x + +# Create backport branch +git checkout -b backport/1.2.x/ar-456-query-bug + +# Cherry-pick from main +git cherry-pick + +# Add changelog fragment +cat > changelog.d/456.fixed.md << 'EOF' +Fixed query null handling issue in WHERE clauses (backported from 1.3.1). +EOF +git add changelog.d/456.fixed.md +git commit -m "docs: add changelog fragment for backport AR-456" + +# Push and create PR to maintenance branch +git push origin backport/1.2.x/ar-456-query-bug +# Create PR: backport/1.2.x/ar-456-query-bug → maint/1.2.x +``` + +#### Multi-Version Backport + +```bash +# Bug in: 1.3.x (current), 1.2.x (LTS), 1.1.x (security-only) +# Fix merged to main as v1.3.1 + +# Backport to 1.2.x +git checkout maint/1.2.x +git checkout -b backport/1.2.x/ar-999-security-fix +git cherry-pick +# Create fragment +cat > changelog.d/999.security.md << 'EOF' +**SECURITY**: Fixed SQL injection vulnerability. CVE-2024-XXXXX. +EOF +git add changelog.d/999.security.md +git commit -m "docs: add security fragment" +git push origin backport/1.2.x/ar-999-security-fix + +# Backport to 1.1.x +git checkout maint/1.1.x +git checkout -b backport/1.1.x/ar-999-security-fix +git cherry-pick +# Create fragment +cat > changelog.d/999.security.md << 'EOF' +**SECURITY**: Fixed SQL injection vulnerability. CVE-2024-XXXXX. +EOF +git add changelog.d/999.security.md +git commit -m "docs: add security fragment" +git push origin backport/1.1.x/ar-999-security-fix + +# After all PRs merged, release patches: +# - 1.3.1 (already released) +# - 1.2.6 (from maint/1.2.x) +# - 1.1.12 (from maint/1.1.x) + +# For each maintenance branch: +git checkout maint/1.2.x +__version__ = "1.2.6" +git commit -m "chore: bump version to 1.2.6" +towncrier build --version 1.2.6 --yes +git add CHANGELOG.md changelog.d/ +git commit -m "docs: SECURITY RELEASE - update CHANGELOG for 1.2.6" +git tag -a v1.2.6 -m "Security release v1.2.6" +git push origin maint/1.2.x v1.2.6 +``` + +## 10. Capability-Based Version Management + +### Capability Declaration System + +Backends MUST declare their supported capabilities using the `DatabaseCapabilities` system: + +```python +# Backend capability declaration example +from rhosocial.activerecord.backend.capabilities import ( + DatabaseCapabilities, + CapabilityCategory, + CTECapability, + WindowFunctionCapability, +) + +class MySQLBackend(StorageBackend): + def _initialize_capabilities(self): + """Declare backend capabilities based on server version.""" + capabilities = DatabaseCapabilities() + version = self.get_server_version() + + # CTEs supported from MySQL 8.0+ + if version >= (8, 0, 0): + capabilities.add_cte([ + CTECapability.BASIC_CTE, + CTECapability.RECURSIVE_CTE, + ]) + + # Window functions from MySQL 8.0+ + if version >= (8, 0, 0): + capabilities.add_window_function(ALL_WINDOW_FUNCTIONS) + + return capabilities +``` + +### Capability-Driven Test Execution + +Tests automatically skip when required capabilities are unavailable: + +```python +from rhosocial.activerecord.backend.capabilities import ( + CapabilityCategory, + CTECapability, +) +from rhosocial.activerecord.testsuite.utils import requires_capability + +@requires_capability(CapabilityCategory.CTE, CTECapability.RECURSIVE_CTE) +def test_recursive_cte(tree_fixtures): + """Test requires recursive CTE support.""" + Node = tree_fixtures[0] + # Test implementation +``` + +**Capability Version Tracking**: + +- New capabilities added in MINOR versions +- Capability deprecation in MAJOR versions +- Backend capability updates tracked in changelog +- Compatibility report generated during release + +## 11. Test Coverage and Quality Standards + +**Core Package**: + +- Minimum 90% code coverage for new features +- 100% coverage for critical paths (save, delete, query) +- Unit tests for all public APIs +- Integration tests with built-in SQLite backend + +**Test Suite**: + +- Backend-agnostic test logic +- Provider interface compliance tests +- Capability negotiation tests +- Performance regression tests + +**Backend Packages**: + +- Backend-specific schema implementation tests +- Capability declaration verification tests +- Integration tests with testsuite +- Driver-specific edge case tests + +## 12. Development Cycle + +### Sprint Planning (Bi-weekly) + +- Feature prioritization +- Backend compatibility review +- Testsuite updates planning +- Documentation requirements + +### Daily Development + +- Small, focused commits +- Continuous integration checks +- Early draft pull requests for feedback +- Cross-package impact assessment + +### Code Review Requirements + +- Core team approval required (minimum 1) +- Backend maintainers approval for backend changes +- Testsuite changes require core + testsuite maintainer approval +- Review checklist: + - Code style compliance + - Test coverage adequate + - Documentation updated + - Backward compatibility maintained + - Breaking changes documented + +### Testing Requirements + +- Unit tests: ≥90% coverage for new code +- Integration tests: Pass across all supported backends +- Performance benchmarks: No significant regression (>5%) +- Compatibility tests: Pass with supported core versions + +## 13. Release Process + +### Pre-release Checklist + +**For All Packages**: + +- [ ] All tests pass on CI +- [ ] Documentation updated and builds successfully +- [ ] **Changelog fragments reviewed** (towncrier build --draft) +- [ ] **CHANGELOG.md built** (towncrier build --version X.Y.Z --yes) +- [ ] Fragments removed after build +- [ ] Version number updated in `__init__.py` +- [ ] Git tag created with version number +- [ ] Release notes drafted + +**For Core Package**: + +- [ ] Backward compatibility verified +- [ ] API changes documented +- [ ] Migration guide provided (if breaking changes) +- [ ] Performance benchmarks run +- [ ] Security audit completed (if applicable) + +**For Test Suite**: + +- [ ] Tests pass against target core version +- [ ] New tests for new core features +- [ ] Provider interface changes documented +- [ ] Capability requirements updated + +**For Backend Packages**: + +- [ ] Compatibility with declared core versions verified +- [ ] Capability declarations updated +- [ ] Backend-specific tests pass +- [ ] Schema files updated +- [ ] Driver version requirements documented + +### Coordinated Release Process + +**Phase 1: Core Package Release** + +1. Finalize core package changes +2. Create release branch +3. Run full test suite +4. Build changelog with Towncrier +5. Update version +6. Tag and publish to PyPI +7. Generate release notes + +**Phase 2: Test Suite Update** + +1. Update testsuite to test new core features +2. Add capability requirements for new features +3. Verify testsuite passes with new core +4. Build changelog with Towncrier +5. Tag and publish matching version +6. Update documentation + +**Phase 3: Backend Package Updates** + +1. Backend maintainers test against new core +2. Implement new capabilities if applicable +3. Update capability declarations +4. Run compatibility tests +5. Build changelog with Towncrier +6. Publish updated backend packages +7. Generate compatibility reports + +**Phase 4: Documentation Sync** + +1. Update main documentation site +2. Publish compatibility matrix +3. Update installation guides +4. Announce release on channels + +### Release Cadence + +**Production Releases**: + +- Core package: Monthly (if features ready) +- Test suite: Aligned with core releases +- Backend packages: Independent, as needed + +**Beta Releases**: + +- First Friday of each month (if features ready) +- Allow 2 weeks for community testing +- Production release third Friday + +**Security Patch Releases**: + +- As needed, within 72 hours of disclosure +- Coordinated across all affected packages +- Emergency hotfix process + +**Release Schedule Example**: + +``` +Week 1: Core beta release (1.3.0-beta.1) +Week 2: Backend testing and feedback +Week 3: Core production release (1.3.0) +Week 3: Testsuite release (1.3.0) +Week 4: Backend updates (1.3.x) +``` + +### Support Windows + +**Stable Releases**: + +- Full support: 12 months +- Security fixes: Additional 6 months +- Total lifecycle: 18 months + +**LTS Releases** (Selected major versions): + +- Full support: 24 months +- Security fixes: Additional 12 months +- Total lifecycle: 36 months + +**Deprecation Policy**: + +- Deprecation warnings in MINOR versions +- Removal in next MAJOR version +- Minimum 12 months between deprecation and removal + +## 14. Backend Extension Development + +### Extension Package Guidelines + +#### Package Structure + +``` +rhosocial-activerecord-{backend}/ +├── src/rhosocial/activerecord/backend/impl/{backend}/ +│ ├── __init__.py +│ ├── backend.py # Backend implementation +│ ├── dialect.py # SQL dialect specifics +│ ├── type_converter.py # Type conversions +│ └── errors.py # Backend-specific errors +├── tests/ +│ ├── providers/ # Test providers +│ ├── schemas/ # SQL schemas +│ └── conftest.py # Test configuration +├── docs/ +│ ├── installation.md +│ ├── configuration.md +│ └── compatibility.md +├── pyproject.toml +└── README.md +``` + +#### Naming Conventions + +**Package Name**: `rhosocial-activerecord-{backend}` + +- Examples: `rhosocial-activerecord-mysql`, `rhosocial-activerecord-mongodb` + +**Module Path**: `rhosocial.activerecord.backend.impl.{backend}` + +- Examples: `rhosocial.activerecord.backend.impl.mysql` + +**Backend Class**: `{Backend}Backend` + +- Examples: `MySQLBackend`, `PostgreSQLBackend` + +#### Interface Compliance + +All backends MUST implement: + +1. **StorageBackend Interface**: + + ```python + from rhosocial.activerecord.backend import StorageBackend + + class MyBackend(StorageBackend): + def connect(self) -> None: ... + def disconnect(self) -> None: ... + def execute(self, sql: str, params: Dict) -> QueryResult: ... + def insert(self, table: str, data: Dict) -> QueryResult: ... + def update(self, table: str, data: Dict, where: str) -> QueryResult: ... + def delete(self, table: str, where: str) -> QueryResult: ... + def get_server_version(self) -> tuple: ... + # ... other required methods + ``` + +2. **Capability Declaration**: + + ```python + def _initialize_capabilities(self) -> DatabaseCapabilities: + """Declare backend capabilities.""" + capabilities = DatabaseCapabilities() + # Add supported capabilities based on version/config + return capabilities + ``` + +3. **Test Provider Implementation**: + + ```python + from rhosocial.activerecord.testsuite.core import IProvider + + class MyBackendProvider(IProvider): + def setup_fixtures(self, scenario: str) -> Tuple[Type[ActiveRecord], ...]: + # Setup models and schemas + pass + + def cleanup(self, scenario: str) -> None: + # Cleanup test data + pass + ``` + +#### Capability Declaration Requirements + +Backends MUST accurately declare capabilities: + +```python +def _initialize_capabilities(self): + capabilities = DatabaseCapabilities() + version = self.get_server_version() + + # Example: MySQL 8.0+ features + if version >= (8, 0, 0): + capabilities.add_cte([ + CTECapability.BASIC_CTE, + CTECapability.RECURSIVE_CTE, + ]) + capabilities.add_window_function(ALL_WINDOW_FUNCTIONS) + + # JSON operations + if version >= (5, 7, 0): + capabilities.add_json([ + JSONCapability.JSON_EXTRACT, + JSONCapability.JSON_SET, + ]) + + return capabilities +``` + +**Capability Testing**: + +- Backend must pass all tests for declared capabilities +- Tests automatically skip for unsupported capabilities +- False capability declarations fail during CI + +#### Version Management for Extensions + +**Dependency Declaration**: + +```toml +# pyproject.toml +[project] +name = "rhosocial-activerecord-mysql" +version = "1.2.0" +dependencies = [ + "rhosocial-activerecord>=1.2.0,<2.0.0", + "mysql-connector-python>=8.0.0", +] + +[project.optional-dependencies] +test = [ + "rhosocial-activerecord-testsuite>=1.2.0,<1.3.0", + "pytest>=7.0.0", +] +``` + +**Version Increment Strategy**: + +- MAJOR: When core API breaks (follow core) +- MINOR: Backend-specific features or driver updates +- PATCH: Bug fixes and performance improvements + +**Compatibility Matrix**: +Maintain a compatibility table in README.md: + +```markdown +| Backend Version | Core Version | Driver Version | DB Version | +|-----------------|--------------|----------------|------------| +| 1.2.0 | 1.2.x | 8.0.0+ | MySQL 8.0+ | +| 1.1.5 | 1.1.x | 8.0.0+ | MySQL 5.7+ | +``` + +#### Testing Requirements + +**Unit Tests**: + +- Backend-specific functionality +- Capability declaration accuracy +- Connection handling +- Error handling +- Type conversions + +**Integration Tests**: + +- Full testsuite execution +- Provider interface compliance +- Schema setup and teardown +- Cross-scenario testing + +**Performance Tests**: + +- Query execution benchmarks +- Connection pool efficiency +- Bulk operation performance + +**Compatibility Tests**: + +- Multiple database versions +- Multiple Python versions +- Multiple core package versions (within declared range) + +### Fork Development + +**For Independent Forks**: + +Forks creating independent implementations have full autonomy but should: + +1. **Maintain Namespace Compatibility** (if desired): + + ```python + # Still use rhosocial.activerecord namespace + from rhosocial.activerecord.backend import StorageBackend + ``` + +2. **Document Compatibility**: + - State which core version fork is based on + - Document deviations from official API + - Maintain changelog of fork-specific changes + +3. **Testing**: + - Forks are responsible for their own test suite + - May use official testsuite if compatible + - Should declare compatibility with testsuite version + +4. **Versioning**: + - Independent version numbering allowed + - Should indicate original core version in docs + - Example: "Based on rhosocial-activerecord 1.2.0" + +**For Community Backends**: + +Community-developed backends can be: + +1. Submitted for inclusion in official ecosystem +2. Maintained independently with ecosystem compatibility +3. Listed in official documentation (upon review) + +**Quality Requirements for Listing**: + +- Pass official testsuite +- Accurate capability declarations +- Documentation and examples +- Maintained and responsive to issues +- License compatible with core (MIT recommended) + +## 15. Compatibility and Upgrade Strategy + +### Backward Compatibility Promise + +**Core Package**: + +- MINOR versions: Fully backward compatible +- MAJOR versions: Breaking changes allowed with migration guide +- Deprecation warnings: Minimum 12 months before removal + +**Test Suite**: + +- Test interface changes only in MAJOR versions +- New tests added in MINOR versions +- Test fixes in PATCH versions + +**Backend Packages**: + +- Must maintain compatibility with declared core version range +- Breaking changes require MAJOR version bump +- Driver updates handled in MINOR/PATCH as appropriate + +### Migration Guides + +For each MAJOR version release, provide: + +1. **Breaking Changes Summary**: List of all breaking changes +2. **Migration Steps**: Step-by-step upgrade instructions +3. **Code Examples**: Before/after comparison +4. **Automated Tools**: Scripts for common migrations (if applicable) +5. **FAQ**: Common issues and solutions + +### Deprecation Process + +1. **Announcement**: Deprecation warning in code and documentation +2. **Alternative**: Provide recommended alternative approach +3. **Timeline**: Minimum 12 months before removal +4. **Removal**: Only in next MAJOR version + +Example: + +```python +import warnings + +def old_method(self): + warnings.warn( + "old_method() is deprecated and will be removed in version 2.0. " + "Use new_method() instead.", + DeprecationWarning, + stacklevel=2 + ) + return self.new_method() +``` + +## 16. Documentation Requirements + +### Version-Specific Documentation + +Each release must include: + +**Release Notes** (`CHANGELOG.md`): + +- Version number and date (automatically generated by Towncrier) +- Breaking changes (if any) +- New features +- Bug fixes +- Deprecations +- Known issues + +**API Documentation**: + +- Auto-generated from docstrings +- Version switcher for multiple versions +- Examples for new features + +**Migration Guides** (for MAJOR versions): + +- Detailed upgrade instructions +- Breaking changes explained +- Code migration examples + +### Documentation Versioning + +- Documentation versioned with package +- Separate docs for each MAJOR.MINOR version +- Latest stable docs as default +- Dev/unstable docs available + +## 17. Current Status + +### Core Package (rhosocial-activerecord) + +**Current Version**: `1.0.0.dev11` + +**Status**: + +- ✅ Core ActiveRecord implementation stable +- ✅ Query builder functional +- ✅ Relationship management operational +- 🚧 Event hooks system in development +- 🚧 Advanced field types in progress + +**Supported Python Versions**: 3.8+ + +**Supported Pydantic Versions**: 2.x series + +**Key Features**: + +- Basic CRUD operations +- Query building with conditions +- One-to-many, many-to-one relationships +- Type-safe field definitions +- Backend abstraction layer +- Transaction management +- Optimistic locking mixin +- Timestamp tracking mixin + +### Test Suite Package (rhosocial-activerecord-testsuite) + +**Current Version**: Tracks core package + +**Status**: + +- ✅ Provider interface system operational +- ✅ Capability negotiation implemented +- ✅ Basic feature tests complete +- ✅ Query feature tests comprehensive +- 🚧 Real-world scenario tests expanding +- 🚧 Benchmark suite in development + +**Test Coverage**: + +- Feature tests: Comprehensive +- Real-world scenarios: Growing +- Performance benchmarks: Initial set +- Compatibility tests: Cross-backend + +**Supported Test Categories**: + +- `feature.basic`: CRUD, validation, fields +- `feature.query`: CTE, window functions, aggregates +- `feature.events`: Event hooks, callbacks +- `feature.mixins`: Timestamp, soft delete, optimistic lock +- `realworld.*`: Business scenario tests (planned) +- `benchmark.*`: Performance tests (planned) + +### Backend Implementations + +#### SQLite (Built-in) + +**Status**: ✅ Production-ready, actively maintained + +**Capabilities**: + +- Basic CRUD ✅ +- CTEs (version 3.8.3+) ✅ +- Window functions (version 3.25.0+) ✅ +- RETURNING clause (version 3.35.0+) ⚠️ (Python 3.10+ recommended) +- JSON operations (version 3.38.0+) ✅ +- Recursive CTEs ✅ + +**Version Support**: SQLite 3.8.3+ + +**Notes**: + +- Built-in, no additional installation +- RETURNING clause limitations in Python <3.10 +- Excellent for development and testing +- Production-ready for moderate workloads + +#### MySQL (rhosocial-activerecord-mysql) + +**Status**: ✅ Stable implementation, separate package + +**Capabilities**: + +- Basic CRUD ✅ +- CTEs (MySQL 8.0+) ✅ +- Window functions (MySQL 8.0+) ✅ +- RETURNING clause ❌ (Not supported) +- JSON operations ✅ +- Recursive CTEs ✅ +- Upsert (ON DUPLICATE KEY UPDATE) ✅ + +**Driver**: mysql-connector-python + +**Version Support**: MySQL 5.7+, MySQL 8.0+ recommended + +**Installation**: `pip install rhosocial-activerecord-mysql` + +#### Postgres (rhosocial-activerecord-postgres) + +**Status**: ✅ Stable implementation, separate package + +**Capabilities**: + +- Basic CRUD ✅ +- CTEs ✅ +- Window functions ✅ +- RETURNING clause ✅ +- JSON operations (JSONB) ✅ +- Recursive CTEs ✅ +- Upsert (ON CONFLICT) ✅ +- Array types ✅ + +**Driver**: psycopg +**Notes**: Supports psycopg (version 3) only. Psycopg2 is not supported. + +**Version Support**: Postgres 9.6+, Postgres 12+ recommended + +**Installation**: `pip install rhosocial-activerecord-postgres` + +#### Community Backends + +Community-maintained backends can be developed following the extension guidelines. Popular targets include: + +- MongoDB (planned) +- Redis (planned) +- SQL Server (community interest) +- Oracle (community interest) + +## Summary + +### Version Management Hierarchy + +``` +Core Package (Independent) + ↓ +Test Suite (Tracks MAJOR.MINOR) + ↓ +Backend Packages (Synced MAJOR, Independent MINOR/PATCH) +``` + +### Key Principles + +1. **Semantic Versioning**: Strict interpretation - MAJOR for breaking changes, MINOR for features, PATCH for fixes +2. **Pre-release Cycle**: Every MAJOR.MINOR follows dev→alpha→beta→rc→release +3. **Branch Strategy**: Pre-release branches from main, development branches from pre-release +4. **Version Bumps**: First commit in new branch updates version number +5. **Phase Transitions**: Reuse `release/vX.Y.Z` branch across alpha/beta/rc phases +6. **Merge Strategy**: Squash for features, no-ff merge for releases to preserve history +7. **Changelog Management**: Towncrier-based fragment workflow for all packages +8. **Post-Release**: Structured process for PATCH releases and security fixes +9. **Backporting**: Maintenance branches for LTS versions with independent changelog +10. **Capability System**: Enables fine-grained feature detection and test selection +11. **Coordinated Releases**: Phased release process ensures ecosystem compatibility +12. **Quality Standards**: Minimum 90% test coverage, comprehensive documentation +13. **Python Version Support**: Clear policy with migration timelines for version drops +14. **Continuous Integration**: Mandatory CI checks on all protected branches + +### Quick Reference + +**Creating New Version**: + +1. Branch from `main` → `release/vX.Y.Z.dev1` +2. First commit bumps version number +3. Create feature branches from release branch +4. Create changelog fragments for each feature +5. Merge features back to release branch (fragments stay) + +**Phase Transitions**: + +1. Create `release/vX.Y.Z` from final dev branch +2. Bump version to alpha/beta/rc in same branch +3. Continue using same branch through all phases +4. Fragments accumulate but CHANGELOG.md not updated until final + +**Final Release**: + +1. Build changelog with Towncrier (removes fragments) +2. Commit changelog to release branch +3. Merge release branch to main (no-ff) +4. Tag with version number +5. Publish to PyPI +6. Update documentation + +**Post-Release Defects**: + +1. Fix bugs in main branch with changelog fragments +2. Bump to next PATCH version +3. Build changelog with Towncrier +4. Tag and publish immediately (critical) or batch (non-critical) +5. Backport to LTS versions if needed (with fragments) + +**Abandoned Features**: + +1. Delete feature branch +2. Delete changelog fragment from pre-release branch +3. Fragment never enters CHANGELOG.md + +### Release Coordination + +1. Core package releases first (with changelog build) +2. Test suite updates for new core features (with changelog build) +3. Backend packages test and update capability declarations (with changelog build) +4. Documentation synced across all packages +5. Compatibility matrix published with each release diff --git a/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md b/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md index 092f013d..908b588f 100644 --- a/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md @@ -92,12 +92,12 @@ Go over all the following points, and put an `x` in all the boxes that apply. If you're unsure about any of these, don't hesitate to ask. We're here to help! --> -- [ ] My code follows the project's [code style guidelines](./.gemini/code_style.md). +- [ ] My code follows the project's [code style guidelines](./.claude/code_style.md). - [ ] I have performed a self-review of my own code. - [ ] I have commented my code, particularly in hard-to-understand areas. - [ ] I have made corresponding changes to the documentation (Docstrings, `README.md`, etc.). - [ ] My changes generate no new warnings. -- [ ] I have added a [Changelog fragment](./.gemini/version_control.md#5-changelog-management-with-towncrier) (`changelog.d/..md`). +- [ ] I have added a [Changelog fragment](./.claude/version_control.md#5-changelog-management-with-towncrier) (`changelog.d/..md`). - [ ] I have verified that my changes do not introduce SQL injection vulnerabilities. - [ ] I have checked for potential performance regressions. diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.md b/.github/PULL_REQUEST_TEMPLATE/feature_template.md index 156aa7a4..dcd621fd 100644 --- a/.github/PULL_REQUEST_TEMPLATE/feature_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.md @@ -79,12 +79,12 @@ Go over all the following points, and put an `x` in all the boxes that apply. If you're unsure about any of these, don't hesitate to ask. We're here to help! --> -- [ ] My code follows the project's [code style guidelines](./.gemini/code_style.md). +- [ ] My code follows the project's [code style guidelines](./.claude/code_style.md). - [ ] I have performed a self-review of my own code. - [ ] I have commented my code, particularly in hard-to-understand areas. - [ ] I have made corresponding changes to the documentation (Docstrings, `README.md`, etc.). - [ ] My changes generate no new warnings. -- [ ] I have added a [Changelog fragment](./.gemini/version_control.md#5-changelog-management-with-towncrier) (`changelog.d/..md`). +- [ ] I have added a [Changelog fragment](./.claude/version_control.md#5-changelog-management-with-towncrier) (`changelog.d/..md`). - [ ] I have verified that my changes do not introduce SQL injection vulnerabilities. - [ ] I have checked for potential performance regressions. diff --git a/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md b/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md index a3fe229d..506127b9 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md @@ -66,7 +66,7 @@ For hotfixes, describe the specific tests performed to validate the fix. Go over all the following points, and put an `x` in all the boxes that apply. --> -- [ ] My changes follow the project's [version control guidelines](./.gemini/version_control.md) (especially for release and hotfix procedures). +- [ ] My changes follow the project's [version control guidelines](./.claude/version_control.md) (especially for release and hotfix procedures). - [ ] I have updated the `CHANGELOG.md` using `towncrier build` (for releases). - [ ] All necessary documentation (e.g., release notes, migration guides) has been prepared or updated. - [ ] I have confirmed all CI checks have passed on the target branch after merging (post-merge verification). diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad4f0351..102ecdc0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,7 @@ on: - main - 'release/v**' - 'maint/**' + workflow_dispatch: permissions: contents: read @@ -67,8 +68,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev27 - pip install rhosocial-activerecord-testsuite==1.0.0.dev14 + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord + pip install rhosocial-activerecord-testsuite==1.0.0.dev15 - name: Create postgres scenario config run: | @@ -121,14 +122,31 @@ jobs: python -m pytest \ --rootdir=. \ --confcutdir=. \ - "$TESTSUITE_DIR/benchmark/crud" \ + "$TESTSUITE_DIR/benchmark/crud/test_delete_async.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_delete_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_insert_async.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_insert_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_query_async.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_query_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_update_async.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_update_sync.py" \ "$TESTSUITE_DIR/benchmark/query" \ "$TESTSUITE_DIR/benchmark/transaction" \ "$TESTSUITE_DIR/benchmark/mixin" \ -m benchmark \ --benchmark-size=small \ --benchmark-sort=mean \ + --strict-markers \ --tb=short | tee benchmark-output.txt + python -m pytest \ + --rootdir=. \ + --confcutdir=. \ + tests/benchmark/activerecord_bulk \ + -m benchmark \ + --benchmark-size=small \ + --benchmark-sort=mean \ + --strict-markers \ + --tb=short | tee -a benchmark-output.txt python -m pytest \ --rootdir=. \ --confcutdir=. \ @@ -136,6 +154,7 @@ jobs: -m benchmark \ --benchmark-size=small \ --benchmark-sort=mean \ + --strict-markers \ --tb=short | tee -a benchmark-output.txt - name: Run FastAPI benchmark suite @@ -166,12 +185,16 @@ jobs: echo echo "- Python: 3.14" echo "- postgres: 18" - echo "- Scope: testsuite generic benchmarks + PostgreSQL backend direct benchmarks" + echo "- Scope: testsuite generic benchmarks + ActiveRecord bulk benchmarks + PostgreSQL backend direct benchmarks" echo "- Size: small" echo "- Result: benchmark job fails only when benchmark execution or correctness checks fail." echo echo '```text' - grep -A 120 "benchmark:" benchmark-output.txt || cat benchmark-output.txt + if [ -f benchmark-output.txt ]; then + grep -A 120 "benchmark:" benchmark-output.txt || tail -n 200 benchmark-output.txt + else + echo "benchmark-output.txt was not generated." + fi echo '```' } >> "$GITHUB_STEP_SUMMARY" @@ -188,10 +211,119 @@ jobs: echo "- Result: benchmark job fails only when FastAPI benchmark execution or correctness checks fail." echo echo '```text' - grep -A 120 "benchmark:" fastapi-benchmark-output.txt || cat fastapi-benchmark-output.txt + if [ -f fastapi-benchmark-output.txt ]; then + grep -A 120 "benchmark:" fastapi-benchmark-output.txt || tail -n 200 fastapi-benchmark-output.txt + else + echo "fastapi-benchmark-output.txt was not generated." + fi echo '```' } >> "$GITHUB_STEP_SUMMARY" + test-experimental-python-315: + # Temporarily disabled until Python 3.15 beta/toolchain compatibility stabilizes. + if: ${{ false }} + name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} (experimental) + runs-on: ubuntu-latest + permissions: + contents: read + continue-on-error: true + strategy: + fail-fast: false + matrix: + include: + - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: false } + - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: true } + + services: + postgres: + image: postgres:${{ matrix.postgres-version }} + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + postgres-exts: + image: vistart/postgres-exts:${{ matrix.postgres-version }} + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }}${{ matrix.freethreaded && ' free-threaded' || '' }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + freethreaded: ${{ matrix.freethreaded }} + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Install activerecord and testsuite + run: | + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord + pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + + - name: Create postgres scenario config + run: | + cat > postgres_scenarios.yaml << EOF + scenarios: + postgres_default: + host: 127.0.0.1 + port: 5432 + database: test_db + username: root + password: password + postgres_exts: + host: 127.0.0.1 + port: 5433 + database: test_db + username: root + password: password + EOF + echo "POSTGRES_SCENARIOS_CONFIG_PATH=$(pwd)/postgres_scenarios.yaml" >> $GITHUB_ENV + + - name: Install postgres client for health check + run: | + sudo apt-get update + sudo apt-get install --yes postgresql-client + + - name: Wait for postgres service + run: | + until pg_isready -h 127.0.0.1 -p 5432 -U root; do + echo "Waiting for postgres..." + sleep 2 + done + echo "postgres is ready!" + until pg_isready -h 127.0.0.1 -p 5433 -U root; do + echo "Waiting for postgres-exts..." + sleep 2 + done + echo "postgres-exts is ready!" + + - name: Run tests + run: | + export PYTHONPATH=src:tests + python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup + test: name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest @@ -259,8 +391,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev27 - pip install rhosocial-activerecord-testsuite==1.0.0.dev14 + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord + pip install rhosocial-activerecord-testsuite==1.0.0.dev15 - name: Create postgres scenario config run: | @@ -321,3 +453,73 @@ jobs: flags: unittests,python-${{ matrix.python-version }},postgres-${{ matrix.postgres-version }} name: codecov-postgres-${{ matrix.python-version }}-${{ matrix.postgres-version }} fail_ci_if_error: false + + test-postgres-19: + name: Python 3.14 / postgres 19beta1 (PGQ) + runs-on: ubuntu-latest + continue-on-error: true + + services: + postgres: + image: postgres:19beta1 + env: + POSTGRES_DB: test_db + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Install activerecord and testsuite + run: | + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord + pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + + - name: Create postgres scenario config + run: | + cat > postgres_scenarios.yaml << EOF + scenarios: + postgres_default: + host: 127.0.0.1 + port: 5432 + database: test_db + username: root + password: password + EOF + echo "POSTGRES_SCENARIOS_CONFIG_PATH=$(pwd)/postgres_scenarios.yaml" >> $GITHUB_ENV + + - name: Install postgres client for health check + run: | + sudo apt-get update + sudo apt-get install --yes postgresql-client + + - name: Wait for postgres service + run: | + until pg_isready -h 127.0.0.1 -p 5432 -U root; do + echo "Waiting for postgres..." + sleep 2 + done + echo "postgres is ready!" + + - name: Run tests + run: | + export PYTHONPATH=src:tests + python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed0595c..884197a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [v1.0.0.dev15] - 2026-06-15 + +### Added + +- Added PostgreSQL expression-level `COLLATE` support with schema-qualified collation validation. ([#40](https://github.com/rhosocial/python-activerecord-postgres/issues/40)) +- Added PostgreSQL support for core SQL/XML standard expressions, including XML construction, serialization, aggregation, and query/table expression capability declarations. ([#41](https://github.com/rhosocial/python-activerecord-postgres/issues/41)) +- Added PostgreSQL dialect support for datetime interval expressions (`EXTRACT`, `DATE_PART`, `DATE_TRUNC`, `INTERVAL` add/subtract, date diff) with EXPLAIN-backed index usage verification tests. ([#42](https://github.com/rhosocial/python-activerecord-postgres/issues/42)) +- Added comprehensive PostgreSQL partitioning support including ATTACH CONCURRENTLY/DEFAULT partition, partition auto-management via named expressions (monthly/quarterly), pg_partman create_parent parameter completion, enhanced partition metadata queries, and full test coverage ([#43](https://github.com/rhosocial/python-activerecord-postgres/issues/43)) +- Add SQL/PGQ property graph query support and PostgreSQL 19 compatibility enhancements ([#44](https://github.com/rhosocial/python-activerecord-postgres/issues/44)) + + + +### Fixed + +- Fill all PostgreSQL index, trigger, table, and extension protocol feature coverage gaps ([#39](https://github.com/rhosocial/python-activerecord-postgres/issues/39)) + + ## [v1.0.0.dev14] - 2026-05-30 ### Changed diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index 9db1f4d7..61cac59e 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -13,9 +13,50 @@ The PostgreSQL backend supports the same type-safe DDL expressions as the core l | `DropIndexExpression` | ✅ Full | | | `CreateViewExpression` | ✅ Full | Materialized views support | | `DropViewExpression` | ✅ Full | | +| `CreatePartitionExpression` | ✅ Full | RANGE, LIST, HASH partitioning (PG 10+) | +| `AttachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | +| `DetachPartitionExpression` | ✅ Full | Includes CONCURRENTLY (PG 14+) | +| `AlterIndexExpression` | ✅ Full | RENAME TO, SET TABLESPACE, SET/RESET storage params | +| `ReindexExpression` | ✅ Full | INDEX/TABLE/SCHEMA/DATABASE level, with CONCURRENTLY | ## PostgreSQL-Specific Features +### Partition Support + +PostgreSQL 10+ supports declarative partitioning. See [Partition Documentation](../postgres_specific_features/partition.md). + +```python +partition = PostgresCreatePartitionExpression( + dialect, parent_table="orders", partition_name="orders_2024_q1", + partition_type="RANGE", bounds="FROM ('2024-01-01') TO ('2024-04-01')", +) +``` + +### pg_partman Extension + +See [Partition Documentation](../postgres_specific_features/partition.md) for pg_partman support. + +### Index Operation Enhancements + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.index import ( + PostgresAlterIndexExpression, PostgresAlterIndexActionType, + PostgresReindexExpression, +) + +# ALTER INDEX ... RENAME TO +alter = PostgresAlterIndexExpression( + dialect, index_name="idx_old", + action=PostgresAlterIndexActionType.RENAME_TO("idx_new"), +) + +# REINDEX CONCURRENTLY +reindex = PostgresReindexExpression( + dialect, target_type="INDEX", target_name="idx_corrupted", + concurrently=True, +) +``` + ### Index Types PostgreSQL supports various index types: diff --git a/docs/en_US/postgres_specific_features/README.md b/docs/en_US/postgres_specific_features/README.md index 57232011..d26a8ff4 100644 --- a/docs/en_US/postgres_specific_features/README.md +++ b/docs/en_US/postgres_specific_features/README.md @@ -11,6 +11,8 @@ PostgreSQL offers many advanced features beyond standard SQL. This section cover - **[Protocol Support Matrix](./protocol_support.md)**: Comprehensive protocol support and version compatibility - **[Database Introspection](./introspection.md)**: Query metadata using pg_catalog - **[EXPLAIN Support](./explain.md)**: Query execution plan analysis and performance diagnostics +- **[Table Partitioning](partition.md)**: RANGE / LIST / HASH partitioning and pg_partman management +- **[Property Graph Query](property_graph_query.md)**: SQL/PGQ property graph query (PG 19+) ## Feature Highlights diff --git a/docs/en_US/postgres_specific_features/partition.md b/docs/en_US/postgres_specific_features/partition.md new file mode 100644 index 00000000..64bfdb27 --- /dev/null +++ b/docs/en_US/postgres_specific_features/partition.md @@ -0,0 +1,69 @@ +# PostgreSQL Partitioning + +PostgreSQL supports declarative partitioning from version 10 onward, with significant enhancements in version 11+. + +## Partitioning Strategies + +| Strategy | Description | Minimum Version | +|----------|-------------|-----------------| +| RANGE | Range partitioning | PG 10 | +| LIST | List partitioning | PG 10 | +| HASH | Hash partitioning | PG 11 | + +## Creating Partitions + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresCreatePartitionExpression, +) + +partition = PostgresCreatePartitionExpression( + dialect, parent_table="orders", partition_name="orders_2024_q1", + partition_type="RANGE", bounds="FROM ('2024-01-01') TO ('2024-04-01')", +) +``` + +## Partition Management + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresAttachPartitionExpression, PostgresDetachPartitionExpression, +) + +# ATTACH PARTITION +attach = PostgresAttachPartitionExpression( + dialect, parent_table="orders", partition_name="orders_new", + partition_type="RANGE", bounds="FROM ('2024-07-01') TO ('2024-10-01')", +) + +# DETACH PARTITION CONCURRENTLY (PG 14+) +detach = PostgresDetachPartitionExpression( + dialect, parent_table="orders", partition_name="orders_old", + concurrently=True, +) +``` + +## pg_partman Extension + +```python +from rhosocial.activerecord.backend.impl.postgres.functions.pg_partman import create_parent, run_maintenance + +# Create parent table with monthly partitioning +expr = create_parent( + dialect, parent_table="public.orders", + control="created_at", interval="1 month", + partition_type="range", premake=3, +) + +# Run maintenance +expr = run_maintenance(dialect, parent_table="public.orders") +``` + +## Dialect Feature Detection + +```python +if dialect.supports_default_partition(): + # PG 11+: DEFAULT partition +if dialect.supports_concurrent_detach(): + # PG 14+: Non-blocking partition detach +``` diff --git a/docs/en_US/postgres_specific_features/property_graph_query.md b/docs/en_US/postgres_specific_features/property_graph_query.md new file mode 100644 index 00000000..e75f060d --- /dev/null +++ b/docs/en_US/postgres_specific_features/property_graph_query.md @@ -0,0 +1,57 @@ +# Property Graph Query + +PostgreSQL 19+ supports the SQL/PGQ (Property Graph Query) standard for property graph queries. + +## MATCH Clause + +```python +from rhosocial.activerecord.backend.expression.graph import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, + GraphTableExpression, GraphColumn, ColumnsClause, +) + +person = GraphVertex(dialect, variable="p", table="persons") +product = GraphVertex(dialect, variable="pr", table="products") +purchased = GraphEdge(dialect, variable="pu", table="purchases", + direction=GraphEdgeDirection.RIGHT) + +match = MatchClause(dialect, person, purchased, product) +# sql: 'MATCH (p) - [pu] -> (pr)' + +graph_table = GraphTableExpression( + dialect, + match_clause=match, + columns=ColumnsClause(dialect, columns=[ + GraphColumn(dialect, name="person_name", type="VARCHAR"), + GraphColumn(dialect, name="product_name", type="VARCHAR"), + ]), +) +``` + +## Property Graph DDL + +```python +from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphExpression, DropPropertyGraphExpression, + VertexTable, EdgeTable, +) + +create_graph = CreatePropertyGraphExpression( + dialect, + graph_name="social_graph", + vertices=[VertexTable(dialect, table_name="persons", graph_label="Person")], + edges=[EdgeTable(dialect, table_name="knows", + source_vertex="Person", dest_vertex="Person")], +) +``` + +## Dialect Feature Detection + +```python +if dialect.supports_graph_match(): + # PG 19+: MATCH clause +if dialect.supports_graph_table(): + # PG 19+: GRAPH_TABLE expression +``` + +> **Note**: SQL/PGQ support requires PostgreSQL 19+. diff --git a/docs/en_US/postgres_specific_features/protocol_support.md b/docs/en_US/postgres_specific_features/protocol_support.md index bc11dc51..a32c4c89 100644 --- a/docs/en_US/postgres_specific_features/protocol_support.md +++ b/docs/en_US/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ The PostgreSQL dialect implements numerous protocols from the `rhosocial.activer | **ArraySupport** | ✅ Full | All versions | Native array types and operations | | **JSONSupport** | ✅ Full | ≥ 9.2 | JSON type; JSONB ≥ 9.4; JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ Full | All versions | EXPLAIN and EXPLAIN ANALYZE with multiple formats | -| **GraphSupport** | ❌ None | N/A | No native graph query MATCH clause | +| **GraphSupport** | ✅ Full | PG 19+ | SQL/PGQ property graph query MATCH and GRAPH_TABLE | | **LockingSupport** | ✅ Partial | ≥ 9.5 | FOR UPDATE; SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ Full | ≥ 15.0 | MERGE statement | | **OrderedSetAggregationSupport** | ✅ Full | ≥ 9.4 | Ordered-set aggregate functions | @@ -209,7 +209,11 @@ In addition to standard protocols, PostgreSQL provides database-specific protoco | Protocol | Description | Min Version | Documentation | |----------|-------------|-------------|---------------| -| **PostgresPartitionSupport** | Advanced partitioning features | PG 11+ | [Partitioning Docs](https://www.postgresql.org/docs/current/ddl-partitioning.html) | +| **PostgresPartitionSupport** | Advanced partitioning features | PG 10+ | [Partitioning Docs](https://www.postgresql.org/docs/current/ddl-partitioning.html) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ property graph | PG 19+ | [Property Graph Query](property_graph_query.md) | +| **PostgresPgPartmanSupport** | pg_partman auto-partitioning | PG 10+ | [Partitioning](partition.md) | +| **PostgresXMLMixin** | SQL/XML standard expressions | PG 8.3+ | [Dialect](dialect.md) | +| **PostgresCollationMixin** | Expression-level COLLATE | All | [Dialect](dialect.md) | | **PostgresIndexSupport** | Index enhancements | PG 10+ | [Index Docs](https://www.postgresql.org/docs/current/indexes.html) | | **PostgresVacuumSupport** | VACUUM improvements | PG 13+ | [VACUUM Docs](https://www.postgresql.org/docs/current/sql-vacuum.html) | | **PostgresQueryOptimizationSupport** | Query optimization features | PG 11+ | [Query Docs](https://www.postgresql.org/docs/current/runtime-config-query.html) | diff --git a/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_async.py b/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_async.py index 2c5d0cdf..d228055c 100644 --- a/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_async.py +++ b/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_async.py @@ -129,9 +129,9 @@ async def main(): test_backend = AsyncPostgresBackend(**postgres_config) await test_backend.connect() print(f"Backend threadsafety: {test_backend.threadsafety}") - print(f" 0 = None (not thread-safe)") - print(f" 1 = driver (only supports SQL)") - print(f" 2 = Full thread-safe (psycopg)") + print(f" 0 = None (not thread-safe)") # noqa: F541 + print(f" 1 = driver (only supports SQL)") # noqa: F541 + print(f" 2 = Full thread-safe (psycopg)") # noqa: F541 await test_backend.disconnect() # Create connection pool with higher load @@ -144,7 +144,7 @@ async def main(): backend_factory=lambda: AsyncPostgresBackend(**postgres_config) ) - print(f"\nPool configuration:") + print(f"\nPool configuration:") # noqa: F541 print(f" min_size: {config.min_size}") print(f" max_size: {config.max_size}") print(f" connection_mode: {config.connection_mode}") diff --git a/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_sync.py b/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_sync.py index 50642e35..076f25c0 100644 --- a/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_sync.py +++ b/docs/examples/chapter_02_connection_pool/connection_pool_stress_test_sync.py @@ -131,9 +131,9 @@ def main(): test_backend = PostgresBackend(**postgres_config) test_backend.connect() print(f"Backend threadsafety: {test_backend.threadsafety}") - print(f" 0 = None (not thread-safe)") - print(f" 1 = driver (only supports SQL)") - print(f" 2 = Full thread-safe (psycopg)") + print(f" 0 = None (not thread-safe)") # noqa: F541 + print(f" 1 = driver (only supports SQL)") # noqa: F541 + print(f" 2 = Full thread-safe (psycopg)") # noqa: F541 test_backend.disconnect() # Create connection pool with higher load @@ -146,7 +146,7 @@ def main(): backend_factory=lambda: PostgresBackend(**postgres_config) ) - print(f"\nPool configuration:") + print(f"\nPool configuration:") # noqa: F541 print(f" min_size: {config.min_size}") print(f" max_size: {config.max_size}") print(f" connection_mode: {config.connection_mode}") diff --git a/docs/examples/chapter_05_worker_isolation/config.py b/docs/examples/chapter_05_worker_isolation/config.py index e6a56e13..29a01103 100644 --- a/docs/examples/chapter_05_worker_isolation/config.py +++ b/docs/examples/chapter_05_worker_isolation/config.py @@ -5,7 +5,7 @@ import os import yaml -from typing import Optional +from typing import Optional # noqa: F401 from rhosocial.activerecord.backend.impl.postgres import PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig @@ -115,5 +115,5 @@ def setup_database(backend: PostgresBackend, drop_existing: bool = True) -> None for table in reversed(list(SCHEMA_SQL.keys())): backend.execute(f"DROP TABLE IF EXISTS {table} CASCADE") - for table, sql in SCHEMA_SQL.items(): + for table, sql in SCHEMA_SQL.items(): # noqa: B007 backend.execute(sql) \ No newline at end of file diff --git a/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py b/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py index 66aff9bf..a26a556a 100644 --- a/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py +++ b/docs/examples/chapter_05_worker_isolation/exp1_shared_connection.py @@ -19,22 +19,22 @@ from __future__ import annotations -import multiprocessing +import multiprocessing # noqa: F401 import os import random import sys import time -import threading -from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed +import threading # noqa: F401 +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed # noqa: F401 from dataclasses import dataclass -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional # noqa: F401 from decimal import Decimal # Add parent directories to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) -from config import load_scenario_config, get_backend_class, setup_database -from models import User, Order, Post, Comment, ALL_MODELS +from config import load_scenario_config, get_backend_class, setup_database # noqa: F401 +from models import User, Order, Post, Comment, ALL_MODELS # noqa: F401 @dataclass @@ -153,7 +153,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker and letting all worker processes share the same connection. """ print(f"\n{'='*70}") - print(f"Experiment 1: Shared Global Connection (ANTI-PATTERN)") + print(f"Experiment 1: Shared Global Connection (ANTI-PATTERN)") # noqa: F541 print(f"{'='*70}") print(f"Workers: {num_workers}") print(f"Operations per worker: {ops_per_worker}") @@ -183,7 +183,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker for i in range(10): user = User(username=f"seed_user_{i}", email=f"seed{i}@test.com", balance=100.0) user.save() - print(f" Created 10 seed users") + print(f" Created 10 seed users") # noqa: F541 # Run workers with shared connection print(f"\nStep 3: Running {num_workers} workers with SHARED connection...") @@ -230,7 +230,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker # Show sample errors all_errors = [e for r in results for e in r.errors] if all_errors: - print(f"\n Sample errors (first 5):") + print(f"\n Sample errors (first 5):") # noqa: F541 for err in all_errors[:5]: print(f" - {err[:100]}...") @@ -243,7 +243,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker for table in ['comments', 'orders', 'posts', 'users']: backend.execute(f"DROP TABLE IF EXISTS `{table}`") backend.execute("SET FOREIGN_KEY_CHECKS = 1") - except: + except: # noqa: E722 pass backend.disconnect() diff --git a/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py b/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py index b04d7308..a7967284 100644 --- a/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py +++ b/docs/examples/chapter_05_worker_isolation/exp2_isolated_connection.py @@ -19,20 +19,20 @@ from __future__ import annotations -import multiprocessing +import multiprocessing # noqa: F401 import os import random import sys import time from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional # noqa: F401 from decimal import Decimal # Add parent directories to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) -from config import load_scenario_config, get_backend_class, SCHEMA_SQL +from config import load_scenario_config, get_backend_class, SCHEMA_SQL # noqa: F401 @dataclass @@ -51,7 +51,7 @@ def _ensure_tables_exist(backend) -> None: backend.execute("SET FOREIGN_KEY_CHECKS = 0") try: # Just create if not exists - don't drop - for table, sql in SCHEMA_SQL.items(): + for table, sql in SCHEMA_SQL.items(): # noqa: B007 # Modify to CREATE IF NOT EXISTS sql_modified = sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE IF NOT EXISTS") try: @@ -81,7 +81,7 @@ def worker_task_isolated_connection( """ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig - from models import User, Order, Post, Comment, ALL_MODELS + from models import User, Order, Post, Comment, ALL_MODELS # noqa: F401 start_time = time.time() errors = [] @@ -181,7 +181,7 @@ def worker_task_isolated_connection( model.__backend__ = None # Disconnect backend.disconnect() - except: + except: # noqa: E722 pass duration = time.time() - start_time @@ -203,7 +203,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker Each worker process creates its own connection and manages its lifecycle. """ print(f"\n{'='*70}") - print(f"Experiment 2: Process-Isolated Connections (CORRECT PATTERN)") + print(f"Experiment 2: Process-Isolated Connections (CORRECT PATTERN)") # noqa: F541 print(f"{'='*70}") print(f"Workers: {num_workers}") print(f"Operations per worker: {ops_per_worker}") @@ -233,11 +233,11 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker for model in ALL_MODELS: model.__backend__ = None setup_backend.disconnect() - print(f" Created 10 seed users") + print(f" Created 10 seed users") # noqa: F541 # Run workers with isolated connections print(f"\nStep 2: Running {num_workers} workers with ISOLATED connections...") - print(f" Each worker creates its own database connection") + print(f" Each worker creates its own database connection") # noqa: F541 start_time = time.time() results: List[TaskResult] = [] @@ -280,11 +280,11 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker # Show sample errors all_errors = [e for r in results for e in r.errors] if all_errors: - print(f"\n Sample errors (first 5):") + print(f"\n Sample errors (first 5):") # noqa: F541 for err in all_errors[:5]: print(f" - {err[:100]}...") else: - print(f"\n ✅ No errors detected!") + print(f"\n ✅ No errors detected!") # noqa: F541 print(f"{'='*70}\n") @@ -298,7 +298,7 @@ def run_experiment(config_dict: Dict[str, Any], num_workers: int, ops_per_worker cleanup_backend.execute(f"DROP TABLE IF EXISTS `{table}`") cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 1") cleanup_backend.disconnect() - except: + except: # noqa: E722 pass return { diff --git a/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py b/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py index 2ed78e35..bb89f5af 100644 --- a/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py +++ b/docs/examples/chapter_05_worker_isolation/exp3_endurance_test.py @@ -21,32 +21,32 @@ from __future__ import annotations import argparse -import gc -import multiprocessing +import gc # noqa: F401 +import multiprocessing # noqa: F401 import os import random import sys import time -import traceback +import traceback # noqa: F401 from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass, field -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional # noqa: F401 from decimal import Decimal -import threading +import threading # noqa: F401 # Add parent directories to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) -from config import load_scenario_config, get_backend_class, SCHEMA_SQL +from config import load_scenario_config, get_backend_class, SCHEMA_SQL # noqa: F401 # Import from core package worker_pool module from rhosocial.activerecord.worker_pool import ( - TaskDefinition, - TaskResult, - TaskMode, - TaskPriority, - register_handler, - WorkerPool, + TaskDefinition, # noqa: F401 + TaskResult, # noqa: F401 + TaskMode, # noqa: F401 + TaskPriority, # noqa: F401 + register_handler, # noqa: F401 + WorkerPool, # noqa: F401 ) @@ -95,7 +95,7 @@ def worker_task( """ from rhosocial.activerecord.backend.impl.mysql import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig - from models import User, Order, Post, Comment, ALL_MODELS + from models import User, Order, Post, Comment, ALL_MODELS # noqa: F401 start_time = time.time() operations = 0 @@ -196,7 +196,7 @@ def record_error(error_msg: str): for model in ALL_MODELS: model.__backend__ = None backend.disconnect() - except: + except: # noqa: E722 pass duration = time.time() - start_time @@ -284,7 +284,7 @@ def run_endurance_test( if error_type == "connection": stats.connection_issues += count - except Exception as e: + except Exception as e: # noqa: F841 stats.total_errors += 1 stats.error_breakdown["executor"] = stats.error_breakdown.get("executor", 0) + 1 @@ -296,7 +296,7 @@ def run_endurance_test( # Progress report if round_num % 5 == 0 or round_num == num_rounds: elapsed = time.time() - start_time - avg_ops = stats.total_operations / round_num if round_num > 0 else 0 + avg_ops = stats.total_operations / round_num if round_num > 0 else 0 # noqa: F841 print(f" Round {round_num}/{num_rounds}: " f"{round_ops} ops, {round_errors} errors, " f"{round_duration:.2f}s, " @@ -319,7 +319,7 @@ def run_endurance_test( print(f" Success rate: {(1 - stats.total_errors/(stats.total_operations+1))*100:.2f}%") if stats.error_breakdown: - print(f"\n Error breakdown:") + print(f"\n Error breakdown:") # noqa: F541 for error_type, count in sorted(stats.error_breakdown.items()): print(f" {error_type}: {count}") @@ -346,7 +346,7 @@ def run_endurance_test( cleanup_backend.execute(f"DROP TABLE IF EXISTS `{table}`") cleanup_backend.execute("SET FOREIGN_KEY_CHECKS = 1") cleanup_backend.disconnect() - except: + except: # noqa: E722 pass return stats @@ -384,7 +384,7 @@ def main(): print("\n" + "="*70) print("Worker Isolation Experiment - Endurance Test") print("="*70) - print(f"\nConfiguration:") + print(f"\nConfiguration:") # noqa: F541 print(f" Pattern: {args.pattern}") print(f" Workers: {args.workers}") print(f" Operations per round: {args.ops_per_round}") diff --git a/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py b/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py index 689bfda4..3b19ffb7 100644 --- a/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py +++ b/docs/examples/chapter_08_scenarios/parallel_workers/exp6_backend_group_pool.py @@ -32,7 +32,7 @@ from rhosocial.activerecord.connection import BackendGroup from rhosocial.activerecord.connection.pool import PoolConfig, BackendPool -from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig # noqa: F401 from config_loader import load_config from models import Comment, Post, User @@ -266,7 +266,7 @@ def main(): pool_time = time.time() - start_time pool.close() - print(f"\nResults (BackendPool):") + print(f"\nResults (BackendPool):") # noqa: F541 total_success = sum(r["success"] for r in results_pool) total_errors = sum(len(r["errors"]) for r in results_pool) for r in results_pool: @@ -312,7 +312,7 @@ def main(): group_time = time.time() - start_time group.disconnect() - print(f"\nResults (BackendGroup):") + print(f"\nResults (BackendGroup):") # noqa: F541 total_success = sum(r["success"] for r in results_group) total_errors = sum(len(r["errors"]) for r in results_group) for r in results_group: diff --git a/docs/examples/chapter_10_fastapi/app.py b/docs/examples/chapter_10_fastapi/app.py index a3396506..9a565708 100644 --- a/docs/examples/chapter_10_fastapi/app.py +++ b/docs/examples/chapter_10_fastapi/app.py @@ -55,13 +55,13 @@ sys.path.insert(0, _src) from contextlib import asynccontextmanager -from typing import List, Optional +from typing import List, Optional # noqa: F401 from fastapi import FastAPI, Depends, HTTPException, status -from fastapi.encoders import jsonable_encoder +from fastapi.encoders import jsonable_encoder # noqa: F401 from pydantic import BaseModel, EmailStr -from config_loader import load_config +from config_loader import load_config # noqa: F401 from database import get_request_db, init_database, init_pool, close_pool from models import AsyncUser, AsyncPost, AsyncComment @@ -138,7 +138,7 @@ async def lifespan(app: FastAPI): # ───────────────────────────────────────────────────────────────────────────── @app.get("/users", response_model=List[UserResponse]) -async def list_users(db=Depends(get_request_db)): +async def list_users(db=Depends(get_request_db)): # noqa: B008 """List all users.""" users = await AsyncUser.query().all() return [UserResponse( @@ -150,7 +150,7 @@ async def list_users(db=Depends(get_request_db)): @app.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def create_user(user_data: UserCreate, db=Depends(get_request_db)): +async def create_user(user_data: UserCreate, db=Depends(get_request_db)): # noqa: B008 """Create a new user.""" user = AsyncUser( username=user_data.username, @@ -168,7 +168,7 @@ async def create_user(user_data: UserCreate, db=Depends(get_request_db)): @app.get("/users/{user_id}", response_model=UserResponse) -async def get_user(user_id: int, db=Depends(get_request_db)): +async def get_user(user_id: int, db=Depends(get_request_db)): # noqa: B008 """Get a single user by ID.""" user = await AsyncUser.find_one(user_id) if not user: @@ -187,7 +187,7 @@ async def get_user(user_id: int, db=Depends(get_request_db)): # ───────────────────────────────────────────────────────────────────────────── @app.post("/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED) -async def create_post(post_data: PostCreate, db=Depends(get_request_db)): +async def create_post(post_data: PostCreate, db=Depends(get_request_db)): # noqa: B008 """Create a new post.""" # Validate user exists user = await AsyncUser.find_one(post_data.user_id) @@ -213,7 +213,7 @@ async def create_post(post_data: PostCreate, db=Depends(get_request_db)): @app.get("/posts", response_model=List[PostResponse]) -async def list_posts(db=Depends(get_request_db)): +async def list_posts(db=Depends(get_request_db)): # noqa: B008 """List all posts.""" posts = await AsyncPost.query().all() return [PostResponse( @@ -227,7 +227,7 @@ async def list_posts(db=Depends(get_request_db)): @app.get("/posts/{post_id}", response_model=PostResponse) -async def get_post(post_id: int, db=Depends(get_request_db)): +async def get_post(post_id: int, db=Depends(get_request_db)): # noqa: B008 """Get a single post by ID.""" post = await AsyncPost.find_one(post_id) if not post: @@ -244,7 +244,7 @@ async def get_post(post_id: int, db=Depends(get_request_db)): @app.get("/users/{user_id}/posts", response_model=List[PostResponse]) -async def get_user_posts(user_id: int, db=Depends(get_request_db)): +async def get_user_posts(user_id: int, db=Depends(get_request_db)): # noqa: B008 """Get all posts by a user.""" user = await AsyncUser.find_one(user_id) if not user: @@ -268,7 +268,7 @@ async def get_user_posts(user_id: int, db=Depends(get_request_db)): # ───────────────────────────────────────────────────────────────────────────── @app.post("/posts/{post_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) -async def create_comment(post_id: int, comment_data: CommentCreate, db=Depends(get_request_db)): +async def create_comment(post_id: int, comment_data: CommentCreate, db=Depends(get_request_db)): # noqa: B008 """Add a comment to a post.""" # Validate post exists post = await AsyncPost.find_one(post_id) @@ -298,7 +298,7 @@ async def create_comment(post_id: int, comment_data: CommentCreate, db=Depends(g @app.get("/posts/{post_id}/comments", response_model=List[CommentResponse]) -async def get_post_comments(post_id: int, db=Depends(get_request_db)): +async def get_post_comments(post_id: int, db=Depends(get_request_db)): # noqa: B008 """Get all comments for a post.""" post = await AsyncPost.find_one(post_id) if not post: diff --git a/docs/examples/chapter_10_fastapi/database.py b/docs/examples/chapter_10_fastapi/database.py index 58d671ac..bec2a2a4 100644 --- a/docs/examples/chapter_10_fastapi/database.py +++ b/docs/examples/chapter_10_fastapi/database.py @@ -26,7 +26,7 @@ if _src not in sys.path: sys.path.insert(0, _src) -from fastapi import Request +from fastapi import Request # noqa: F401 from rhosocial.activerecord.connection.pool import PoolConfig, AsyncBackendPool from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend diff --git a/docs/examples/chapter_12_named_procedure/order_workflow.py b/docs/examples/chapter_12_named_procedure/order_workflow.py index b6b511a3..cda78942 100644 --- a/docs/examples/chapter_12_named_procedure/order_workflow.py +++ b/docs/examples/chapter_12_named_procedure/order_workflow.py @@ -192,7 +192,7 @@ async def run(self, ctx: ProcedureContext) -> None: if __name__ == "__main__": - from rhosocial.activerecord.backend.named_expression import ProcedureDiagram + from rhosocial.activerecord.backend.named_expression import ProcedureDiagram # noqa: F401 print("=== 静态流程图 ===") print(OrderProcessingProcedure.static_diagram("flowchart")) diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index 063547e5..5fde20e7 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -13,9 +13,59 @@ PostgreSQL 后端支持与核心库相同的类型安全 DDL 表达式,并具 | `DropIndexExpression` | ✅ 完整 | | | `CreateViewExpression` | ✅ 完整 | 物化视图支持 | | `DropViewExpression` | ✅ 完整 | | +| `CreatePartitionExpression` | ✅ 完整 | RANGE, LIST, HASH 分区(PG 10+) | +| `AttachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | +| `DetachPartitionExpression` | ✅ 完整 | 含 CONCURRENTLY(PG 14+) | +| `AlterIndexExpression` | ✅ 完整 | RENAME TO, SET TABLESPACE, SET/RESET 存储参数 | +| `ReindexExpression` | ✅ 完整 | 索引/表/模式/数据库级重建,含 CONCURRENTLY | ## PostgreSQL 特性 +### 分区支持 + +PostgreSQL 10+ 支持声明式分区,详见 [分区文档](../postgres_specific_features/partition.md)。 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresCreatePartitionExpression, PostgresAttachPartitionExpression, +) + +# 创建子分区 +partition = PostgresCreatePartitionExpression( + dialect, parent_table="orders", partition_name="orders_2024_q1", + partition_type="RANGE", bounds="FROM ('2024-01-01') TO ('2024-04-01')", +) +``` + +### pg_partman 扩展 + +pg_partman 扩展支持自动分区管理,详见 [分区文档](../postgres_specific_features/partition.md)。 + +### 索引操作增强 + +PG 后端支持高级索引操作: + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.index import ( + PostgresAlterIndexExpression, PostgresAlterIndexActionType, + PostgresReindexExpression, +) + +# ALTER INDEX ... RENAME TO +alter = PostgresAlterIndexExpression( + dialect, index_name="idx_old", + action=PostgresAlterIndexActionType.RENAME_TO("idx_new"), +) +# sql: 'ALTER INDEX "idx_old" RENAME TO "idx_new"' + +# REINDEX +reindex = PostgresReindexExpression( + dialect, target_type="INDEX", target_name="idx_corrupted", + concurrently=True, +) +# sql: 'REINDEX INDEX CONCURRENTLY "idx_corrupted"' +``` + ### 索引类型 PostgreSQL 支持多种索引类型: diff --git a/docs/zh_CN/postgres_specific_features/dialect.md b/docs/zh_CN/postgres_specific_features/dialect.md index 00a14472..0bc9cc56 100644 --- a/docs/zh_CN/postgres_specific_features/dialect.md +++ b/docs/zh_CN/postgres_specific_features/dialect.md @@ -285,3 +285,111 @@ Product.query().where("attributes ? 'brand'", ()) ``` 💡 *AI 提示词:* "PostgreSQL 的 ILIKE 和标准 LIKE 在性能上有什么区别?" + +## 表达式级 COLLATE 支持 + +PostgreSQL 支持为表达式指定排序规则: + +```python +from rhosocial.activerecord.backend.expression.collation import collate +from rhosocial.activerecord.backend.impl.postgres.collation import PostgresCollation + +# 为列指定排序规则 +expr = collate(Column(dialect, "name"), PostgresCollation.C) +# 生成: "name" COLLATE "C" + +# 使用 schema 限定的排序规则 +expr = collate(Column(dialect, "name"), "C", schema="pg_catalog") +# 生成: "name" COLLATE "pg_catalog"."C" + +# 检查支持 +if dialect.supports_collate_expression(): + pass +``` + +## 日期时间间隔表达式 + +```python +from rhosocial.activerecord.backend.expression.datetime import ( + ExtractExpression, DateTimeField, IntervalExpression, IntervalUnit, + DateTimeAddExpression, DateTimeSubtractExpression, DateTimeDiffExpression, +) + +# EXTRACT +expr = ExtractExpression(dialect, DateTimeField.YEAR, Column(dialect, "created_at")) +# 生成: EXTRACT(YEAR FROM "created_at") + +# DATE_TRUNC +expr = DateTruncExpression(dialect, "month", Column(dialect, "created_at")) +# 生成: DATE_TRUNC('month', "created_at") + +# 间隔值 +interval = IntervalExpression(dialect, 7, IntervalUnit.DAY) +# 生成: INTERVAL '7' DAY + +# 日期 + 间隔 +expr = DateTimeAddExpression(dialect, Column(dialect, "created_at"), + IntervalExpression(dialect, 30, IntervalUnit.DAY)) +# 生成: "created_at" + INTERVAL '30' DAY + +# 日期差 +expr = DateTimeDiffExpression(dialect, DateTimeField.DAY, + Column(dialect, "start"), Column(dialect, "end")) +# 生成: EXTRACT(EPOCH FROM "end" - "start") / 86400 +``` + +## SQL/XML 标准表达式 + +PostgreSQL 支持 SQL/XML 标准表达式(PG 8.3+): + +```python +from rhosocial.activerecord.backend.expression.functions.xml import ( + xmlelement, xmlforest, xmlconcat, xmlagg, xmlcomment, xmlpi, + xmlparse, xmlserialize, xmlattributes, xmlexists, xmltable, +) + +# XMLELEMENT +elem = xmlelement(dialect, "name", Column(dialect, "user_name")) +# 生成: XMLELEMENT(NAME "name", "user_name") + +# XMLFOREST +forest = xmlforest(dialect, + (Column(dialect, "first_name"), "first"), + (Column(dialect, "last_name"), "last")) + +# XMLAGG +agg = xmlagg(dialect, Column(dialect, "xml_col"), alias="combined") + +# XMLEXISTS(PG 8.4+) +exists = xmlexists(dialect, "/root/element", + passing=Column(dialect, "xml_data")) + +# XMLTABLE(PG 10+) +table = xmltable(dialect, + xquery="/root/row", + passing=Column(dialect, "xml_data"), + columns=[("id", "INTEGER", "id"), ("name", "VARCHAR(100)", "name")]) +``` + +## 属性图查询 (PG 19+) + +PostgreSQL 19+ 支持 SQL/PGQ 属性图查询。详见 [属性图查询](property_graph_query.md)。 + +```python +from rhosocial.activerecord.backend.expression.graph import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, +) + +person = GraphVertex(dialect, variable="p", table="persons") +product = GraphVertex(dialect, variable="pr", table="products") +purchased = GraphEdge(dialect, variable="pu", table="purchases", + direction=GraphEdgeDirection.RIGHT) + +match = MatchClause(dialect, person, purchased, product) +# sql: 'MATCH (p) - [pu] -> (pr)' + +# 检查支持 +if dialect.supports_graph_match(): + # PG 19+ + pass +``` diff --git a/docs/zh_CN/postgres_specific_features/partition.md b/docs/zh_CN/postgres_specific_features/partition.md new file mode 100644 index 00000000..dd8d3e9e --- /dev/null +++ b/docs/zh_CN/postgres_specific_features/partition.md @@ -0,0 +1,122 @@ +# PostgreSQL 分区 + +PostgreSQL 自 10 版本起支持声明式分区,11+ 版本功能显著增强。 + +## 分区策略 + +| 策略 | 说明 | 最低版本 | +|------|------|---------| +| RANGE | 范围分区 | PG 10 | +| LIST | 列表分区 | PG 10 | +| HASH | 哈希分区 | PG 11 | + +## 创建分区表 + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresCreatePartitionExpression, PartitionValue, +) + +# 创建子分区 +partition = PostgresCreatePartitionExpression( + dialect, + parent_table="orders", + partition_name="orders_2024_q1", + partition_type="RANGE", + bounds="FROM ('2024-01-01') TO ('2024-04-01')", +) +# sql: 'CREATE TABLE "orders_2024_q1" PARTITION OF "orders" FOR VALUES FROM ('2024-01-01') TO ('2024-04-01')' +``` + +## 分区管理 + +### ATTACH / DETACH PARTITION + +```python +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresAttachPartitionExpression, PostgresDetachPartitionExpression, +) + +# 附加分区 +attach = PostgresAttachPartitionExpression( + dialect, parent_table="orders", partition_name="orders_new", + partition_type="RANGE", bounds="FROM ('2024-07-01') TO ('2024-10-01')", +) +# sql: 'ALTER TABLE "orders" ATTACH PARTITION "orders_new" FOR VALUES FROM ('2024-07-01') TO ('2024-10-01')' + +# 分离分区(PG 14+ 支持 CONCURRENTLY) +detach = PostgresDetachPartitionExpression( + dialect, parent_table="orders", partition_name="orders_old", + concurrently=True, +) +# sql: 'ALTER TABLE "orders" DETACH PARTITION "orders_old" CONCURRENTLY' +``` + +### 方言检查 + +```python +if dialect.supports_default_partition(): + # PG 11+: DEFAULT 分区 + +if dialect.supports_concurrent_detach(): + # PG 14+: 非阻塞分区分离 + +if dialect.supports_partition_key_update(): + # PG 11+: 键更新时自动行移动 +``` + +## pg_partman 扩展 + +pg_partman 是 PostgreSQL 分区管理扩展,支持自动创建和维护分区。 + +### 安装 + +```sql +CREATE EXTENSION pg_partman; +``` + +### 创建分区父表 + +```python +from rhosocial.activerecord.backend.impl.postgres.functions.pg_partman import create_parent + +# 创建按月分区的父表 +expr = create_parent( + dialect, + parent_table="public.orders", + control="created_at", + interval="1 month", + partition_type="range", + premake=3, # 提前创建 3 个分区 +) +# 生成: SELECT partman.create_parent('public.orders', 'created_at', '1 month', 'range', p_premake := 3) +``` + +### 运行维护 + +```python +from rhosocial.activerecord.backend.impl.postgres.functions.pg_partman import run_maintenance + +expr = run_maintenance(dialect, parent_table="public.orders") +# 生成: SELECT partman.run_maintenance('public.orders') +``` + +### 分区快照创建辅助 + +```python +from rhosocial.activerecord.backend.impl.postgres.named_expressions.partition import ( + create_next_monthly_partition, create_range_partition_for_month, + create_range_partitions_for_interval, +) + +# 创建下个月分区 +partition_expr = create_next_monthly_partition(dialect, "orders") + +# 创建指定月份分区 +partition_expr = create_range_partition_for_month(dialect, "orders", 2024, 10) + +# 批量创建时间范围内的分区 +for expr in create_range_partitions_for_interval(dialect, "orders", "2024-01-01", "2025-01-01", "1 month"): + # 执行每个 CREATE TABLE partition OF ... + pass +``` diff --git a/docs/zh_CN/postgres_specific_features/property_graph_query.md b/docs/zh_CN/postgres_specific_features/property_graph_query.md new file mode 100644 index 00000000..c2c4b7e1 --- /dev/null +++ b/docs/zh_CN/postgres_specific_features/property_graph_query.md @@ -0,0 +1,74 @@ +# 属性图查询 (Property Graph Query) + +PostgreSQL 19+ 支持 SQL/PGQ(Property Graph Query)属性图查询标准。 + +## MATCH 子句 + +```python +from rhosocial.activerecord.backend.expression.graph import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, GraphTableExpression, + GraphColumn, ColumnsClause, +) + +# 定义顶点 +person = GraphVertex(dialect, variable="p", table="persons") +product = GraphVertex(dialect, variable="pr", table="products") + +# 定义边 +purchased = GraphEdge(dialect, variable="pu", table="purchases", + direction=GraphEdgeDirection.RIGHT) + +# MATCH 子句 +match = MatchClause(dialect, person, purchased, product) +# sql: 'MATCH (p) - [pu] -> (pr)' + +# GRAPH_TABLE 表达式 +graph_table = GraphTableExpression( + dialect, + match_clause=match, + columns=ColumnsClause(dialect, columns=[ + GraphColumn(dialect, name="person_name", type="VARCHAR"), + GraphColumn(dialect, name="product_name", type="VARCHAR"), + ]), +) +``` + +## 属性图 DDL + +```python +from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphExpression, DropPropertyGraphExpression, + VertexTable, EdgeTable, TablePropertiesClause, +) + +# 创建属性图 +create_graph = CreatePropertyGraphExpression( + dialect, + graph_name="social_graph", + vertices=[ + VertexTable(dialect, table_name="persons", graph_label="Person"), + ], + edges=[ + EdgeTable(dialect, table_name="knows", + source_vertex="Person", dest_vertex="Person"), + ], +) +# sql: 'CREATE PROPERTY GRAPH "social_graph" ...' + +# 删除属性图 +drop_graph = DropPropertyGraphExpression( + dialect, graph_name="social_graph", if_exists=True, +) +``` + +## 方言检查 + +```python +if dialect.supports_graph_match(): + # PG 19+: 支持 MATCH 子句 + +if dialect.supports_graph_table(): + # PG 19+: 支持 GRAPH_TABLE 表达式 +``` + +> **注意**:SQL/PGQ 支持需要 PostgreSQL 19+。 diff --git a/docs/zh_CN/postgres_specific_features/protocol_support.md b/docs/zh_CN/postgres_specific_features/protocol_support.md index 110a40c9..f9a8909a 100644 --- a/docs/zh_CN/postgres_specific_features/protocol_support.md +++ b/docs/zh_CN/postgres_specific_features/protocol_support.md @@ -24,7 +24,7 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | **ArraySupport** | ✅ 完整 | 所有版本 | 原生数组类型和操作 | | **JSONSupport** | ✅ 完整 | ≥ 9.2 | JSON 类型;JSONB ≥ 9.4;JSON_TABLE ≥ 12 | | **ExplainSupport** | ✅ 完整 | 所有版本 | EXPLAIN 和 EXPLAIN ANALYZE 支持多种格式 | -| **GraphSupport** | ❌ 无 | 不适用 | 无原生图查询 MATCH 子句 | +| **GraphSupport** | ✅ 完整 | PG 19+ | SQL/PGQ 属性图查询 MATCH 子句和 GRAPH_TABLE | | **LockingSupport** | ✅ 部分 | ≥ 9.5 | FOR UPDATE;SKIP LOCKED ≥ 9.5 | | **MergeSupport** | ✅ 完整 | ≥ 15.0 | MERGE 语句 | | **OrderedSetAggregationSupport** | ✅ 完整 | ≥ 9.4 | 有序集聚合函数 | @@ -65,7 +65,11 @@ PostgreSQL 方言实现了 `rhosocial.activerecord.backend.dialect.protocols` | 协议 | 描述 | 最低版本 | 文档 | |------|------|----------|------| -| **PostgresPartitionSupport** | 高级分区特性 | PG 11+ | [分区文档](https://www.postgresql.org/docs/current/ddl-partitioning.html) | +| **PostgresPartitionSupport** | 高级分区特性 | PG 10+ | [分区文档](https://www.postgresql.org/docs/current/ddl-partitioning.html) | +| **PostgresPropertyGraphQueryMixin** | SQL/PGQ 属性图 | PG 19+ | [属性图查询](property_graph_query.md) | +| **PostgresPgPartmanSupport** | pg_partman 自动分区 | PG 10+ | [分区文档](partition.md) | +| **PostgresXMLMixin** | SQL/XML 标准表达式 | PG 8.3+ | [Dialect 表达式](dialect.md) | +| **PostgresCollationMixin** | 表达式级 COLLATE | 所有版本 | [Dialect 表达式](dialect.md) | | **PostgresIndexSupport** | 索引增强 | PG 10+ | [索引文档](https://www.postgresql.org/docs/current/indexes.html) | | **PostgresVacuumSupport** | VACUUM 改进 | PG 13+ | [VACUUM 文档](https://www.postgresql.org/docs/current/sql-vacuum.html) | | **PostgresQueryOptimizationSupport** | 查询优化特性 | PG 11+ | [查询文档](https://www.postgresql.org/docs/current/runtime-config-query.html) | diff --git a/pyproject.toml b/pyproject.toml index 337f192c..dee3111b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rhosocial-activerecord-postgres" -version = "1.0.0.dev14" +version = "1.0.0.dev15" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", "Framework :: Pydantic", "Framework :: Pydantic :: 2", @@ -46,9 +47,9 @@ pooling = [ ] # Development dependencies test = [ - "pytest>=7.0.0", + "pytest", "coverage>=7.0.0", - "pytest-mock>=3.0.0", + "pytest-mock", "fastapi>=0.110", "httpx>=0.27", ] diff --git a/requirements-dev-3.8.txt b/requirements-dev-3.8.txt index c6ed77b9..807c9eed 100644 --- a/requirements-dev-3.8.txt +++ b/requirements-dev-3.8.txt @@ -1,7 +1,7 @@ # requirements-dev-3.8.txt # Testing framework for unit and integration tests # Version ~=8.3.5 is the latest pytest version compatible with Python 3.8 -pytest~=8.3.5 +pytest pytest-asyncio # Pytest plugin for performance benchmarking @@ -20,7 +20,7 @@ pytest-xdist # Code coverage measurement tool and pytest integration coverage -pytest-cov>=4.0.0 +pytest-cov # Pydantic with email validation support pydantic[email] diff --git a/requirements-dev.txt b/requirements-dev.txt index 0966b148..a6698464 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,7 +2,7 @@ # Testing framework for unit and integration tests # Core framework for running test suites and fixtures # Version ~=8.4.2 provides stable testing capabilities -pytest~=8.4.2 +pytest pytest-asyncio # Pytest plugin for performance benchmarking @@ -22,7 +22,7 @@ aiofiles>=23.0.0 # Parallel test execution plugin for pytest # Enables faster test execution through parallel processing # Version ~=3.8.0 provides stable parallel test execution -pytest-xdist~=3.8.0 +pytest-xdist # Code coverage measurement tool # Measures test coverage and generates coverage reports @@ -31,7 +31,7 @@ coverage~=7.10.7 # Coverage integration with pytest # Combines pytest and coverage for seamless test coverage reporting -pytest-cov>=4.0.0 +pytest-cov # Pydantic with email validation support # Adds email field validation capabilities to the core framework diff --git a/requirements.txt b/requirements.txt index e90bb622..6aeecb94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ -psycopg[binary] -rhosocial-activerecord \ No newline at end of file +# requirements.txt +# Backend-specific dependencies only +# Core dependency (rhosocial-activerecord) is installed via CI workflow from git + +# PostgreSQL driver +psycopg[binary] \ No newline at end of file diff --git a/scripts/extension_comparison.py b/scripts/extension_comparison.py deleted file mode 100644 index a40916b6..00000000 --- a/scripts/extension_comparison.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 -# scripts/extension_comparison.py -""" -Generate a comparison table of PostgreSQL extensions across versions. - -This script connects to all PostgreSQL servers defined in the config file -and generates a comparison table showing installed extensions. -""" -import yaml -import sys -import os - -# Add src to PYTHONPATH -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'python-activerecord', 'src')) - -from rhosocial.activerecord.backend.impl.postgres import PostgresBackend -from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig - - -def load_all_scenarios(yaml_path: str): - """Load all scenarios from YAML file, including commented ones.""" - import re - with open(yaml_path, 'r') as f: - content = f.read() - - config_data = yaml.safe_load(content) - scenarios = config_data.get('scenarios', {}) - - # Parse commented scenarios - lines = content.split('\n') - current_scenario = None - current_config = {} - - for line in lines: - scenario_match = re.match(r'#\s*(postgres_\d+):\s*$', line) - if scenario_match: - if current_scenario and current_config: - if current_scenario not in scenarios: - scenarios[current_scenario] = current_config - current_scenario = scenario_match.group(1) - current_config = {} - continue - - if current_scenario: - if line.strip().startswith('#') and ':' in line: - config_line = line.strip()[1:].strip() - if config_line.startswith('#') or 'Replace with your password' in config_line: - continue - if ':' in config_line: - key, value = config_line.split(':', 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - if key: - current_config[key] = value - - if current_scenario and current_config: - if current_scenario not in scenarios: - scenarios[current_scenario] = current_config - - return scenarios - - -def get_extension_info(scenario_config): - """Connect to database and get extension info.""" - conn_config = PostgresConnectionConfig( - host=scenario_config['host'], - port=scenario_config['port'], - database=scenario_config['database'], - username=scenario_config['username'], - password=scenario_config['password'], - sslmode=scenario_config.get('sslmode', 'prefer') - ) - - backend = PostgresBackend(connection_config=conn_config) - try: - backend.connect() - backend.introspect_and_adapt() - - version = backend.get_server_version() - extensions = backend.dialect._extensions - - installed = {} - for name, info in extensions.items(): - if info.installed: - installed[name] = { - 'version': info.version, - 'schema': info.schema - } - - return version, installed - finally: - if backend._connection: - backend.disconnect() - - -def main(): - config_path = os.path.join( - os.path.dirname(__file__), - '..', - 'tests', - 'config', - 'postgres_scenarios.yaml' - ) - - scenarios = load_all_scenarios(config_path) - - # Define the extensions we care about - extensions_of_interest = ['plpgsql', 'postgis', 'vector', 'pg_trgm', 'hstore', 'uuid-ossp', 'pgcrypto'] - - # Collect data from all scenarios - results = {} - for scenario_name in sorted(scenarios.keys(), key=lambda x: int(x.split('_')[1])): - print(f"Checking {scenario_name}...", file=sys.stderr) - scenario_config = scenarios[scenario_name] - try: - version, installed = get_extension_info(scenario_config) - results[scenario_name] = { - 'version': version, - 'extensions': installed - } - except Exception as e: - print(f"Error checking {scenario_name}: {e}", file=sys.stderr) - results[scenario_name] = {'error': str(e)} - - # Generate comparison table - print("\n" + "=" * 80) - print("PostgreSQL Extension Comparison Table") - print("=" * 80) - - # Header - header = "| Version | PG Version | " + " | ".join(ext.ljust(10) for ext in extensions_of_interest) + " |" - separator = "|" + "-" * 9 + "|" + "-" * 13 + "|" + "|".join("-" * 11 for _ in extensions_of_interest) + "|" - - print(header) - print(separator) - - # Rows - for scenario_name in sorted(results.keys(), key=lambda x: int(x.split('_')[1])): - data = results[scenario_name] - if 'error' in data: - print(f"| {scenario_name} | ERROR | {data['error'][:60]}...") - continue - - version_str = '.'.join(map(str, data['version'])) - row = f"| {scenario_name} | {version_str.ljust(11)} |" - - for ext in extensions_of_interest: - if ext in data['extensions']: - info = data['extensions'][ext] - cell = f"{info['version']}" - else: - cell = "-" - row += f" {cell.ljust(10)} |" - - print(row) - - print(separator) - - # Summary - print("\nSummary:") - print("-" * 40) - - # Count installed extensions per type - ext_counts = {} - for ext in extensions_of_interest: - count = sum(1 for data in results.values() - if 'extensions' in data and ext in data['extensions']) - ext_counts[ext] = count - - for ext, count in ext_counts.items(): - total = len(results) - print(f" {ext}: {count}/{total} servers ({count*100//total}%)") - - # Print detailed info for installed extensions - print("\nDetailed Extension Information:") - print("-" * 60) - - for scenario_name in sorted(results.keys(), key=lambda x: int(x.split('_')[1])): - data = results[scenario_name] - if 'extensions' in data and data['extensions']: - version_str = '.'.join(map(str, data['version'])) - print(f"\n{scenario_name} (PostgreSQL {version_str}):") - for ext, info in sorted(data['extensions'].items()): - print(f" - {ext}: version={info['version']}, schema={info['schema']}") - - -if __name__ == '__main__': - main() diff --git a/scripts/test_extension_detection.py b/scripts/test_extension_detection.py deleted file mode 100644 index e84001e7..00000000 --- a/scripts/test_extension_detection.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -# scripts/test_extension_detection.py -""" -Test script to detect PostgreSQL extensions. - -This script connects to a PostgreSQL database using configuration from -tests/config/postgres_scenarios.yaml and displays installed extensions. -""" -import yaml -import sys -import os - -# Add src to PYTHONPATH -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'python-activerecord', 'src')) - -from rhosocial.activerecord.backend.impl.postgres import PostgresBackend -from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig - - -def load_scenario_config(yaml_path: str, scenario_name: str = None): - """Load connection configuration from YAML file. - - Also loads commented-out scenarios (lines starting with '# postgres_') - """ - with open(yaml_path, 'r') as f: - content = f.read() - - # Parse the YAML content - config_data = yaml.safe_load(content) - scenarios = config_data.get('scenarios', {}) - - # Also parse commented scenarios - import re - # Match commented scenario blocks - lines = content.split('\n') - current_scenario = None - current_config = {} - - for line in lines: - # Check for commented scenario header: # postgres_XX: - scenario_match = re.match(r'#\s*(postgres_\d+):\s*$', line) - if scenario_match: - # Save previous scenario if exists - if current_scenario and current_config: - if current_scenario not in scenarios: - scenarios[current_scenario] = current_config - current_scenario = scenario_match.group(1) - current_config = {} - continue - - # Parse config lines within commented scenarios - if current_scenario: - # Skip pure comment lines (not key: value) - if line.strip().startswith('#') and ':' in line: - # Remove the leading # - config_line = line.strip()[1:].strip() - # Skip lines that are just comments - if config_line.startswith('#') or 'Replace with your password' in config_line: - continue - # Parse key: value - if ':' in config_line: - key, value = config_line.split(':', 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - if key: - current_config[key] = value - - # Save last scenario - if current_scenario and current_config: - if current_scenario not in scenarios: - scenarios[current_scenario] = current_config - - # If no scenario specified, use the first active one - if scenario_name is None: - scenario_name = list(scenarios.keys())[0] - print(f"No scenario specified, using: {scenario_name}") - - if scenario_name not in scenarios: - print(f"Error: Scenario '{scenario_name}' not found") - print(f"Available scenarios: {list(scenarios.keys())}") - sys.exit(1) - - return scenario_name, scenarios[scenario_name] - - -def main(): - # Path to config file - config_path = os.path.join( - os.path.dirname(__file__), - '..', - 'tests', - 'config', - 'postgres_scenarios.yaml' - ) - - # Allow command line argument to specify scenario - scenario_name = sys.argv[1] if len(sys.argv) > 1 else None - - # Load configuration - scenario_name, scenario_config = load_scenario_config(config_path, scenario_name) - - print(f"\n{'='*60}") - print(f"Connecting to PostgreSQL scenario: {scenario_name}") - print(f"{'='*60}") - print(f"Host: {scenario_config['host']}") - print(f"Port: {scenario_config['port']}") - print(f"Database: {scenario_config['database']}") - print(f"Username: {scenario_config['username']}") - print(f"{'='*60}\n") - - # Create connection config - conn_config = PostgresConnectionConfig( - host=scenario_config['host'], - port=scenario_config['port'], - database=scenario_config['database'], - username=scenario_config['username'], - password=scenario_config['password'], - sslmode=scenario_config.get('sslmode', 'prefer') - ) - - # Create backend - backend = PostgresBackend(connection_config=conn_config) - - try: - # Connect to database - backend.connect() - print("Connected successfully!\n") - - # Get server version - version = backend.get_server_version() - print(f"PostgreSQL Version: {'.'.join(map(str, version))}") - - # Introspect and detect extensions - print("\nDetecting extensions...") - backend.introspect_and_adapt() - - # Get extension information - dialect = backend.dialect - extensions = dialect._extensions - - print(f"\n{'='*60}") - print("Extension Detection Results") - print(f"{'='*60}") - - # Separate installed and not installed extensions - installed = [] - not_installed = [] - - for name, info in extensions.items(): - if info.installed: - installed.append((name, info.version, info.schema)) - else: - not_installed.append(name) - - if installed: - print("\nInstalled Extensions:") - print("-" * 40) - for name, version, schema in installed: - print(f" {name}") - print(f" Version: {version}") - print(f" Schema: {schema}") - else: - print("\nNo extensions installed.") - - if not_installed: - print(f"\nKnown but not installed extensions:") - print("-" * 40) - for name in not_installed: - print(f" {name}") - - # Test extension check methods - print(f"\n{'='*60}") - print("Extension Support Tests") - print(f"{'='*60}") - - print(f"\nPostGIS (spatial):") - print(f" is_extension_installed('postgis'): {dialect.is_extension_installed('postgis')}") - print(f" supports_postgis_geometry_type(): {dialect.supports_postgis_geometry_type()}") - print(f" supports_postgis_spatial_functions(): {dialect.supports_postgis_spatial_functions()}") - - print(f"\npgvector (vector):") - print(f" is_extension_installed('vector'): {dialect.is_extension_installed('vector')}") - print(f" supports_pgvector_type(): {dialect.supports_pgvector_type()}") - print(f" supports_pgvector_hnsw_index(): {dialect.supports_pgvector_hnsw_index()}") - - print(f"\npg_trgm (trigram):") - print(f" is_extension_installed('pg_trgm'): {dialect.is_extension_installed('pg_trgm')}") - print(f" supports_pg_trgm_similarity(): {dialect.supports_pg_trgm_similarity()}") - - print(f"\nhstore (key-value):") - print(f" is_extension_installed('hstore'): {dialect.is_extension_installed('hstore')}") - print(f" supports_hstore_type(): {dialect.supports_hstore_type()}") - - print(f"\n{'='*60}") - - except Exception as e: - print(f"\nError: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - finally: - # Disconnect - if backend._connection: - backend.disconnect() - print("\nDisconnected successfully!") - - -if __name__ == '__main__': - main() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 58696e0b..83f988d5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -18,6 +18,7 @@ """ from .backend import PostgresBackend, AsyncPostgresBackend +from .collation import PostgresCollation # noqa: F401 from .config import PostgresConnectionConfig from .dialect import PostgresDialect from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py index c63d7180..e1c93541 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py @@ -7,7 +7,7 @@ import json from enum import Enum as PythonEnum -from typing import Any, Dict, List, Type, Union, Optional, Set, get_args, get_origin +from typing import Any, Dict, List, Type, Union, Optional, Set, get_args, get_origin # noqa: F401 from psycopg.types.json import Jsonb @@ -251,7 +251,7 @@ def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[ return result - def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # noqa: E501 # If already a string, check if we should convert to Python Enum if isinstance(value, str): if options and "enum_class" in options: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py index c92f154f..b249c173 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/json.py @@ -16,7 +16,7 @@ """ import json -from typing import Any, Dict, List, Optional, Set, Type, Union, get_args, get_origin +from typing import Any, Dict, List, Optional, Set, Type, Union, get_args, get_origin # noqa: F401 from psycopg.types.json import Jsonb diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index b4008f99..7aba7eb1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -16,7 +16,7 @@ import datetime import logging -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import psycopg from psycopg import AsyncConnection @@ -39,6 +39,7 @@ OperationalError, QueryError, ) +from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.result import QueryResult from ..config import PostgresConnectionConfig from ..dialect import PostgresDialect @@ -56,7 +57,7 @@ class AsyncPostgresBackend( ): """Asynchronous PostgreSQL-specific backend implementation.""" - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: """Initialize async PostgreSQL backend with connection configuration. Note: @@ -151,7 +152,7 @@ def __init__(self, **kwargs): self.log(logging.INFO, "AsyncPostgreSQLBackend initialized") - def _create_introspector(self): + def _create_introspector(self) -> Any: """Create and return an AsyncPostgreSQLIntrospector with an async executor.""" from rhosocial.activerecord.backend.introspection.executor import AsyncIntrospectorExecutor from ..introspection import AsyncPostgreSQLIntrospector @@ -159,17 +160,17 @@ def _create_introspector(self): return AsyncPostgreSQLIntrospector(self, AsyncIntrospectorExecutor(self)) @property - def dialect(self): + def dialect(self) -> PostgresDialect: """Get the PostgreSQL dialect instance.""" return self._dialect @property - def transaction_manager(self): + def transaction_manager(self) -> AsyncPostgresTransactionManager: """Get the async PostgreSQL transaction manager.""" return self._transaction_manager - async def connect(self): + async def connect(self) -> None: """Establish async connection to PostgreSQL database.""" try: # Prepare connection parameters from config @@ -252,7 +253,7 @@ async def connect(self): self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e - async def disconnect(self): + async def disconnect(self) -> None: """Close async connection to PostgreSQL database.""" if self._connection: try: @@ -267,7 +268,7 @@ async def disconnect(self): self.log(logging.ERROR, f"Error during disconnection: {str(e)}") raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e - async def _get_cursor(self): + async def _get_cursor(self) -> Any: """Get a database cursor, ensuring connection is active. This method implements automatic connection health checking (Plan A): @@ -354,10 +355,10 @@ async def get_server_version(self) -> Optional[Tuple[int, int, int]]: # Extract version from string like "PostgreSQL 13.2..." import re - match = re.search(r"PostgreSQL (\d+)\.(\d+)(?:\.(\d+))?", version_str) + match = re.search(r"PostgreSQL (\d+)(?:\.(\d+))?(?:[^\d]*(\d+))?", version_str) if match: major = int(match.group(1)) - minor = int(match.group(2)) + minor = int(match.group(2)) if match.group(2) else 0 patch = int(match.group(3)) if match.group(3) else 0 return (major, minor, patch) else: @@ -496,9 +497,9 @@ async def execute( sql: str, params: Optional[Tuple] = None, *, - options=None, + options: Optional[ExecutionOptions] = None, max_retries: int = 2, - **kwargs, + **kwargs: Any, ) -> QueryResult: """ Execute a SQL statement with optional parameters asynchronously. @@ -516,7 +517,7 @@ async def execute( Returns: QueryResult containing the query results """ - from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType + from rhosocial.activerecord.backend.options import StatementType # If no options provided, create default options from kwargs if options is None: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index 7071a2e4..b23d26fa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -16,7 +16,7 @@ import datetime import logging -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import psycopg from psycopg.errors import Error as PsycopgError @@ -38,6 +38,7 @@ OperationalError, QueryError, ) +from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.result import QueryResult from ..config import PostgresConnectionConfig from ..dialect import PostgresDialect @@ -56,7 +57,7 @@ class PostgresBackend( ): """PostgreSQL-specific backend implementation.""" - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: """Initialize PostgreSQL backend with connection configuration. Note: @@ -151,24 +152,24 @@ def __init__(self, **kwargs): self.log(logging.INFO, "PostgreSQLBackend initialized") - def _create_introspector(self): + def _create_introspector(self) -> Any: """Create and return a SyncPostgreSQLIntrospector with a sync executor.""" from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor return SyncPostgreSQLIntrospector(self, SyncIntrospectorExecutor(self)) @property - def dialect(self): + def dialect(self) -> PostgresDialect: """Get the PostgreSQL dialect instance.""" return self._dialect @property - def transaction_manager(self): + def transaction_manager(self) -> PostgresTransactionManager: """Get the PostgreSQL transaction manager.""" return self._transaction_manager - def connect(self): + def connect(self) -> None: """Establish connection to PostgreSQL database.""" try: # Prepare connection parameters from config @@ -253,7 +254,7 @@ def connect(self): self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}") raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e - def disconnect(self): + def disconnect(self) -> None: """Close connection to PostgreSQL database.""" if self._connection: try: @@ -268,7 +269,7 @@ def disconnect(self): self.log(logging.ERROR, f"Error during disconnection: {str(e)}") raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e - def _get_cursor(self): + def _get_cursor(self) -> Any: """Get a database cursor, ensuring connection is active. This method implements automatic connection health checking (Plan A): @@ -355,10 +356,10 @@ def get_server_version(self) -> Optional[Tuple[int, int, int]]: # Extract version from string like "PostgreSQL 13.2..." import re - match = re.search(r"PostgreSQL (\d+)\.(\d+)(?:\.(\d+))?", version_str) + match = re.search(r"PostgreSQL (\d+)(?:\.(\d+))?(?:[^\d]*(\d+))?", version_str) if match: major = int(match.group(1)) - minor = int(match.group(2)) + minor = int(match.group(2)) if match.group(2) else 0 patch = int(match.group(3)) if match.group(3) else 0 return (major, minor, patch) else: @@ -497,9 +498,9 @@ def execute( sql: str, params: Optional[Tuple] = None, *, - options=None, + options: Optional[ExecutionOptions] = None, max_retries: int = 2, - **kwargs, + **kwargs: Any, ) -> QueryResult: """ Execute a SQL statement with optional parameters. @@ -517,7 +518,7 @@ def execute( Returns: QueryResult containing the query results """ - from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType + from rhosocial.activerecord.backend.options import StatementType # If no options provided, create default options from kwargs if options is None: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/info.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/info.py index c4b08e61..b1a2122d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/info.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/info.py @@ -37,7 +37,6 @@ PostgresVacuumSupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, PostgresLogicalReplicationSupport, PostgresLtreeSupport, PostgresIntarraySupport, @@ -49,6 +48,28 @@ PostgresTypeSupport, PostgresMultirangeSupport, PostgresEnumTypeSupport, + # Per-feature protocol imports replacing PostgresSQLSyntaxSupport + PostgresCTESupport, + PostgresWindowSupport, + PostgresFilterSupport, + PostgresReturningSupport, + PostgresGroupingSupport, + PostgresExplainSupport, + PostgresMergeSupport, + PostgresUpsertSupport, + PostgresLateralJoinSupport, + PostgresSetOperationSupport, + PostgresILIKESupport, + PostgresJoinSupport, + PostgresTruncateSupport, + PostgresSchemaSupport, + PostgresSequenceSupport, + PostgresTransactionSupport, + PostgresViewSupport, + PostgresXMLSupport, + PostgresCollationSupport, + PostgresOrderedSetAggSupport, + PostgresFeaturesSupport, ) from .connection import add_connection_args, add_version_arg, resolve_connection_config_from_args @@ -83,10 +104,21 @@ "PostgreSQL Native": [ PostgresPartitionSupport, PostgresIndexSupport, PostgresVacuumSupport, PostgresQueryOptimizationSupport, - PostgresDataTypeSupport, PostgresSQLSyntaxSupport, + PostgresDataTypeSupport, PostgresLogicalReplicationSupport, PostgresTriggerSupport, PostgresCommentSupport, PostgresTypeSupport, PostgresMultirangeSupport, PostgresEnumTypeSupport, + PostgresCTESupport, PostgresWindowSupport, + PostgresFilterSupport, PostgresReturningSupport, + PostgresGroupingSupport, PostgresExplainSupport, + PostgresMergeSupport, PostgresUpsertSupport, + PostgresLateralJoinSupport, PostgresSetOperationSupport, + PostgresILIKESupport, PostgresJoinSupport, + PostgresTruncateSupport, PostgresSchemaSupport, + PostgresSequenceSupport, PostgresTransactionSupport, + PostgresViewSupport, PostgresXMLSupport, + PostgresCollationSupport, PostgresOrderedSetAggSupport, + PostgresFeaturesSupport, ], "PostgreSQL Extensions": [ PostgresExtensionSupport, PostgresTableSupport, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/collation.py new file mode 100644 index 00000000..ec422542 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/collation.py @@ -0,0 +1,43 @@ +# src/rhosocial/activerecord/backend/impl/postgres/collation.py +""" +PostgreSQL collation names supported by the dialect whitelist. +""" + +from enum import Enum +from typing import Optional, Tuple + + +class PostgresCollation(Enum): + """Common PostgreSQL collations for expression-level COLLATE.""" + + C = "C" + POSIX = "POSIX" + UCS_BASIC = "ucs_basic" + UND_X_ICU = "und-x-icu" + + +_BUILTIN_COLLATIONS = { + PostgresCollation.C.value, + PostgresCollation.POSIX.value, + PostgresCollation.UCS_BASIC.value, +} + +_VERSION_SENSITIVE_COLLATIONS = { + PostgresCollation.UND_X_ICU.value: (10, 0, 0), +} + +_POSTGRES_COLLATIONS = _BUILTIN_COLLATIONS | set(_VERSION_SENSITIVE_COLLATIONS) + + +def validate_postgres_collation_name( + name: str, + version: Optional[Tuple[int, int, int]] = None, +) -> str: + if name not in _POSTGRES_COLLATIONS: + raise ValueError(f"Unsupported PostgreSQL collation: {name!r}") + required_version = _VERSION_SENSITIVE_COLLATIONS.get(name) + if required_version is not None and version is not None and version < required_version: + raise ValueError( + f"PostgreSQL collation requires PostgreSQL {required_version[0]}.{required_version[1]}+: {name!r}" + ) + return name diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 2e5a24f5..c59bb218 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -9,10 +9,14 @@ from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.collation import CollateExpression from .function_versions import FunctionSupportInfo, FunctionVersionRequirement from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import ( + SQLXMLMixin, + CollationMixin, CTEMixin, FilterClauseMixin, WindowFunctionMixin, @@ -22,6 +26,7 @@ ArrayMixin, ExplainMixin, GraphMixin, + GraphTableMixin, LockingMixin, MergeMixin, OrderedSetAggregationMixin, @@ -39,8 +44,25 @@ TruncateMixin, ILIKEMixin, ConstraintMixin, + PartitionMixin, + # New Mixins + IdentifierMixin, + PredicateMixin, + ExpressionMixin, + DateTimeMixin, + DQLMixin, + DMLMixin, + DDLColumnMixin, + TransactionControlMixin, ) from rhosocial.activerecord.backend.dialect.protocols import ( + SQLXMLSupport, + SQLXMLParsingSupport, + SQLXMLSerializationSupport, + SQLXMLConstructionSupport, + SQLXMLAggregationSupport, + SQLXMLQueryingSupport, + CollationSupport, CTESupport, FilterClauseSupport, WindowFunctionSupport, @@ -50,6 +72,7 @@ ArraySupport, ExplainSupport, GraphSupport, + GraphTableSupport, MergeSupport, OrderedSetAggregationSupport, QualifyClauseSupport, @@ -80,13 +103,35 @@ PostgresHstoreMixin, # Native feature mixins PostgresPartitionMixin, + PostgresPropertyGraphQueryMixin, PostgresIndexMixin, PostgresVacuumMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, + # Per-feature mixins + PostgresCTEMixin, + PostgresWindowMixin, + PostgresFilterMixin, + PostgresReturningMixin, + PostgresGroupingMixin, + PostgresExplainMixin, + PostgresMergeMixin, + PostgresUpsertMixin, + PostgresLateralJoinMixin, + PostgresSetOperationMixin, + PostgresILIKEMixin, + PostgresJoinMixin, + PostgresTruncateMixin, + PostgresSchemaMixin, + PostgresSequenceMixin, + PostgresTransactionMixin, + PostgresViewMixin, + PostgresXMLMixin, + PostgresCollationMixin, + PostgresOrderedSetAggMixin, + PostgresFeaturesMixin, # Extension feature mixins PostgresLtreeMixin, PostgresIntarrayMixin, @@ -133,6 +178,8 @@ PostgresIntrospectionCapabilityMixin, ) +from .collation import validate_postgres_collation_name + # PostgreSQL-specific imports from .protocols import ( PostgresExtensionSupport, @@ -150,8 +197,29 @@ PostgresVacuumSupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, PostgresLogicalReplicationSupport, + # Per-feature protocols + PostgresCTESupport, + PostgresWindowSupport, + PostgresFilterSupport, + PostgresReturningSupport, + PostgresGroupingSupport, + PostgresExplainSupport, + PostgresMergeSupport, + PostgresUpsertSupport, + PostgresLateralJoinSupport, + PostgresSetOperationSupport, + PostgresILIKESupport, + PostgresJoinSupport, + PostgresTruncateSupport, + PostgresSchemaSupport, + PostgresSequenceSupport, + PostgresTransactionSupport, + PostgresViewSupport, + PostgresXMLSupport, + PostgresCollationSupport, + PostgresOrderedSetAggSupport, + PostgresFeaturesSupport, # Extension feature protocols PostgresLtreeSupport, PostgresIntarraySupport, @@ -207,6 +275,8 @@ RefreshMaterializedViewExpression, ExplainExpression, AddTableConstraint, + AddIndex, + DropIndex as DropIndexAction, # noqa: F401 TableConstraint, ) from rhosocial.activerecord.backend.expression.transaction import ( @@ -218,21 +288,47 @@ SetTransactionExpression, ) - class PostgresDialect( SQLDialectBase, + # Per-feature mixins + PostgresCTEMixin, + PostgresWindowMixin, + PostgresFilterMixin, + PostgresReturningMixin, + PostgresGroupingMixin, + PostgresExplainMixin, + PostgresMergeMixin, + PostgresUpsertMixin, + PostgresLateralJoinMixin, + PostgresSetOperationMixin, + PostgresILIKEMixin, + PostgresJoinMixin, + PostgresTruncateMixin, + PostgresSchemaMixin, + PostgresSequenceMixin, + PostgresTransactionMixin, + PostgresViewMixin, + PostgresXMLMixin, + PostgresCollationMixin, + PostgresOrderedSetAggMixin, + PostgresFeaturesMixin, + SQLXMLMixin, + CollationMixin, SetOperationMixin, TruncateMixin, ILIKEMixin, CTEMixin, FilterClauseMixin, WindowFunctionMixin, + PostgresJSONBEnhancedMixin, JSONMixin, ReturningMixin, AdvancedGroupingMixin, ArrayMixin, ExplainMixin, + PostgresPropertyGraphQueryMixin, GraphMixin, + GraphTableMixin, PostgresLockingMixin, LockingMixin, MergeMixin, @@ -247,13 +343,15 @@ class PostgresDialect( PostgresIndexMixin, IndexMixin, SequenceMixin, - TableMixin, - ConstraintMixin, - PostgresIntrospectionCapabilityMixin, # PostgreSQL-specific mixins PostgresExtensionMixin, PostgresMaterializedViewMixin, - PostgresTableMixin, + PostgresTableMixin, # Before TableMixin to override supports_table_like_syntax + TableMixin, + ConstraintMixin, + PostgresPartitionMixin, + PartitionMixin, + PostgresIntrospectionCapabilityMixin, PostgresPgvectorMixin, PostgresPostGISMixin, PostgresPostgisRasterMixin, @@ -261,11 +359,9 @@ class PostgresDialect( PostgresPgTrgmMixin, PostgresHstoreMixin, # Native feature mixins - PostgresPartitionMixin, PostgresVacuumMixin, PostgresQueryOptimizationMixin, PostgresDataTypeMixin, - PostgresSQLSyntaxMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, # Extension feature mixins @@ -303,13 +399,28 @@ class PostgresDialect( MultirangeMixin, PostgresFullTextSearchMixin, PostgresRangeTypeMixin, - PostgresJSONBEnhancedMixin, PostgresArrayEnhancedMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, + # New Mixins + IdentifierMixin, + PredicateMixin, + ExpressionMixin, + DateTimeMixin, + DQLMixin, + DMLMixin, + DDLColumnMixin, + TransactionControlMixin, # Protocol supports + SQLXMLSupport, + SQLXMLParsingSupport, + SQLXMLSerializationSupport, + SQLXMLConstructionSupport, + SQLXMLAggregationSupport, + SQLXMLQueryingSupport, + CollationSupport, SetOperationSupport, TruncateSupport, ILIKESupport, @@ -322,6 +433,7 @@ class PostgresDialect( ArraySupport, ExplainSupport, GraphSupport, + GraphTableSupport, MergeSupport, OrderedSetAggregationSupport, QualifyClauseSupport, @@ -353,8 +465,29 @@ class PostgresDialect( PostgresVacuumSupport, PostgresQueryOptimizationSupport, PostgresDataTypeSupport, - PostgresSQLSyntaxSupport, PostgresLogicalReplicationSupport, + # Per-feature protocols + PostgresCTESupport, + PostgresWindowSupport, + PostgresFilterSupport, + PostgresReturningSupport, + PostgresGroupingSupport, + PostgresExplainSupport, + PostgresMergeSupport, + PostgresUpsertSupport, + PostgresLateralJoinSupport, + PostgresSetOperationSupport, + PostgresILIKESupport, + PostgresJoinSupport, + PostgresTruncateSupport, + PostgresSchemaSupport, + PostgresSequenceSupport, + PostgresTransactionSupport, + PostgresViewSupport, + PostgresXMLSupport, + PostgresCollationSupport, + PostgresOrderedSetAggSupport, + PostgresFeaturesSupport, # Extension feature protocols PostgresLtreeSupport, PostgresIntarraySupport, @@ -436,6 +569,7 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): If None, the dialect must be adapted via backend.introspect_and_adapt() before version-dependent features can be used. + """ super().__init__() if version is not None: @@ -463,79 +597,64 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version - # region Protocol Support Checks based on version - def supports_basic_cte(self) -> bool: - """Basic CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_recursive_cte(self) -> bool: - """Recursive CTEs are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_materialized_cte(self) -> bool: - """MATERIALIZED hint is supported since PostgreSQL 12.""" - return self.version >= (12, 0, 0) - - def supports_returning_insert(self) -> bool: - """RETURNING clause is supported for INSERT since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_update(self) -> bool: - """RETURNING clause is supported for UPDATE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_returning_delete(self) -> bool: - """RETURNING clause is supported for DELETE since PostgreSQL 8.2.""" - return self.version >= (8, 2, 0) - - def supports_window_functions(self) -> bool: - """Window functions are supported since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_window_frame_clause(self) -> bool: - """Whether window frame clauses (ROWS/RANGE) are supported, since PostgreSQL 8.4.""" - return True # Supported in all modern versions - - def supports_filter_clause(self) -> bool: - """FILTER clause for aggregate functions is supported since PostgreSQL 9.4.""" - return self.version >= (9, 4, 0) - - def get_json_access_operator(self) -> str: - """PostgreSQL uses '->' for JSON access.""" - return "->" - - def supports_rollup(self) -> bool: - """ROLLUP is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_cube(self) -> bool: - """CUBE is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_grouping_sets(self) -> bool: - """GROUPING SETS is supported since PostgreSQL 9.5.""" - return self.version >= (9, 5, 0) - - def supports_explain_analyze(self) -> bool: - """Whether EXPLAIN ANALYZE is supported.""" - return True # Supported in all modern versions - - def supports_explain_format(self, format_type: str) -> bool: - """Check if specific EXPLAIN format is supported.""" - format_type_upper = format_type.upper() - # PostgreSQL supports TEXT, XML, JSON, and YAML formats - supported_formats = ["TEXT", "XML", "JSON", "YAML"] - return format_type_upper in supported_formats + def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: + start_sql, start_params = expr.start.to_sql() + end_sql, end_params = expr.end.to_sql() + seconds_sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))" + factors = { + "second": "1", + "minute": "60", + "hour": "3600", + "day": "86400", + "week": "604800", + } + if expr.unit.value in factors: + sql = f"({seconds_sql} / {factors[expr.unit.value]})" + params = end_params + start_params + elif expr.unit.value == "month": + sql = ( + f"((EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql})) * 12 + " + f"(EXTRACT(MONTH FROM {end_sql}) - " + f"EXTRACT(MONTH FROM {start_sql})))" + ) + params = end_params + start_params + end_params + start_params + else: + sql = ( + f"(EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql}))" + ) + params = end_params + start_params + return self._apply_value_expression_modifiers(sql, params, expr) + + def validate_collation_name(self, expr: "CollateExpression") -> str: + """Validate PostgreSQL collation names and return their SQL representation.""" + schema = expr.collation_options.get("schema") + unsupported = set(expr.collation_options) - {"schema"} + if unsupported: + options = ", ".join(sorted(unsupported)) + raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") + validate_postgres_collation_name(expr.collation_name, getattr(self, "version", None)) + if schema is not None: + return f"{self.format_identifier(str(schema))}.{self.format_identifier(expr.collation_name)}" + return self.format_identifier(expr.collation_name) def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` - Supported options assembled here: + Supported options: - ``ANALYZE`` - ``FORMAT { TEXT | XML | JSON | YAML }`` - - ``VERBOSE`` (passed through ``ExplainOptions.verbose`` if present) + - ``QUERY PLAN`` type — silently omitted (plain EXPLAIN is equivalent). + + Args: + explain_expr: ExplainExpression instance + + Returns: + Tuple of (SQL string, params tuple) + """ from rhosocial.activerecord.backend.expression.statements import ExplainType @@ -560,12 +679,6 @@ def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params return f"EXPLAIN {statement_sql}", statement_params - def supports_graph_match(self) -> bool: - """Whether graph query MATCH clause is supported.""" - # PostgreSQL doesn't have native MATCH clause like some other systems - # Though graph querying can be done with extensions like Apache AGE - return False - def supports_for_update(self) -> bool: """Whether FOR UPDATE clause is supported in SELECT statements. @@ -591,6 +704,7 @@ def supports_lock_strength(self, strength) -> bool: Returns: True if the lock strength is supported, False otherwise + """ from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength @@ -604,84 +718,20 @@ def supports_lock_strength(self, strength) -> bool: return self.version >= (9, 3, 0) return False - def supports_merge_statement(self) -> bool: - """Whether MERGE statement is supported.""" - return self.version >= (15, 0, 0) # MERGE added in PostgreSQL 15 - - def supports_temporal_tables(self) -> bool: - """Whether temporal tables are supported.""" - # PostgreSQL doesn't have built-in temporal tables - return False - - def supports_qualify_clause(self) -> bool: - """Whether QUALIFY clause is supported.""" - # PostgreSQL doesn't have QUALIFY clause (though can be simulated with subqueries) - return False + # region ILIKE Support - def supports_upsert(self) -> bool: - """Whether UPSERT (ON CONFLICT) is supported.""" - return self.version >= (9, 5, 0) # ON CONFLICT added in 9.5 + def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: + """Format ILIKE expression for PostgreSQL. - def get_upsert_syntax_type(self) -> str: - """ - Get UPSERT syntax type. + Args: + column: Column name string or expression with ``to_sql()``. + pattern: Right-hand-side pattern (replaced with ``%s`` placeholder). + negate: If ``True``, produces ``NOT ILIKE`` instead of ``ILIKE``. Returns: - 'ON CONFLICT' (PostgreSQL) or 'ON DUPLICATE KEY' (MySQL) - """ - return "ON CONFLICT" - - def supports_lateral_join(self) -> bool: - """Whether LATERAL joins are supported.""" - return self.version >= (9, 3, 0) # LATERAL joins added in 9.3 - - def supports_ordered_set_aggregation(self) -> bool: - """Whether ordered-set aggregate functions are supported.""" - return self.version >= (9, 4, 0) # Supported since 9.4 - - def supports_multirange(self) -> bool: - """Whether multirange types are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_multirange_constructor(self) -> bool: - """Whether multirange constructor functions are supported (PostgreSQL 14+).""" - return self.version >= (14, 0, 0) - - def supports_inner_join(self) -> bool: - """INNER JOIN is supported.""" - return True - - def supports_left_join(self) -> bool: - """LEFT JOIN is supported.""" - return True - - def supports_right_join(self) -> bool: - """RIGHT JOIN is supported.""" - return True - - def supports_full_join(self) -> bool: - """FULL JOIN is supported.""" - return True - - def supports_cross_join(self) -> bool: - """CROSS JOIN is supported.""" - return True - - def supports_natural_join(self) -> bool: - """NATURAL JOIN is supported.""" - return True - - def supports_wildcard(self) -> bool: - """Wildcard (*) is supported.""" - return True + Tuple of (SQL string, (pattern,) params tuple) - # region ILIKE Support - def supports_ilike(self) -> bool: - """ILIKE is supported.""" - return True - - def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: - """Format ILIKE expression for PostgreSQL.""" + """ if isinstance(column, str): col_sql = self.format_identifier(column) else: @@ -696,56 +746,22 @@ def format_ilike_expression(self, column: Any, pattern: str, negate: bool = Fals # endregion - # region Set Operation Support - def supports_union(self) -> bool: - """UNION is supported.""" - return True - - def supports_union_all(self) -> bool: - """UNION ALL is supported.""" - return True - - def supports_intersect(self) -> bool: - """INTERSECT is supported.""" - return True - - def supports_except(self) -> bool: - """EXCEPT is supported.""" - return True - - def supports_set_operation_order_by(self) -> bool: - """ORDER BY is supported for set operations.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """LIMIT/OFFSET is supported for set operations.""" - return True - - def supports_set_operation_for_update(self) -> bool: - """FOR UPDATE is supported for set operations.""" - return True - - # endregion - # region Truncate Support - def supports_truncate(self) -> bool: - """TRUNCATE is supported.""" - return True - def supports_truncate_table_keyword(self) -> bool: - """TABLE keyword is supported in TRUNCATE.""" - return True + def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + """Format TRUNCATE statement for PostgreSQL. - def supports_truncate_restart_identity(self) -> bool: - """RESTART IDENTITY is supported since PostgreSQL 8.4.""" - return self.version >= (8, 4, 0) + - ``expr.table_name`` — target table. + - ``expr.restart_identity`` — add ``RESTART IDENTITY`` (PG 8.4+). + - ``expr.cascade`` — add ``CASCADE``. - def supports_truncate_cascade(self) -> bool: - """CASCADE is supported in TRUNCATE.""" - return True + Args: + expr: TruncateExpression instance - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: - """Format TRUNCATE statement for PostgreSQL.""" + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["TRUNCATE TABLE"] parts.append(self.format_identifier(expr.table_name)) @@ -769,6 +785,7 @@ def format_identifier(self, identifier: str) -> str: Returns: Quoted identifier with escaped internal quotes + """ # Escape any internal double quotes by doubling them escaped = identifier.replace('"', '""') @@ -792,7 +809,11 @@ def format_column(self, name: str, table: Optional[str] = None, # In PostgreSQL, when a table has an alias, column references # must use the alias — schema_name is irrelevant in this context. if schema_name and not alias: - col_sql = f"{self.format_identifier(schema_name)}.{self.format_identifier(table)}.{self.format_identifier(name)}" + col_sql = ( + f"{self.format_identifier(schema_name)}." + f"{self.format_identifier(table)}." + f"{self.format_identifier(name)}" + ) else: col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" else: @@ -809,6 +830,18 @@ def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: Overrides the base implementation to handle EXCLUDED pseudo-table references without quoting, as EXCLUDED is a special PostgreSQL keyword in ON CONFLICT context and must not be double-quoted. + + - ``expr.conflict_target`` — optional list of column names / expressions. + - ``expr.do_nothing`` — ``DO NOTHING``. + - ``expr.update_assignments`` — dict of ``{column: expression}`` for ``DO UPDATE SET``. + - ``expr.update_where`` — optional WHERE predicate for the update. + + Args: + expr: OnConflictClause expression instance + + Returns: + Tuple of (SQL string, params tuple) + """ from rhosocial.activerecord.backend.expression import bases from rhosocial.activerecord.backend.expression.core import Column @@ -863,48 +896,24 @@ def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) # region View Support - def supports_or_replace_view(self) -> bool: - """Whether CREATE OR REPLACE VIEW is supported.""" - return True # PostgreSQL supports OR REPLACE - - def supports_temporary_view(self) -> bool: - """Whether TEMPORARY views are supported.""" - return True # PostgreSQL supports TEMPORARY views - def supports_materialized_view(self) -> bool: - """Whether materialized views are supported.""" - return True # PostgreSQL supports materialized views since 9.3 - - def supports_refresh_materialized_view(self) -> bool: - """Whether REFRESH MATERIALIZED VIEW is supported.""" - return True # PostgreSQL supports REFRESH MATERIALIZED VIEW - - def supports_materialized_view_concurrent_refresh(self) -> bool: - """Whether concurrent refresh for materialized views is supported.""" - return self.version >= (9, 4, 0) # CONCURRENTLY added in 9.4 - - def supports_materialized_view_tablespace(self) -> bool: - """Whether tablespace specification for materialized views is supported.""" - return True - - def supports_materialized_view_storage_options(self) -> bool: - """Whether storage options for materialized views are supported.""" - return True + def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + """Format CREATE VIEW statement for PostgreSQL. - def supports_if_exists_view(self) -> bool: - """Whether DROP VIEW IF EXISTS is supported.""" - return True # PostgreSQL supports IF EXISTS + - ``expr.temporary`` — add ``TEMPORARY``. + - ``expr.replace`` — add ``OR REPLACE``. + - ``expr.view_name`` — view name (identifier). + - ``expr.column_aliases`` — optional list of column aliases. + - ``expr.query`` — source SELECT expression. + - ``expr.options.check_option`` — ``WITH {LOCAL|CASCADED} CHECK OPTION``. - def supports_view_check_option(self) -> bool: - """Whether WITH CHECK OPTION is supported.""" - return True # PostgreSQL supports WITH CHECK OPTION + Args: + expr: CreateViewExpression instance - def supports_cascade_view(self) -> bool: - """Whether DROP VIEW CASCADE is supported.""" - return True # PostgreSQL supports CASCADE + Returns: + Tuple of (SQL string, params tuple) - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: - """Format CREATE VIEW statement for PostgreSQL.""" + """ parts = ["CREATE"] if expr.temporary: @@ -930,7 +939,19 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st return " ".join(parts), query_params def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: - """Format DROP VIEW statement for PostgreSQL.""" + """Format DROP VIEW statement for PostgreSQL. + + - ``expr.if_exists`` — add ``IF EXISTS``. + - ``expr.view_name`` — view name (identifier). + - ``expr.cascade`` — add ``CASCADE``. + + Args: + expr: DropViewExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["DROP VIEW"] if expr.if_exists: parts.append("IF EXISTS") @@ -940,7 +961,22 @@ def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, t return " ".join(parts), () def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL.""" + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + + - ``expr.view_name`` — view name (identifier). + - ``expr.column_aliases`` — optional list of column aliases. + - ``expr.tablespace`` — optional tablespace. + - ``expr.storage_options`` — optional dict of storage parameters (``WITH (… )``). + - ``expr.query`` — source SELECT expression. + - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. + + Args: + expr: CreateMaterializedViewExpression instance + + Returns: + Tuple of (SQL string, params tuple) + + """ parts = ["CREATE MATERIALIZED VIEW"] parts.append(self.format_identifier(expr.view_name)) @@ -968,7 +1004,19 @@ def format_create_materialized_view_statement(self, expr: "CreateMaterializedVie return " ".join(parts), query_params def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: - """Format DROP MATERIALIZED VIEW statement for PostgreSQL.""" + """Format DROP MATERIALIZED VIEW statement for PostgreSQL. + + - ``expr.if_exists`` — add ``IF EXISTS``. + - ``expr.view_name`` — view name (identifier). + - ``expr.cascade`` — add ``CASCADE``. + + Args: + expr: DropMaterializedViewExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["DROP MATERIALIZED VIEW"] if expr.if_exists: parts.append("IF EXISTS") @@ -980,7 +1028,19 @@ def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExp def format_refresh_materialized_view_statement( self, expr: "RefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: - """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL.""" + """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL. + + - ``expr.concurrent`` — add ``CONCURRENTLY`` (PG 9.4+). + - ``expr.view_name`` — view name (identifier). + - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. + + Args: + expr: RefreshMaterializedViewExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["REFRESH MATERIALIZED VIEW"] if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): parts.append("CONCURRENTLY") @@ -991,101 +1051,53 @@ def format_refresh_materialized_view_statement( # endregion - # region Schema Support - def supports_create_schema(self) -> bool: - """Whether CREATE SCHEMA is supported.""" - return True - - def supports_drop_schema(self) -> bool: - """Whether DROP SCHEMA is supported.""" - return True - - def supports_schema_if_not_exists(self) -> bool: - """Whether CREATE SCHEMA IF NOT EXISTS is supported.""" - return True # PostgreSQL 9.3+ supports IF NOT EXISTS - - def supports_schema_if_exists(self) -> bool: - """Whether DROP SCHEMA IF EXISTS is supported.""" - return True - - def supports_schema_cascade(self) -> bool: - """Whether DROP SCHEMA CASCADE is supported.""" - return True - - # endregion - # region Index Support - def supports_create_index(self) -> bool: - """Whether CREATE INDEX is supported.""" - return True - def supports_drop_index(self) -> bool: - """Whether DROP INDEX is supported.""" - return True + def format_add_index_action(self, action: "AddIndex") -> Tuple[str, tuple]: + """PostgreSQL does not support ALTER TABLE ADD INDEX. - def supports_unique_index(self) -> bool: - """Whether UNIQUE indexes are supported.""" - return True - - def supports_index_if_not_exists(self) -> bool: - """Whether CREATE INDEX IF NOT EXISTS is supported.""" - return True # PostgreSQL 9.5+ supports IF NOT EXISTS + Raises UnsupportedFeatureError — use CREATE INDEX instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) - def supports_index_if_exists(self) -> bool: - """Whether DROP INDEX IF EXISTS is supported.""" - return True + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ADD INDEX", + suggestion="Use CREATE INDEX to create an index on the table.", + ) - # endregion + def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, tuple]: # noqa: F821 + """PostgreSQL does not support ALTER TABLE DROP INDEX. - # region Sequence Support - def supports_create_sequence(self) -> bool: - """Whether CREATE SEQUENCE is supported.""" - return True + Raises UnsupportedFeatureError — use DROP INDEX statement instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) - def supports_drop_sequence(self) -> bool: - """Whether DROP SEQUENCE is supported.""" - return True + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE DROP INDEX", + suggestion="Use DROP INDEX statement to remove an index.", + ) # endregion # region Table Support - def supports_if_not_exists_table(self) -> bool: - """Whether CREATE TABLE IF NOT EXISTS is supported.""" - return True - - def supports_if_exists_table(self) -> bool: - """Whether DROP TABLE IF EXISTS is supported.""" - return True - - def supports_temporary_table(self) -> bool: - """Whether TEMPORARY tables are supported.""" - return True - - def supports_table_inheritance(self) -> bool: - """Whether table inheritance is supported.""" - return True # PostgreSQL supports INHERITS - - def supports_table_partitioning(self) -> bool: - """Whether table partitioning is supported.""" - return True # PostgreSQL supports partitioning - - def supports_table_tablespace(self) -> bool: - """Whether tablespace specification is supported.""" - return True # Constraint capability overrides - def supports_constraint_enforced(self) -> bool: - """PostgreSQL does not support ENFORCED/NOT ENFORCED (SQL:2016). - - PostgreSQL uses NOT VALID as a proprietary alternative for - skipping validation of existing data when adding constraints. - """ - return False - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: """ - Format CREATE TABLE statement for PostgreSQL, including LIKE syntax support. + Format CREATE TABLE statement for PostgreSQL. + + This formatter handles PostgreSQL-specific CREATE TABLE behavior before + delegating ordinary table generation to the base dialect: + + * PostgreSQL LIKE syntax with INCLUDING/EXCLUDING options. + * Declarative partitioning validation for ``partition`` expressions. PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, @@ -1101,8 +1113,18 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior 2. When `dialect_options` does NOT contain 'like_table': - - Falls back to base class implementation - - Standard CREATE TABLE with column definitions is generated + - Applies PostgreSQL partition validation when `partition` is present + - Falls back to base class implementation for SQL generation + - Standard CREATE TABLE and CREATE TABLE ... PARTITION BY statements + are generated by the base formatter + + Partition validation follows PostgreSQL declarative partitioning rules: + + - RANGE and LIST partitioned tables require PostgreSQL 10+. + - HASH partitioned tables require PostgreSQL 11+. + - MySQL-specific partition methods such as KEY and RANGE COLUMNS are rejected. + - The API is `partition=PartitionClause(...)`; PartitionClause is the + standard expression type for partition specification. The 'like_options' key supports two formats: @@ -1175,11 +1197,30 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ ) # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) + # PostgreSQL declarative partitioned table + CreateTableExpression( + dialect=postgres_dialect, + table="events", + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ], + partition=PartitionClause( + dialect=postgres_dialect, + method="RANGE", + keys=[Column(postgres_dialect, "created_at")], + ), + ) + # Generates: CREATE TABLE "events" (...) PARTITION BY RANGE ("created_at") + # PostgreSQL dialect validates version support before delegating to + # the base formatter. + Args: expr: CreateTableExpression instance Returns: Tuple of (SQL string, parameters tuple) + """ # Check for LIKE syntax in dialect_options (highest priority) if "like_table" in expr.dialect_options: @@ -1237,6 +1278,10 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ return " ".join(parts), () + if getattr(expr, "partition", None) is not None: + # Validate through the PartitionClause → format_partition_clause chain. + expr.partition.to_sql() + # Otherwise, delegate to base implementation return super().format_create_table_statement(expr) @@ -1273,6 +1318,7 @@ def format_cast_expression( For chained type conversions, each ::type is appended: >>> col.cast('money').cast('numeric').cast('float8') # Generates: col::money::numeric::float8 + """ sql = f"{expr_sql}::{target_type}" if alias: @@ -1361,6 +1407,7 @@ def _format_exclude_constraint( } ) # Generates: EXCLUDE USING gist (range WITH &&) + """ params: list = [] @@ -1423,18 +1470,6 @@ def _format_exclude_constraint( # endregion - # region Trigger Support (PostgreSQL-specific) - def supports_trigger(self) -> bool: - return True - - def supports_create_trigger(self) -> bool: - return True - - def supports_drop_trigger(self) -> bool: - return True - - # endregion - # region Transaction Control Support # PostgreSQL function version support: function_name -> (min_version, max_version) @@ -1458,6 +1493,7 @@ def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: Returns: Dict mapping function names to FunctionSupportInfo. + """ from .function_versions import FunctionSupportInfo from rhosocial.activerecord.backend.expression.functions import ( @@ -1465,9 +1501,25 @@ def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: ) from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + expression_constructors = { + "xmlagg", + "xmlattributes", + "xmlcomment", + "xmlconcat", + "xmlelement", + "xmlexists", + "xmlforest", + "xmlparse", + "xmlpi", + "xmlquery", + "xmlroot", + "xmlserialize", + "xmltable", + } result: Dict[str, FunctionSupportInfo] = {} for func_name in core_functions: - result[func_name] = self._check_function_support(func_name) + if func_name not in expression_constructors: + result[func_name] = self._check_function_support(func_name) postgres_funcs = getattr(postgres_functions, "__all__", []) for func_name in postgres_funcs: @@ -1484,6 +1536,7 @@ def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": Returns: FunctionSupportInfo with support status and reason if unsupported + """ from .function_versions import FunctionSupportInfo @@ -1533,6 +1586,7 @@ def _is_postgres_function_supported(self, func_name: str) -> bool: Returns: True if supported, False otherwise + """ requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) if requirement is None: @@ -1583,26 +1637,6 @@ def _check_extension_requirement( return True - def supports_transaction_mode(self) -> bool: - """PostgreSQL supports READ ONLY and READ WRITE transaction modes.""" - return True - - def supports_isolation_level_in_begin(self) -> bool: - """PostgreSQL supports isolation level specification in BEGIN statement.""" - return True - - def supports_read_only_transaction(self) -> bool: - """PostgreSQL supports READ ONLY transactions.""" - return True - - def supports_deferrable_transaction(self) -> bool: - """PostgreSQL supports DEFERRABLE transactions (for SERIALIZABLE isolation).""" - return True - - def supports_savepoint(self) -> bool: - """PostgreSQL supports savepoints.""" - return True - def format_begin_transaction( self, expr: "BeginTransactionExpression" ) -> Tuple[str, tuple]: @@ -1642,7 +1676,17 @@ def format_begin_transaction( def format_commit_transaction( self, expr: "CommitTransactionExpression" ) -> Tuple[str, tuple]: - """Format COMMIT TRANSACTION statement for PostgreSQL.""" + """Format COMMIT TRANSACTION statement for PostgreSQL. + + Always returns ``COMMIT``; ``expr`` is ignored. + + Args: + expr: CommitTransactionExpression instance (unused) + + Returns: + Tuple of ("COMMIT", ()) + + """ return "COMMIT", () def format_rollback_transaction( @@ -1661,7 +1705,17 @@ def format_rollback_transaction( def format_savepoint( self, expr: "SavepointExpression" ) -> Tuple[str, tuple]: - """Format SAVEPOINT statement for PostgreSQL.""" + """Format SAVEPOINT statement for PostgreSQL. + + ``expr.get_params()["name"]`` — savepoint name (identifier). + + Args: + expr: SavepointExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ params = expr.get_params() name = params.get("name", "") return f"SAVEPOINT {self.format_identifier(name)}", () @@ -1669,7 +1723,17 @@ def format_savepoint( def format_release_savepoint( self, expr: "ReleaseSavepointExpression" ) -> Tuple[str, tuple]: - """Format RELEASE SAVEPOINT statement for PostgreSQL.""" + """Format RELEASE SAVEPOINT statement for PostgreSQL. + + ``expr.get_params()["name"]`` — savepoint name (identifier). + + Args: + expr: ReleaseSavepointExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ params = expr.get_params() name = params.get("name", "") return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py index 8971e5c7..9005f9a7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -87,6 +87,7 @@ # Add a new column add_email_action = AddColumn( + dialect=dialect, column=ColumnDefinition( name='email', data_type='VARCHAR(100)', @@ -111,6 +112,7 @@ # Add another column without default (PostgreSQL ALTER TABLE doesn't support parameterized DEFAULT well) add_age_action = AddColumn( + dialect=dialect, column=ColumnDefinition( 'age', 'INT', @@ -129,6 +131,7 @@ # Rename a column rename_action = RenameColumn( + dialect=dialect, old_name='name', new_name='full_name', ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py new file mode 100644 index 00000000..0f2b3b02 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/__init__.py @@ -0,0 +1 @@ +"""SQL/XML expression examples for PostgreSQL.""" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml new file mode 100644 index 00000000..0db8a159 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_1.xml @@ -0,0 +1 @@ +alpha diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml new file mode 100644 index 00000000..6e2f0f8b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/item_2.xml @@ -0,0 +1 @@ +beta diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml new file mode 100644 index 00000000..3b6e9eea --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/root.xml @@ -0,0 +1,4 @@ + + alpha + beta + diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py new file mode 100644 index 00000000..dba2aac5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py @@ -0,0 +1,331 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py +"""PostgreSQL SQL/XML standard expression examples. + +By default this script only renders SQL and parameters. Pass --execute to run +safe SELECT examples against PostgreSQL using PGHOST / PGPORT / PGDATABASE / +PGUSER / PGPASSWORD environment variables. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Iterable, Optional, Tuple, Union + +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + InsertExpression, + Literal, + OrderByClause, + QueryExpression, + TableExpression, + ValuesSource, + XMLStandaloneOption, + XMLTableColumn, + xmlagg, + xmlattributes, + xmlcomment, + xmlconcat, + xmlelement, + xmlexists, + xmlforest, + xmlparse, + xmlpi, + xmlroot, + xmlserialize, + xmltable, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresBackend, + PostgresConnectionConfig, + PostgresDialect, +) + +SQLParam = Optional[Union[str, int, float, bool]] +SQLParams = Tuple[SQLParam, ...] +SQLQueryAndParams = Tuple[str, SQLParams] +EXAMPLE_TABLE = "sqlxml_example_items" +EXAMPLE_DIR = Path(__file__).resolve().parent + + +def load_xml(filename: str) -> str: + return (EXAMPLE_DIR / filename).read_text(encoding="utf-8").strip() + + +class SQLXMLExample: + def __init__( + self, + title: str, + sql_and_params: SQLQueryAndParams, + executable: bool = True, + ): + self.title: str = title + self.sql_and_params: SQLQueryAndParams = sql_and_params + self.executable: bool = executable + + +def text_literal(dialect: PostgresDialect, value: str) -> Literal: + expr = Literal(dialect, value) + expr.cast("TEXT") + return expr + + +def xml_literal(dialect: PostgresDialect, value: str) -> Literal: + expr = Literal(dialect, value) + expr.cast("XML") + return expr + + +def render(example: SQLXMLExample) -> None: + sql, params = example.sql_and_params + print(f"\n=== {example.title} ===") + print(sql) + print(f"params: {params}") + + +def select(dialect: PostgresDialect, expr) -> SQLQueryAndParams: + sql, params = QueryExpression(dialect, [expr]).to_sql() + return sql, tuple(params) + + +def select_from_examples(dialect: PostgresDialect, expr) -> SQLQueryAndParams: + sql, params = QueryExpression( + dialect, + [expr], + from_=TableExpression(dialect, EXAMPLE_TABLE), + ).to_sql() + return sql, tuple(params) + + +def build_examples(dialect: PostgresDialect) -> Iterable[SQLXMLExample]: + payload = Column(dialect, "payload") + item_id = Column(dialect, "id") + root_xml = load_xml("root.xml") + + parsed_document = xmlparse(dialect, xml_literal(dialect, root_xml)) + parsed_content = xmlparse( + dialect, + xml_literal(dialect, "alphabeta"), + document=False, + ) + + yield SQLXMLExample("XMLPARSE document literal", select(dialect, parsed_document)) + yield SQLXMLExample("XMLPARSE content literal", select(dialect, parsed_content)) + yield SQLXMLExample( + "XMLSERIALIZE content as text", + select(dialect, xmlserialize(dialect, parsed_document, "TEXT")), + ) + + yield SQLXMLExample( + "XMLELEMENT with XMLATTRIBUTES", + select( + dialect, + xmlelement( + dialect, + "product", + text_literal(dialect, "PostgreSQL"), + attributes=xmlattributes( + dialect, + (text_literal(dialect, "pg-001"), "sku"), + (text_literal(dialect, "active"), "status"), + ), + ), + ), + ) + + yield SQLXMLExample( + "XMLFOREST values", + select( + dialect, + xmlforest( + dialect, + (text_literal(dialect, "Ada"), "first_name"), + (text_literal(dialect, "Lovelace"), "last_name"), + (Literal(dialect, 1815), "birth_year"), + ), + ), + ) + + yield SQLXMLExample( + "XMLCONCAT constructor output", + select( + dialect, + xmlconcat( + dialect, + xmlelement(dialect, "first", text_literal(dialect, "alpha")), + xmlelement(dialect, "second", text_literal(dialect, "beta")), + ), + ), + ) + + yield SQLXMLExample( + "XMLCOMMENT and XMLPI", + select( + dialect, + xmlconcat( + dialect, + xmlcomment(dialect, text_literal(dialect, "generated by rhosocial-activerecord")), + xmlpi( + dialect, + "stylesheet", + text_literal(dialect, "type=\"text/xsl\" href=\"style.xsl\""), + ), + ), + ), + ) + + yield SQLXMLExample( + "XMLROOT with version and standalone", + select( + dialect, + xmlroot( + dialect, + parsed_document, + version="1.0", + standalone=XMLStandaloneOption.YES, + ), + ), + ) + + yield SQLXMLExample( + "XMLAGG over prepared table", + select_from_examples( + dialect, + xmlagg( + dialect, + payload, + order_by=OrderByClause(dialect, [(item_id, "ASC")]), + ), + ), + ) + + yield SQLXMLExample( + "XMLEXISTS predicate", + select(dialect, xmlexists(dialect, text_literal(dialect, "/root/item[@id=\"1\"]"), passing=[parsed_document])), + executable=False, + ) + + yield SQLXMLExample( + "XMLTABLE rows", + xmltable( + dialect, + text_literal(dialect, "/root/item"), + [ + XMLTableColumn("id", "INTEGER", path=text_literal(dialect, "@id")), + XMLTableColumn("name", "TEXT", path=text_literal(dialect, "text()")), + ], + passing=[parsed_document], + ).to_sql(), + executable=False, + ) + + +def load_config() -> PostgresConnectionConfig: + return PostgresConnectionConfig( + host=os.environ.get("PGHOST", "localhost"), + port=int(os.environ.get("PGPORT", "5432")), + database=os.environ.get("PGDATABASE", "test_db"), + username=os.environ.get("PGUSER", "postgres"), + password=os.environ.get("PGPASSWORD", ""), + sslmode=os.environ.get("PGSSLMODE", "prefer"), + ) + + +def execute_expression(backend: PostgresBackend, expr) -> None: + sql, params = expr.to_sql() + backend.execute(sql, tuple(params)) + + +def prepare_example_data(backend: PostgresBackend, dialect: PostgresDialect) -> None: + execute_expression(backend, DropTableExpression(dialect, EXAMPLE_TABLE, if_exists=True)) + execute_expression( + backend, + CreateTableExpression( + dialect, + EXAMPLE_TABLE, + [ + ColumnDefinition( + "id", + "INTEGER", + [ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition( + "payload", + "XML", + [ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ], + ), + ) + execute_expression( + backend, + InsertExpression( + dialect, + EXAMPLE_TABLE, + ValuesSource( + dialect, + [ + [Literal(dialect, 1), xmlparse(dialect, xml_literal(dialect, load_xml("item_1.xml")))], + [Literal(dialect, 2), xmlparse(dialect, xml_literal(dialect, load_xml("item_2.xml")))], + ], + ), + columns=["id", "payload"], + ), + ) + + +def cleanup_example_data(backend: PostgresBackend, dialect: PostgresDialect) -> None: + execute_expression(backend, DropTableExpression(dialect, EXAMPLE_TABLE, if_exists=True)) + + +def run_select_examples(dialect: PostgresDialect, examples: Iterable[SQLXMLExample]) -> None: + backend = PostgresBackend(connection_config=load_config()) + backend.connect() + try: + prepare_example_data(backend, dialect) + for example in examples: + print(f"\n=== execute: {example.title} ===") + if not example.executable: + print("skip execution: rendered expression is not a standalone query") + continue + sql, params = example.sql_and_params + print(backend.execute(sql, params).data) + finally: + try: + cleanup_example_data(backend, dialect) + finally: + backend.disconnect() + + +def main() -> None: + execute = "--execute" in sys.argv + dialect = PostgresDialect((16, 0, 0)) + examples = list(build_examples(dialect)) + + print("PostgreSQL SQL/XML standard capability flags:") + print(f" XMLPARSE: {dialect.supports_xmlparse()}") + print(f" XMLSERIALIZE: {dialect.supports_xmlserialize()}") + print(f" XMLELEMENT: {dialect.supports_xmlelement()}") + print(f" XMLAGG: {dialect.supports_xmlagg()}") + print(f" XMLEXISTS: {dialect.supports_xmlexists()}") + print(f" XMLTABLE: {dialect.supports_xmltable()}") + print( + f" XMLQUERY: {dialect.supports_xmlquery()} " + "(not declared as PostgreSQL standard support)" + ) + + for example in examples: + render(example) + + if execute: + run_select_examples(dialect, examples) + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 3f1ec5f9..9de671dd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -18,13 +18,16 @@ PostgresTryAdvisoryLockExpression, AdvisoryLockType, ) -from .locking import LockStrength, PostgresForUpdateClause +from .locking import LockStrength from .ddl import ( PostgresVacuumExpression, PostgresAnalyzeExpression, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, + PostgresPartitionMetadataExpression, + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, PostgresReindexExpression, PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -38,6 +41,10 @@ PostgresCreateRangeTypeExpression, PostgresCreateExtensionExpression, PostgresDropExtensionExpression, + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, + PostgresPgPartmanDeleteConfigExpression, ) __all__ = [ @@ -49,13 +56,15 @@ "AdvisoryLockType", # locking "LockStrength", - "PostgresForUpdateClause", # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", + "PostgresPartitionMetadataExpression", + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", "PostgresReindexExpression", "PostgresCreateStatisticsExpression", "PostgresDropStatisticsExpression", @@ -69,4 +78,8 @@ "PostgresCreateRangeTypeExpression", "PostgresCreateExtensionExpression", "PostgresDropExtensionExpression", -] \ No newline at end of file + "PostgresPgPartmanCreateParentExpression", + "PostgresPgPartmanRunMaintenanceExpression", + "PostgresPgPartmanUpdateConfigExpression", + "PostgresPgPartmanDeleteConfigExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py index ee01a9cf..d42f20b0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py @@ -78,6 +78,7 @@ def __init__( key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers shared: If True, acquire shared lock; otherwise exclusive lock session: If True (default), session-level lock; otherwise transaction-level + """ super().__init__(dialect) self.key = key @@ -90,6 +91,7 @@ def to_sql(self) -> SQLQueryAndParams: Returns: Tuple of (SQL string, parameters tuple) + """ return self.dialect.format_advisory_lock(self) @@ -125,6 +127,7 @@ def __init__( dialect: The SQL dialect to use for formatting key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers shared: If True, indicates this was a shared lock (informational only) + """ super().__init__(dialect) self.key = key @@ -136,6 +139,7 @@ def to_sql(self) -> SQLQueryAndParams: Returns: Tuple of (SQL string, parameters tuple) + """ return self.dialect.format_advisory_unlock(self) @@ -159,6 +163,7 @@ def to_sql(self) -> SQLQueryAndParams: Returns: Tuple of (SQL string, parameters tuple) + """ return self.dialect.format_advisory_unlock_all(self) @@ -199,6 +204,7 @@ def __init__( key: Lock key - either a single 64-bit integer or tuple of two 32-bit integers shared: If True, acquire shared lock; otherwise exclusive lock session: If True (default), session-level lock; otherwise transaction-level + """ super().__init__(dialect) self.key = key @@ -211,5 +217,6 @@ def to_sql(self) -> SQLQueryAndParams: Returns: Tuple of (SQL string, parameters tuple) + """ return self.dialect.format_try_advisory_lock(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index c5047369..9fa46a23 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -11,15 +11,58 @@ - mv.py - Materialized view expressions - type.py - Enum/Range type expressions - extension.py - Extension DDL expressions + +Missing expressions — why? +============================ +This package does **not** provide ``PostgresCreateIndexExpression`` or +``PostgresDropIndexExpression``. Those were removed because the generic +``CreateIndexExpression`` / ``DropIndexExpression`` (from +``rhosocial.activerecord.backend.expression.statements.ddl_index``) already +accept all common parameters **and** a ``dialect_options`` dict. + +PG‑specific features such as ``NULLS NOT DISTINCT`` or ``CONCURRENTLY`` on +DROP INDEX are passed through ``dialect_options`` and consumed by the dialect +(``PostgresIndexMixin``). See ``index.py`` for the supported keys. + +Example:: + + from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, + ) + from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + + d = PostgresDialect((15, 0, 0)) + + # NULLS NOT DISTINCT via dialect_options + expr = CreateIndexExpression( + d, "idx_uniq_abc", "t", ["a", "b"], + unique=True, + dialect_options={"nulls_not_distinct": True}, + ) + sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT + + # DROP INDEX CONCURRENTLY via dialect_options (PG 18+) + expr = DropIndexExpression( + d, "idx_old", + dialect_options={"concurrent": True}, + ) + sql, _ = expr.to_sql() # → DROP INDEX CONCURRENTLY … """ from .vacuum import PostgresVacuumExpression, PostgresAnalyzeExpression from .partition import ( + PartitionValue, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, + PostgresPartitionMetadataExpression, +) +from .index import ( + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, + PostgresReindexExpression, ) -from .index import PostgresReindexExpression from .statistics import ( PostgresCreateStatisticsExpression, PostgresDropStatisticsExpression, @@ -35,16 +78,26 @@ PostgresCreateRangeTypeExpression, ) from .extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression +from .pg_partman import ( + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, + PostgresPgPartmanDeleteConfigExpression, +) __all__ = [ # vacuum "PostgresVacuumExpression", "PostgresAnalyzeExpression", # partition + "PartitionValue", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", + "PostgresPartitionMetadataExpression", # index + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", "PostgresReindexExpression", # statistics "PostgresCreateStatisticsExpression", @@ -63,4 +116,8 @@ # extension "PostgresCreateExtensionExpression", "PostgresDropExtensionExpression", -] \ No newline at end of file + "PostgresPgPartmanCreateParentExpression", + "PostgresPgPartmanRunMaintenanceExpression", + "PostgresPgPartmanUpdateConfigExpression", + "PostgresPgPartmanDeleteConfigExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py index c64cac83..d3301a74 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py @@ -63,6 +63,7 @@ class PostgresCommentExpression(BaseExpression): ... object_name="users_email_idx", ... comment=None, ... ) + """ def __init__( @@ -87,5 +88,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_comment_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py index 17eec3e7..fa8fbc8c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py @@ -56,6 +56,7 @@ class PostgresCreateExtensionExpression(BaseExpression): ... version="3.4.0", ... if_not_exists=True, ... ) + """ def __init__( @@ -82,6 +83,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_create_extension(self) @@ -110,6 +112,7 @@ class PostgresDropExtensionExpression(BaseExpression): >>> sql, params = drop_ext.to_sql() >>> sql 'DROP EXTENSION IF EXISTS "uuid-ossp" CASCADE' + """ def __init__( @@ -136,5 +139,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_drop_extension(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index a46060e6..24118b79 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -1,25 +1,147 @@ # src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py """ -PostgreSQL DDL expressions: Index operations (REINDEX). +PostgreSQL DDL expressions: Index operations (ALTER INDEX, REINDEX). PostgreSQL Documentation: +- ALTER INDEX: https://www.postgresql.org/docs/current/sql-alterindex.html - REINDEX: https://www.postgresql.org/docs/current/sql-reindex.html Version Requirements: - REINDEX: PostgreSQL 8.0+ -- CONCURRENTLY option: PostgreSQL 12+ - TABLESPACE option: PostgreSQL 14+ +- CONCURRENTLY option: PostgreSQL 12+ + +CREATE / DROP INDEX — use the generic expressions with ``dialect_options`` +========================================================================== +This module intentionally does **not** define ``PostgresCreateIndexExpression`` +or ``PostgresDropIndexExpression``. The generic classes from the core +package already serve this purpose: + + from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, + ) + +PostgreSQL‑specific options are supplied via the ``dialect_options`` dict and +consumed by ``PostgresIndexMixin.format_create_index_statement`` / +``format_drop_index_statement``. Supported keys: + +======================= ======================================================= +Key Description +======================= ======================================================= +``nulls_not_distinct`` ``bool`` — add ``NULLS NOT DISTINCT`` (PG 15+, unique + index only). Passed to ``CreateIndexExpression``. +``opclasses`` ``dict[str, str]`` — operator classes per column, e.g. + ``{"a": "text_pattern_ops"}``. Passed to + ``CreateIndexExpression``. +``with`` ``dict[str, Any]`` — storage parameters, e.g. + ``{"fillfactor": 70}``. Passed to + ``CreateIndexExpression``. +``concurrent`` ``bool`` — add ``CONCURRENTLY`` to ``DROP INDEX`` + (PG 18+). Passed to ``DropIndexExpression``. + (For ``CREATE INDEX`` the generic class already has + a ``concurrent`` named parameter.) +======================= ======================================================= + +Example:: + + from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + + d = PostgresDialect((15, 0, 0)) + + expr = CreateIndexExpression( + d, "idx_uniq_abc", "t", ["a", "b"], + unique=True, + dialect_options={"nulls_not_distinct": True}, + ) + sql, _ = expr.to_sql() # → CREATE UNIQUE INDEX … NULLS NOT DISTINCT """ +from enum import Enum from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -__all__ = ["PostgresReindexExpression"] +__all__ = [ + "PostgresAlterIndexExpression", + "PostgresAlterIndexActionType", + "PostgresReindexExpression", +] + + +class PostgresAlterIndexActionType(Enum): + """Action types for ALTER INDEX statement.""" + + RENAME_TO = "RENAME TO" + SET_TABLESPACE = "SET TABLESPACE" + SET_STORAGE_PARAMETERS = "SET" + RESET_STORAGE_PARAMETERS = "RESET" + ALTER_COLUMN_STATISTICS = "ALTER COLUMN SET STATISTICS" + ALL_IN_TABLESPACE = "ALL IN TABLESPACE" + + +class PostgresAlterIndexExpression(BaseExpression): + """PostgreSQL ALTER INDEX statement expression. + + Supports all ALTER INDEX operations: + - Rename index + - Move index to a different tablespace + - Set/reset storage parameters + - Alter per-column statistics target + - Bulk tablespace move with ALL IN TABLESPACE + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> # Rename index + >>> expr = PostgresAlterIndexExpression( + ... dialect, "idx_old", + ... action_type=PostgresAlterIndexActionType.RENAME_TO, + ... new_name="idx_new", + ... ) + >>> sql, params = expr.to_sql() + >>> sql + 'ALTER INDEX "idx_old" RENAME TO "idx_new"' + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + action_type: PostgresAlterIndexActionType, + *, + if_exists: bool = False, + new_name: Optional[str] = None, + tablespace: Optional[str] = None, + storage_parameters: Optional[Dict[str, Any]] = None, + column_number: Optional[int] = None, + statistics_target: Optional[int] = None, + source_tablespace: Optional[str] = None, + target_tablespace: Optional[str] = None, + nowait: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.index_name = index_name + self.action_type = action_type + self.if_exists = if_exists + self.new_name = new_name + self.tablespace = tablespace + self.storage_parameters = storage_parameters + self.column_number = column_number + self.statistics_target = statistics_target + self.source_tablespace = source_tablespace + self.target_tablespace = target_tablespace + self.nowait = nowait + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + return self.dialect.format_alter_index_statement(self) class PostgresReindexExpression(BaseExpression): @@ -65,6 +187,7 @@ class PostgresReindexExpression(BaseExpression): ... name="mydb", ... tablespace="fast_tablespace", ... ) + """ def __init__( @@ -93,5 +216,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ - return self.dialect.format_reindex_statement(self) \ No newline at end of file + return self.dialect.format_reindex_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py index 5a5f077c..489280c7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -64,6 +64,7 @@ class PostgresRefreshMaterializedViewExpression(RefreshMaterializedViewExpressio ... name="monthly_sales_summary", ... with_data=False, ... ) + """ def __init__( @@ -100,5 +101,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_refresh_materialized_view_pg_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index 1f72231b..8999a8b8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -14,6 +14,9 @@ - ATTACH PARTITION with CONCURRENTLY: PostgreSQL 14+ """ +from datetime import date, datetime +from decimal import Decimal +from math import isfinite from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -23,12 +26,74 @@ __all__ = [ + "PartitionValue", "PostgresCreatePartitionExpression", "PostgresDetachPartitionExpression", "PostgresAttachPartitionExpression", + "PostgresPartitionMetadataExpression", ] +class PartitionValue(BaseExpression): + """Represents a partition bound value in PostgreSQL DDL. + + Handles the formatting of partition boundary values used in + FOR VALUES clauses of PARTITION OF and ATTACH PARTITION statements. + + Value handling: + - None → NULL + - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is + - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal + - Arbitrary objects are rejected to prevent unsafe ``str(value)`` fallback + + Delegates to dialect.format_partition_value() for SQL generation. + + Attributes: + value: The partition bound value. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect() + >>> PartitionValue(dialect=dialect, value=None).to_sql() + ('NULL', ()) + >>> PartitionValue(dialect=dialect, value='MAXVALUE').to_sql() + ('MAXVALUE', ()) + >>> PartitionValue(dialect=dialect, value='2024-01-01').to_sql() + ("'2024-01-01'", ()) + >>> PartitionValue(dialect=dialect, value=42).to_sql() + ('42', ()) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + value: Any, + ): + super().__init__(dialect) + if isinstance(value, bool): + raise TypeError("partition value must not be bool") + if isinstance(value, float) and not isfinite(value): + raise ValueError("partition value float must be finite") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("partition value Decimal must be finite") + if not isinstance(value, (str, int, float, Decimal, date, datetime, type(None))): + raise TypeError( + "partition value must be str, int, float, Decimal, " + f"date, datetime, or None, got {type(value).__name__}" + ) + self.value = value + + def to_sql(self) -> Tuple[str, tuple]: + """Generate SQL for a partition bound value. + + Delegates to the dialect's format_partition_value() method. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + return self.dialect.format_partition_value(self) + + class PostgresCreatePartitionExpression(BaseExpression): """PostgreSQL CREATE TABLE ... PARTITION OF statement expression. @@ -68,6 +133,7 @@ class PostgresCreatePartitionExpression(BaseExpression): ... partition_type="LIST", ... partition_values={"values": ["active", "pending"]}, ... ) + """ def __init__( @@ -98,6 +164,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_create_partition_statement(self) @@ -127,6 +194,7 @@ class PostgresDetachPartitionExpression(BaseExpression): >>> sql, params = detach.to_sql() >>> sql "ALTER TABLE orders DETACH PARTITION orders_2023 CONCURRENTLY" + """ def __init__( @@ -153,6 +221,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_detach_partition_statement(self) @@ -161,6 +230,7 @@ class PostgresAttachPartitionExpression(BaseExpression): """PostgreSQL ALTER TABLE ... ATTACH PARTITION statement expression. Attaches an existing table as a partition of a partitioned table. + Supports CONCURRENTLY mode on PostgreSQL 14+. Attributes: partition_name: Name of the table to attach. @@ -168,6 +238,10 @@ class PostgresAttachPartitionExpression(BaseExpression): partition_type: Partition type: 'RANGE', 'LIST', or 'HASH'. partition_values: Partition bounds values. schema: Schema name for the partition. + concurrently: Use non-blocking ATTACH mode (PG 14+). + + Raises: + ValueError: If concurrent_attach is used on PostgreSQL < 14. Example: >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect @@ -181,7 +255,8 @@ class PostgresAttachPartitionExpression(BaseExpression): ... ) >>> sql, params = attach.to_sql() >>> sql - "ALTER TABLE orders ATTACH PARTITION orders_2024_q1 RANGE ('2024-01-01', '2024-04-01')" + "ALTER TABLE orders ATTACH PARTITION orders_2024_q1 FOR VALUES FROM ('2024-01-01') TO ('2024-04-01')" + """ def __init__( @@ -192,6 +267,7 @@ def __init__( partition_type: str, partition_values: Dict[str, Any], schema: Optional[str] = None, + concurrently: bool = False, *, dialect_options: Optional[Dict[str, Any]] = None, ): @@ -201,6 +277,7 @@ def __init__( self.partition_type = partition_type self.partition_values = partition_values self.schema = schema + self.concurrently = concurrently self.dialect_options = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: @@ -208,5 +285,40 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + + Raises: + ValueError: If concurrently is used on PostgreSQL < 14. + """ - return self.dialect.format_attach_partition_statement(self) \ No newline at end of file + return self.dialect.format_attach_partition_statement(self) + + +class PostgresPartitionMetadataExpression(BaseExpression): + """PostgreSQL partition metadata query expression. + + This expression builds the public metadata query used by tests and provider + code to inspect a partitioned parent table through PostgreSQL catalogs. It + keeps catalog SQL construction behind the Expression-Dialect protocol + boundary instead of embedding raw SQL in tests. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + parent_table: str, + schema: Optional[str] = None, + *, + include_partitions: bool = True, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + if not parent_table: + raise ValueError("parent_table must not be empty") + self.parent_table = parent_table + self.schema = schema + self.include_partitions = include_partitions + self.dialect_options = dialect_options or {} + + def to_sql(self) -> Tuple[str, tuple]: + """Generate PostgreSQL partition metadata query SQL.""" + return self.dialect.format_partition_metadata_query(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py new file mode 100644 index 00000000..0e08ae26 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py @@ -0,0 +1,211 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py +"""PostgreSQL pg_partman extension expressions. + +pg_partman is a PostgreSQL extension for automated partition management. +This module provides expression classes that encapsulate pg_partman function +calls and config operations as BaseExpression instances. + +Reference: + https://github.com/pgpartman/pg_partman +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresPgPartmanCreateParentExpression", + "PostgresPgPartmanRunMaintenanceExpression", + "PostgresPgPartmanUpdateConfigExpression", + "PostgresPgPartmanDeleteConfigExpression", +] + + +class PostgresPgPartmanCreateParentExpression(BaseExpression): + """Expression for pg_partman create_parent(). + + Initializes automated partitioning for a parent table. pg_partman will + automatically create and manage child partitions based on the configured + interval and premake count. + + Attributes: + parent_table: Fully qualified parent table name (e.g. 'public.events'). + control: Column name used as the partition control field. + interval: Partition interval (e.g. '1 day', '1 month', '1 year'). + partition_type: Partition method ('native' or 'range'). Defaults to 'native'. + premake: Number of future partitions to pre-create. Defaults to 4. + start_partition: Optional starting partition value (e.g. '2026-01-01'). + primary_key: Optional primary key override for the parent table. + default_table: Whether to create a default table for non-matching rows. + constraint_cols: Optional list of columns for partition constraint. + template_table: Optional template table for new partition structure. + epoch: Optional epoch setting for time-based partitioning ('none', 'seconds', 'milliseconds'). + jobmon: Whether to use pg_jobmon for logging. Defaults to True. + schema: Schema where pg_partman functions reside. Defaults to 'partman'. + + Delegates to dialect.format_pg_partman_create_parent() for SQL generation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + parent_table: str, + control: str, + interval: str, + partition_type: str = "native", + premake: int = 4, + start_partition: Optional[str] = None, + primary_key: Optional[str] = None, + default_table: Optional[bool] = None, + constraint_cols: Optional[list] = None, + template_table: Optional[str] = None, + epoch: Optional[str] = None, + jobmon: Optional[bool] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.parent_table = parent_table + self.control = control + self.interval = interval + self.partition_type = partition_type + self.premake = premake + self.start_partition = start_partition + self.primary_key = primary_key + self.default_table = default_table + self.constraint_cols = constraint_cols + self.template_table = template_table + self.epoch = epoch + self.jobmon = jobmon + self.schema = schema + + def to_sql(self) -> Tuple[str, tuple]: + """Generate SQL for pg_partman create_parent(). + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + return self.dialect.format_pg_partman_create_parent(self) + + +class PostgresPgPartmanRunMaintenanceExpression(BaseExpression): + """Expression for pg_partman run_maintenance(). + + Triggers pg_partman maintenance which creates new partitions and detaches + old ones according to each table's configuration. Can be scoped to a + specific parent table or run globally. + + Attributes: + parent_table: Optional parent table to scope maintenance to. + schema: Schema where pg_partman functions reside. Defaults to 'partman'. + + Delegates to dialect.format_pg_partman_run_maintenance() for SQL generation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + parent_table: Optional[str] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.parent_table = parent_table + self.schema = schema + + def to_sql(self) -> Tuple[str, tuple]: + """Generate SQL for pg_partman run_maintenance(). + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + return self.dialect.format_pg_partman_run_maintenance(self) + + +class PostgresPgPartmanUpdateConfigExpression(BaseExpression): + """Expression for updating pg_partman part_config options. + + Updates the pg_partman configuration for a specific parent table. + Only the specified options are modified; unspecified options remain + unchanged. At least one option must be provided. + + Attributes: + parent_table: Parent table whose config to update. + automatic_maintenance: 'on' or 'off' for auto maintenance. + infinite_time_partitions: Allow unlimited future partition creation. + retention: Retention period (e.g. '3 months', '90 days'). + retention_keep_table: Keep table data after retention drop. + retention_keep_index: Keep indexes after retention drop. + schema: Schema where part_config table resides. Defaults to 'partman'. + + Raises: + ValueError: If no config options are specified. + + Delegates to dialect.format_pg_partman_update_config() for SQL generation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + parent_table: str, + *, + automatic_maintenance: Optional[str] = None, + infinite_time_partitions: Optional[bool] = None, + retention: Optional[str] = None, + retention_keep_table: Optional[bool] = None, + retention_keep_index: Optional[bool] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.parent_table = parent_table + self.automatic_maintenance = automatic_maintenance + self.infinite_time_partitions = infinite_time_partitions + self.retention = retention + self.retention_keep_table = retention_keep_table + self.retention_keep_index = retention_keep_index + self.schema = schema + + def to_sql(self) -> Tuple[str, tuple]: + """Generate SQL for updating pg_partman part_config. + + Returns: + Tuple of (UPDATE SQL, params tuple). + + Raises: + ValueError: If no config options are specified. + """ + return self.dialect.format_pg_partman_update_config(self) + + +class PostgresPgPartmanDeleteConfigExpression(BaseExpression): + """Expression for deleting a pg_partman part_config row. + + Removes the pg_partman configuration entry for a specific parent table, + stopping automated partition management for that table. + + Attributes: + parent_table: Parent table whose config to delete. + schema: Schema where part_config table resides. Defaults to 'partman'. + + Delegates to dialect.format_pg_partman_delete_config() for SQL generation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + parent_table: str, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.parent_table = parent_table + self.schema = schema + + def to_sql(self) -> Tuple[str, tuple]: + """Generate SQL for deleting a pg_partman part_config row. + + Returns: + Tuple of (DELETE SQL, params tuple). + """ + return self.dialect.format_pg_partman_delete_config(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py index 139884a5..9c625ad4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py @@ -61,6 +61,7 @@ class PostgresCreateStatisticsExpression(BaseExpression): ... table_name="orders", ... statistics_type="ndistinct, mcv", ... ) + """ def __init__( @@ -89,6 +90,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_create_statistics_statement(self) @@ -113,6 +115,7 @@ class PostgresDropStatisticsExpression(BaseExpression): >>> sql, params = drop.to_sql() >>> sql "DROP STATISTICS users_status_expr" + """ def __init__( @@ -135,5 +138,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_drop_statistics_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py index 10ad0c10..1bf99059 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -71,6 +71,7 @@ class PostgresCreateEnumTypeExpression(BaseExpression): ... schema="app", ... if_not_exists=True, ... ) + """ def __init__( @@ -95,6 +96,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_create_enum_type(self) @@ -129,6 +131,7 @@ class PostgresDropEnumTypeExpression(BaseExpression): ... if_exists=True, ... cascade=True, ... ) + """ def __init__( @@ -153,6 +156,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_drop_enum_type(self) @@ -189,6 +193,7 @@ class PostgresAlterEnumAddValueExpression(BaseExpression): ... new_value="refunded", ... after="cancelled", ... ) + """ def __init__( @@ -215,6 +220,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_alter_enum_add_value(self) @@ -233,6 +239,7 @@ class PostgresAlterEnumTypeAddValueExpression(BaseExpression): Raises: ValueError: If type_name or new_value is empty, or both before and after specified. + """ def __init__( @@ -263,6 +270,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_alter_enum_type_add_value(self) @@ -280,6 +288,7 @@ class PostgresAlterEnumTypeRenameValueExpression(BaseExpression): Raises: ValueError: If type_name, old_value, or new_value is empty. + """ def __init__( @@ -308,6 +317,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_alter_enum_type_rename_value(self) @@ -350,6 +360,7 @@ class PostgresCreateRangeTypeExpression(BaseExpression): ... subtype="date", ... collation="en_US", ... ) + """ def __init__( @@ -384,5 +395,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_create_range_type(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py index f56a2514..18062612 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py @@ -65,6 +65,7 @@ class PostgresVacuumExpression(BaseExpression): ... full=True, ... parallel=4, ... ) + """ def __init__( @@ -105,6 +106,7 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_vacuum_statement(self) @@ -140,6 +142,7 @@ class PostgresAnalyzeExpression(BaseExpression): ... table_name="orders", ... columns=["status", "created_at"], ... ) + """ def __init__( @@ -166,5 +169,6 @@ def to_sql(self) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple). + """ return self.dialect.format_analyze_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py index 36bd9388..e135f41b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py @@ -13,16 +13,16 @@ - FOR NO KEY UPDATE: PostgreSQL 9.0+ - FOR SHARE: PostgreSQL 9.0+ - FOR KEY SHARE: PostgreSQL 9.3+ -""" -from enum import Enum -from typing import Any, Dict, List, Optional, Union, TYPE_CHECKING +Use the generic ``ForUpdateClause`` with ``dialect_options={"lock_strength": ...}`` +to specify PostgreSQL lock strengths: -from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause -from rhosocial.activerecord.backend.expression import bases + >>> from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + >>> from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + >>> clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) +""" -if TYPE_CHECKING: - from rhosocial.activerecord.backend.dialect import SQLDialectBase +from enum import Enum class LockStrength(Enum): @@ -49,81 +49,3 @@ class LockStrength(Enum): NO_KEY_UPDATE = "FOR NO KEY UPDATE" # Weaker exclusive lock SHARE = "FOR SHARE" # Shared lock KEY_SHARE = "FOR KEY SHARE" # Weakest shared lock - - -class PostgresForUpdateClause(ForUpdateClause): - """ - PostgreSQL-specific FOR UPDATE clause with lock strength support. - - Extends the standard ForUpdateClause with PostgreSQL's advanced - row-level locking capabilities. - - PostgreSQL supports additional lock strengths beyond FOR UPDATE: - - FOR NO KEY UPDATE: Weaker exclusive lock that allows FOR KEY SHARE - - FOR SHARE: Shared lock that allows other readers - - FOR KEY SHARE: Weakest shared lock - - Example Usage: - # Basic FOR UPDATE (same as parent class) - for_update = PostgresForUpdateClause(dialect) - - # FOR SHARE (PostgreSQL 9.0+) - for_update = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE) - - # FOR NO KEY UPDATE (PostgreSQL 9.0+) - for_update = PostgresForUpdateClause(dialect, strength=LockStrength.NO_KEY_UPDATE) - - # FOR KEY SHARE (PostgreSQL 9.3+) - for_update = PostgresForUpdateClause(dialect, strength=LockStrength.KEY_SHARE) - - # FOR SHARE with NOWAIT - for_update = PostgresForUpdateClause( - dialect, - strength=LockStrength.SHARE, - nowait=True - ) - """ - - def __init__( - self, - dialect: "SQLDialectBase", - strength: Optional[LockStrength] = None, - of_columns: Optional[List[Union[str, "bases.BaseExpression"]]] = None, - nowait: bool = False, - skip_locked: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, - ): - """ - Initialize PostgreSQL FOR UPDATE clause. - - Args: - dialect: The SQL dialect to use for formatting - strength: Lock strength (defaults to UPDATE for backward compatibility) - of_columns: Columns to apply the lock to - nowait: If True, fail immediately if rows are locked - skip_locked: If True, skip locked rows instead of waiting - dialect_options: Additional dialect-specific options - """ - super().__init__( - dialect, - of_columns=of_columns, - nowait=nowait, - skip_locked=skip_locked, - dialect_options=dialect_options, - ) - # Default to UPDATE for backward compatibility - self.strength = strength if strength is not None else LockStrength.UPDATE - - def to_sql(self) -> "bases.SQLQueryAndParams": - """ - Generate the SQL representation of the PostgreSQL FOR UPDATE clause. - - Delegates to the dialect's format_for_update_clause method - to follow the Expression-Dialect separation pattern. - - Returns: - Tuple containing: - - SQL string fragment for the FOR UPDATE clause - - Tuple of parameter values for prepared statements - """ - return self.dialect.format_for_update_clause(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py b/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py index 8a98b626..fdcb2cae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/function_versions.py @@ -67,6 +67,15 @@ def is_extension_function(self) -> bool: # ── Core PG built-in functions ──────────────────────────────── +JSON_EXPRESSION_FUNCTION_VERSIONS: Dict[str, FunctionVersionRequirement] = { + "json_extract": FunctionVersionRequirement(min_pg_version=(12, 0, 0)), + "json_extract_text": FunctionVersionRequirement(min_pg_version=(12, 0, 0)), + "json_build_object": FunctionVersionRequirement(max_pg_version=(0, 0, 0)), + "json_array_elements": FunctionVersionRequirement(), + "json_objectagg": FunctionVersionRequirement(max_pg_version=(0, 0, 0)), + "json_arrayagg": FunctionVersionRequirement(max_pg_version=(0, 0, 0)), +} + JSON_PATH_FUNCTION_VERSIONS: Dict[str, FunctionVersionRequirement] = { "jsonb_path_query": FunctionVersionRequirement(min_pg_version=(12, 0, 0)), "jsonb_path_query_first": FunctionVersionRequirement(min_pg_version=(12, 0, 0)), @@ -162,7 +171,6 @@ def is_extension_function(self) -> bool: } XML_FUNCTION_VERSIONS: Dict[str, FunctionVersionRequirement] = { - "xmlparse": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xpath_query": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xpath_exists": FunctionVersionRequirement(min_pg_version=(8, 3, 0)), "xml_is_well_formed": FunctionVersionRequirement(min_pg_version=(9, 1, 0)), @@ -335,6 +343,7 @@ def is_extension_function(self) -> bool: # ── Assemble ────────────────────────────────────────────────── _ALL_CATEGORIES = [ + JSON_EXPRESSION_FUNCTION_VERSIONS, JSON_PATH_FUNCTION_VERSIONS, RANGE_FUNCTION_VERSIONS, GEOMETRIC_FUNCTION_VERSIONS, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index f9660e95..da294485 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -162,7 +162,6 @@ # XML functions from .xml import ( - xmlparse, xpath_query, xpath_exists, xml_is_well_formed, @@ -601,7 +600,6 @@ "tsvector_setweight", "tsvector_length", # XML functions - "xmlparse", "xpath_query", "xpath_exists", "xml_is_well_formed", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py index f7abfe8c..4810f5a6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py @@ -2,38 +2,16 @@ """ PostgreSQL XML function factories. -This module provides SQL expression generators for PostgreSQL XML -functions. All functions return FunctionCall expression objects that -integrate with the expression-dialect architecture. +This module provides SQL expression generators for PostgreSQL-specific XML +functions. SQL/XML expression constructors such as XMLPARSE live in +rhosocial.activerecord.backend.expression.functions.xml. PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-xml.html Supported functions: -- xmlparse() - Parse XML content - xpath_query() - Execute XPath query on XML - xpath_exists() - Test if XPath expression matches - xml_is_well_formed() - Check if XML is well-formed - -Examples: - Parse XML document: - >>> func = xmlparse(dialect, 'value') - >>> func.to_sql() - ('XMLPARSE(DOCUMENT, %s, PRESERVE WHITESPACE)', ('value',)) - - Query XML with XPath: - >>> func = xpath_query(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath(%s, xml_column)', ('/root/item',)) - - Test if XPath exists: - >>> func = xpath_exists(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath_exists(%s, xml_column)', ('/root/item',)) - - Check if well-formed: - >>> func = xml_is_well_formed(dialect, '') - >>> func.to_sql() - ('xml_is_well_formed(%s)', ('',)) """ from typing import Dict, Optional, Union, TYPE_CHECKING @@ -43,7 +21,6 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -# Import type for annotations from ..types.xml import PostgresXML @@ -54,67 +31,17 @@ def _convert_to_expression( """Convert an input value to an appropriate BaseExpression.""" if isinstance(expr, bases.BaseExpression): return expr - elif isinstance(expr, PostgresXML): + if isinstance(expr, PostgresXML): return core.Literal(dialect, expr.content) - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - -class _KeywordLiteral(bases.BaseExpression): - """Represents a SQL keyword that should be rendered as-is (unquoted, unparameterized). - - Used for SQL keywords that appear inside function call arguments, - such as DOCUMENT, CONTENT, PRESERVE WHITESPACE in XMLPARSE. - """ + return core.Literal(dialect, expr) - def __init__(self, dialect: "SQLDialectBase", keyword: str): - super().__init__(dialect) - self.keyword = keyword - def to_sql(self) -> "bases.SQLQueryAndParams": - return self.keyword, () - - -def xmlparse( +def _namespaces_to_expression( dialect: "SQLDialectBase", - content: Union[PostgresXML, str, "bases.BaseExpression"], - document: bool = True, - preserve_whitespace: bool = False, -) -> core.FunctionCall: - """Generate XMLPARSE expression. - - XMLPARSE parses XML content from a string. It produces either - a DOCUMENT or CONTENT value, optionally preserving whitespace. - - Args: - dialect: The SQL dialect instance - content: XML content string, PostgresXML instance, or expression - document: Parse as DOCUMENT (default) or CONTENT - preserve_whitespace: Preserve whitespace (default False) - - Returns: - FunctionCall: SQL expression for XMLPARSE - - Example: - >>> func = xmlparse(dialect, 'value') - >>> func.to_sql() - ('XMLPARSE(DOCUMENT, %s)', ('value',)) - - >>> func = xmlparse(dialect, '', document=False, preserve_whitespace=True) - >>> func.to_sql() - ('XMLPARSE(CONTENT, %s, PRESERVE WHITESPACE)', ('',)) - """ - doc_type = "DOCUMENT" if document else "CONTENT" - content_expr = _convert_to_expression(dialect, content) - - args = [_KeywordLiteral(dialect, doc_type), content_expr] - - if preserve_whitespace: - args.append(_KeywordLiteral(dialect, "PRESERVE WHITESPACE")) - - return core.FunctionCall(dialect, "XMLPARSE", *args) + namespaces: Dict[str, str], +) -> "bases.BaseExpression": + namespace_pairs = [[prefix, uri] for prefix, uri in namespaces.items()] + return core.Literal(dialect, namespace_pairs) def xpath_query( @@ -123,36 +50,13 @@ def xpath_query( xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: - """Generate XPath query expression. - - The xpath function evaluates the XPath expression against the XML value, - optionally using namespace mappings. - - Args: - dialect: The SQL dialect instance - xpath: XPath expression string or expression - xml_value: XML value, PostgresXML instance, or column expression - namespaces: Optional namespace mappings (dict of prefix -> URI) - - Returns: - FunctionCall: SQL expression for xpath function - - Example: - >>> func = xpath_query(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath(%s, xml_column)', ('/root/item',)) - """ + """Generate PostgreSQL xpath expression.""" xpath_expr = _convert_to_expression(dialect, xpath) xml_expr = _convert_to_expression(dialect, xml_value) args = [xpath_expr, xml_expr] - if namespaces: - ns_parts = [] - for prefix, uri in namespaces.items(): - ns_parts.append(f"{prefix}={uri}") - ns_literal = core.Literal(dialect, ",".join(ns_parts)) - args.append(ns_literal) + args.append(_namespaces_to_expression(dialect, namespaces)) return core.FunctionCall(dialect, "xpath", *args) @@ -163,36 +67,13 @@ def xpath_exists( xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: - """Generate xpath_exists expression. - - The xpath_exists function checks whether the XPath expression - matches any nodes in the XML value. - - Args: - dialect: The SQL dialect instance - xpath: XPath expression string or expression - xml_value: XML value, PostgresXML instance, or column expression - namespaces: Optional namespace mappings (dict of prefix -> URI) - - Returns: - FunctionCall: SQL expression for xpath_exists function - - Example: - >>> func = xpath_exists(dialect, "/root/item", "xml_column") - >>> func.to_sql() - ('xpath_exists(%s, xml_column)', ('/root/item',)) - """ + """Generate PostgreSQL xpath_exists expression.""" xpath_expr = _convert_to_expression(dialect, xpath) xml_expr = _convert_to_expression(dialect, xml_value) args = [xpath_expr, xml_expr] - if namespaces: - ns_parts = [] - for prefix, uri in namespaces.items(): - ns_parts.append(f"{prefix}={uri}") - ns_literal = core.Literal(dialect, ",".join(ns_parts)) - args.append(ns_literal) + args.append(_namespaces_to_expression(dialect, namespaces)) return core.FunctionCall(dialect, "xpath_exists", *args) @@ -201,28 +82,12 @@ def xml_is_well_formed( dialect: "SQLDialectBase", content: Union[PostgresXML, str, "bases.BaseExpression"], ) -> core.FunctionCall: - """Generate xml_is_well_formed expression (PostgreSQL 9.1+). - - Checks whether the input text is well-formed XML. - - Args: - dialect: The SQL dialect instance - content: XML content string, PostgresXML instance, or expression - - Returns: - FunctionCall: SQL expression for xml_is_well_formed function - - Example: - >>> func = xml_is_well_formed(dialect, '') - >>> func.to_sql() - ('xml_is_well_formed(%s)', ('',)) - """ + """Generate PostgreSQL xml_is_well_formed expression.""" content_expr = _convert_to_expression(dialect, content) return core.FunctionCall(dialect, "xml_is_well_formed", content_expr) __all__ = [ - "xmlparse", "xpath_query", "xpath_exists", "xml_is_well_formed", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 971b5c58..232e1805 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -73,9 +73,32 @@ # Additional mixins from .query_optimization import PostgresQueryOptimizationMixin from .data_type import PostgresDataTypeMixin -from .sql_syntax import PostgresSQLSyntaxMixin from .logical_replication import PostgresLogicalReplicationMixin from .parallel_query import PostgresParallelQueryMixin +from .property_graph_query import PostgresPropertyGraphQueryMixin + +# Per-feature mixin imports +from .cte import PostgresCTEMixin +from .window import PostgresWindowMixin +from .filter_clause import PostgresFilterMixin +from .returning import PostgresReturningMixin +from .grouping import PostgresGroupingMixin +from .explain import PostgresExplainMixin +from .merge import PostgresMergeMixin +from .upsert import PostgresUpsertMixin +from .lateral_join import PostgresLateralJoinMixin +from .set_operation import PostgresSetOperationMixin +from .ilike import PostgresILIKEMixin +from .join import PostgresJoinMixin +from .truncate import PostgresTruncateMixin +from .schema import PostgresSchemaMixin +from .sequence import PostgresSequenceMixin +from .transaction import PostgresTransactionMixin +from .view import PostgresViewMixin +from .xml import PostgresXMLMixin +from .collation import PostgresCollationMixin +from .ordered_set_aggregation import PostgresOrderedSetAggMixin +from .features import PostgresFeaturesMixin # Introspection capability mixin from .introspection import PostgresIntrospectionCapabilityMixin @@ -139,9 +162,31 @@ # Additional mixins "PostgresQueryOptimizationMixin", "PostgresDataTypeMixin", - "PostgresSQLSyntaxMixin", "PostgresLogicalReplicationMixin", "PostgresParallelQueryMixin", + "PostgresPropertyGraphQueryMixin", + # Per-feature mixins + "PostgresCTEMixin", + "PostgresWindowMixin", + "PostgresFilterMixin", + "PostgresReturningMixin", + "PostgresGroupingMixin", + "PostgresExplainMixin", + "PostgresMergeMixin", + "PostgresUpsertMixin", + "PostgresLateralJoinMixin", + "PostgresSetOperationMixin", + "PostgresILIKEMixin", + "PostgresJoinMixin", + "PostgresTruncateMixin", + "PostgresSchemaMixin", + "PostgresSequenceMixin", + "PostgresTransactionMixin", + "PostgresViewMixin", + "PostgresXMLMixin", + "PostgresCollationMixin", + "PostgresOrderedSetAggMixin", + "PostgresFeaturesMixin", # Introspection capability mixin "PostgresIntrospectionCapabilityMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py new file mode 100644 index 00000000..0ce2a6b2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py +"""PostgreSQL collation feature support implementation.""" + + +class PostgresCollationMixin: + """PostgreSQL collation override implementation. + + All features are native, using version number for detection. + """ + + def supports_collate_expression(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/cte.py new file mode 100644 index 00000000..347fc658 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/cte.py @@ -0,0 +1,18 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/cte.py +"""PostgreSQL cte feature support implementation.""" + + +class PostgresCTEMixin: + """PostgreSQL cte override implementation. + + All features are native, using version number for detection. + """ + + def supports_basic_cte(self) -> bool: + return True + + def supports_recursive_cte(self) -> bool: + return True + + def supports_materialized_cte(self) -> bool: + return self.version >= (12, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index 97ff0d2d..2e4d1282 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -9,10 +9,14 @@ from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol -if TYPE_CHECKING: - from ...expression.ddl import PostgresReindexExpression +if TYPE_CHECKING: # pragma: no cover + from ...expression.ddl import ( + PostgresAlterIndexExpression, + PostgresReindexExpression, + ) from rhosocial.activerecord.backend.expression.statements.ddl_index import ( CreateIndexExpression, + DropIndexExpression, ) @@ -66,6 +70,7 @@ def supports_create_statistics(self) -> bool: Returns: True if CREATE STATISTICS is supported + """ return self.version >= (10, 0, 0) @@ -77,6 +82,7 @@ def supports_statistics_mcv(self) -> bool: Returns: True if MCV statistics are supported + """ return self.version >= (12, 0, 0) @@ -88,6 +94,7 @@ def supports_statistics_dependencies(self) -> bool: Returns: True if statistics dependencies are supported + """ return self.version >= (10, 0, 0) @@ -98,6 +105,7 @@ def supports_statistics_ndistinct(self) -> bool: Returns: True if ndistinct statistics are supported + """ return self.version >= (10, 0, 0) @@ -110,6 +118,7 @@ def supports_concurrent_unique_nulls_not_distinct(self) -> bool: Returns: True if supported + """ return self.version >= (16, 0, 0) @@ -124,9 +133,113 @@ def supports_streaming_btree_index_build(self) -> bool: Returns: True if supported + """ return self.version >= (18, 0, 0) + # ========================================================================= + # IndexSupport Protocol Overrides (all supported by PostgreSQL) + # ========================================================================= + + def supports_create_index(self) -> bool: + """CREATE INDEX is supported in all versions.""" + return True + + def supports_drop_index(self) -> bool: + """DROP INDEX is supported in all versions.""" + return True + + def supports_unique_index(self) -> bool: + """UNIQUE indexes are supported in all versions.""" + return True + + def supports_index_if_not_exists(self) -> bool: + """IF NOT EXISTS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_index_if_exists(self) -> bool: + """DROP INDEX IF EXISTS is supported in all versions.""" + return True + + def supports_index_type(self) -> bool: + """PostgreSQL supports USING BTREE | HASH | GIST | GIN | SPGIST | BRIN.""" + return True + + def supports_partial_index(self) -> bool: + """PostgreSQL supports partial indexes with WHERE clause.""" + return True + + def supports_functional_index(self) -> bool: + """PostgreSQL supports expression/functional indexes.""" + return True + + def supports_index_include(self) -> bool: + """PostgreSQL supports INCLUDE clause (PG 11+ for btree, PG 12+ for GiST, PG 14+ for SP-GiST).""" + return self.version >= (11, 0, 0) + + def supports_index_tablespace(self) -> bool: + """PostgreSQL supports TABLESPACE for indexes.""" + return True + + def supports_concurrent_index(self) -> bool: + """CREATE INDEX CONCURRENTLY is supported since PostgreSQL 11. + + Note: CONCURRENTLY-only features may have higher version requirements + (e.g., CONCURRENTLY + NULLS NOT DISTINCT requires PG 16+). + """ + return self.version >= (11, 0, 0) + + def get_supported_index_types(self) -> List[str]: + """Return all index access methods supported by PostgreSQL.""" + return ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] + + # ========================================================================= + # Fulltext methods — PostgreSQL does NOT use MySQL-style FULLTEXT indexes + # ========================================================================= + + def supports_fulltext_index(self) -> bool: + """PostgreSQL uses GIN index + tsvector/tsquery, not MySQL-style FULLTEXT.""" + return False + + def supports_fulltext_parser(self) -> bool: + """PostgreSQL does not have MySQL-style FULLTEXT parsers.""" + return False + + def supports_fulltext_boolean_mode(self) -> bool: + """PostgreSQL does not support MySQL-style FULLTEXT boolean mode.""" + return False + + def supports_fulltext_query_expansion(self) -> bool: + """PostgreSQL does not support MySQL-style FULLTEXT query expansion.""" + return False + + def format_fulltext_match(self, columns, search_term, mode=None) -> Tuple[str, Tuple]: + """Not supported — PostgreSQL uses tsvector @@ tsquery instead.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "FULLTEXT MATCH", + suggestion="Use tsvector @@ tsquery for full-text search.", + ) + + def format_create_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: + """Not supported — use CREATE INDEX with GIN and tsvector expression.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "CREATE FULLTEXT INDEX", + suggestion="Use CREATE INDEX ... ON ... USING GIN (to_tsvector(...))", + ) + + def format_drop_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: + """Not supported — use DROP INDEX instead.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + "PostgreSQL", + "DROP FULLTEXT INDEX", + suggestion="Use DROP INDEX to remove a GIN index.", + ) + def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: """Format CREATE INDEX statement with PostgreSQL-specific options. @@ -134,6 +247,8 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ - Operator classes via dialect_options["opclasses"] - CONCURRENTLY clause - INCLUDE clause with version checks + - NULLS NOT DISTINCT via dialect_options["nulls_not_distinct"] + - WITH storage parameters via dialect_options["with"] """ all_params = [] parts = ["CREATE"] @@ -185,6 +300,18 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ include_cols = ", ".join(self.format_identifier(c) for c in expr.include) parts.append(f"INCLUDE ({include_cols})") + # NULLS NOT DISTINCT (PG 15+, requires UNIQUE) + if expr.dialect_options.get("nulls_not_distinct"): + if not expr.unique: + raise ValueError("NULLS NOT DISTINCT is only valid for UNIQUE indexes") + if expr.concurrent and not self.supports_concurrent_unique_nulls_not_distinct(): + raise ValueError( + "CONCURRENTLY + NULLS NOT DISTINCT requires PostgreSQL 16+" + ) + if not self.supports_nulls_not_distinct_unique(): + raise ValueError("NULLS NOT DISTINCT requires PostgreSQL 15+") + parts.append("NULLS NOT DISTINCT") + # WITH options via dialect_options with_options = expr.dialect_options.get("with") if with_options: @@ -201,8 +328,139 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ return " ".join(parts), tuple(all_params) + def format_drop_index_statement(self, expr: "DropIndexExpression") -> Tuple[str, tuple]: + """Format DROP INDEX statement with PostgreSQL-specific options. + + Extends the generic format_drop_index_statement to support: + - CONCURRENTLY clause (PG 18+) via dialect_options["concurrent"] + """ + parts = ["DROP INDEX"] + + if expr.dialect_options.get("concurrent"): + if not self.supports_streaming_btree_index_build(): + raise ValueError("DROP INDEX CONCURRENTLY requires PostgreSQL 18+") + parts.append("CONCURRENTLY") + + if expr.if_exists: + parts.append("IF EXISTS") + + parts.append(self.format_identifier(expr.index_name)) + + # table_name is ignored for PostgreSQL (unlike MySQL) + return " ".join(parts), () + + def format_alter_index_statement( + self, expr: "PostgresAlterIndexExpression" + ) -> Tuple[str, tuple]: + """Format ALTER INDEX statement with PostgreSQL-specific operations. + + Supports the following action types: + + - ``RENAME TO`` — rename an index; requires ``expr.new_name``. + - ``SET TABLESPACE`` — move index to a different tablespace; requires ``expr.tablespace``. + - ``SET ( storage_parameters )`` — set index storage parameters; requires ``expr.storage_parameters`` dict. + - ``RESET ( storage_parameters )`` — reset index storage parameters; requires ``expr.storage_parameters`` dict. + - ``ALTER COLUMN SET STATISTICS`` — set per-column statistics target; + requires ``expr.column_number`` and ``expr.statistics_target``. + - ``ALL IN TABLESPACE … SET TABLESPACE … NOWAIT`` — move all indexes + in a tablespace; requires ``expr.source_tablespace`` and + ``expr.target_tablespace``. + + Args: + expr: PostgresAlterIndexExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If required parameters for an action are missing or the action is unrecognised. + + """ + from ...expression.ddl import PostgresAlterIndexActionType + + parts = ["ALTER INDEX"] + action = expr.action_type + + # ALL IN TABLESPACE uses "ALL" instead of an index name + if action == PostgresAlterIndexActionType.ALL_IN_TABLESPACE: + if not expr.source_tablespace or not expr.target_tablespace: + raise ValueError( + "source_tablespace and target_tablespace are required " + "for ALL IN TABLESPACE action" + ) + parts.append("ALL IN TABLESPACE") + parts.append(self.format_identifier(expr.source_tablespace)) + parts.append("SET TABLESPACE") + parts.append(self.format_identifier(expr.target_tablespace)) + if expr.nowait: + parts.append("NOWAIT") + return " ".join(parts), () + + if expr.if_exists: + parts.append("IF EXISTS") + + parts.append(self.format_identifier(expr.index_name)) + + if action == PostgresAlterIndexActionType.RENAME_TO: + if not expr.new_name: + raise ValueError("new_name is required for RENAME TO action") + parts.append("RENAME TO") + parts.append(self.format_identifier(expr.new_name)) + + elif action == PostgresAlterIndexActionType.SET_TABLESPACE: + if not expr.tablespace: + raise ValueError("tablespace is required for SET TABLESPACE action") + parts.append("SET TABLESPACE") + parts.append(self.format_identifier(expr.tablespace)) + + elif action == PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS: + if not expr.storage_parameters: + raise ValueError("storage_parameters is required for SET action") + opts = ", ".join( + f"{k} = {v}" for k, v in expr.storage_parameters.items() + ) + parts.append(f"SET ({opts})") + + elif action == PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS: + if not expr.storage_parameters: + raise ValueError("storage_parameters is required for RESET action") + opts = ", ".join(expr.storage_parameters.keys()) + parts.append(f"RESET ({opts})") + + elif action == PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS: + if expr.column_number is None or expr.statistics_target is None: + raise ValueError( + "column_number and statistics_target are required " + "for ALTER COLUMN SET STATISTICS action" + ) + parts.append(f"ALTER COLUMN {expr.column_number}") + parts.append(f"SET STATISTICS {expr.statistics_target}") + + else: + raise ValueError(f"Unsupported ALTER INDEX action: {action}") + + return " ".join(parts), () + def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[str, tuple]: - """Format REINDEX statement with PostgreSQL-specific options.""" + """Format REINDEX statement with PostgreSQL-specific options. + + Supports target types: ``INDEX``, ``TABLE``, ``SCHEMA``, ``DATABASE``, ``SYSTEM``. + + - ``expr.concurrently`` — add ``CONCURRENTLY`` (PG 12+). + - ``expr.schema`` — schema qualifier for INDEX and TABLE targets. + - ``expr.tablespace`` — move index to a different tablespace (PG 14+). + - ``expr.verbose`` — add ``VERBOSE``. + + Args: + expr: PostgresReindexExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If target_type is invalid or CONCURRENTLY is used before PG 12. + + """ target_type = expr.target_type.upper() if target_type not in ("INDEX", "TABLE", "SCHEMA", "DATABASE", "SYSTEM"): raise ValueError(f"Invalid target_type: {target_type}") @@ -259,6 +517,7 @@ def format_create_index_pg_statement( or a ToSQLProtocol expression for parameterized queries. WARNING: When using string, never pass user input directly; always use ToSQLProtocol expressions for untrusted input. + """ all_params = [] parts = ["CREATE"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index 1f322e55..c20496f4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -6,13 +6,17 @@ partitioning with support for various PostgreSQL versions. """ -from typing import Any, Tuple, TYPE_CHECKING +from typing import Any, List, Tuple, TYPE_CHECKING # noqa: F401 + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: from ...expression.ddl import ( + PartitionValue, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, + PostgresPartitionMetadataExpression, ) @@ -22,36 +26,343 @@ class PostgresPartitionMixin: All features are native, using version number for detection. """ + def supports_table_partitioning(self) -> bool: + """Check if declarative table partitioning is supported. + + PostgreSQL 10+ supports declarative partitioning natively. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.version >= (10, 0, 0) + + def supports_partitioned_table_creation(self) -> bool: + """Check if CREATE TABLE can create partitioned parent tables. + + Alias for supports_table_partitioning(), PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_range_table_partitioning(self) -> bool: + """Check if RANGE table partitioning is supported. + + Native feature, PostgreSQL 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_list_table_partitioning(self) -> bool: + """Check if LIST table partitioning is supported. + + Native feature, PostgreSQL 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_hash_table_partitioning(self) -> bool: + """Check if HASH table partitioning is supported. + + Native feature, PostgreSQL 11+. + + Returns: + True if PostgreSQL version >= 11. + """ + return self.supports_hash_partitioning() + + def supports_subpartitioning(self) -> bool: + """Check if nested (sub)partitioning is exposed through this API. + + PostgreSQL supports sub-partitioning but this API does not expose it yet. + + Returns: + Always False. + """ + return False + + def supports_partition_metadata_introspection(self) -> bool: + """Check if partition metadata introspection is available. + + Uses pg_catalog views to introspect partition structure, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_add_partition(self) -> bool: + """Check if adding a partition is supported. + + Maps to CREATE TABLE ... PARTITION OF, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_drop_partition(self) -> bool: + """Check if dropping a partition is supported. + + Maps to DROP TABLE on the child partition table, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_truncate_partition(self) -> bool: + """Check if truncating a partition is supported. + + Maps to TRUNCATE TABLE on the child partition table, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_reorganize_partition(self) -> bool: + """Check if REORGANIZE PARTITION is supported. + + PostgreSQL does not support MySQL-style REORGANIZE PARTITION operation. + + Returns: + Always False. + """ + return False + + def supports_attach_partition(self) -> bool: + """Check if ATTACH PARTITION is supported. + + PostgreSQL supports ALTER TABLE ... ATTACH PARTITION, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def supports_detach_partition(self) -> bool: + """Check if DETACH PARTITION is supported. + + PostgreSQL supports ALTER TABLE ... DETACH PARTITION, PG 10+. + + Returns: + True if PostgreSQL version >= 10. + """ + return self.supports_table_partitioning() + + def format_partition_clause(self, expr) -> Tuple[str, tuple]: + """Format a PostgreSQL PARTITION BY clause from PartitionClause.""" + if not self.supports_partitioned_table_creation(): + raise UnsupportedFeatureError( + self.name, + "declarative table partitioning", + "Declarative table partitioning requires PostgreSQL 10+.", + ) + + method = expr.method.upper() + if method == "RANGE": + if not self.supports_range_table_partitioning(): + raise UnsupportedFeatureError(self.name, "RANGE partitioning") + elif method == "LIST": + if not self.supports_list_table_partitioning(): + raise UnsupportedFeatureError(self.name, "LIST partitioning") + elif method == "HASH": + if not self.supports_hash_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "HASH partitioning", + "HASH partitioning requires PostgreSQL 11+.", + ) + elif method in {"KEY", "RANGE COLUMNS", "LIST COLUMNS", "LINEAR HASH", "LINEAR KEY"}: + raise UnsupportedFeatureError( + self.name, + f"{method} partitioning", + "This is not a PostgreSQL partitioning method.", + ) + else: + raise ValueError(f"Invalid PostgreSQL partition method: {expr.method}") + + key_parts: List[str] = [] + params: List[Any] = [] + for key in expr.keys: + key_sql, key_params = key.to_sql() + key_parts.append(key_sql) + params.extend(key_params) + return f" PARTITION BY {method} ({', '.join(key_parts)})", tuple(params) + def supports_hash_partitioning(self) -> bool: - """HASH partitioning is native feature, PG 11+.""" + """Check if HASH partitioning is supported. + + Native feature, PostgreSQL 11+. Enables PARTITION BY HASH clause. + + Returns: + True if PostgreSQL version >= 11. + """ return self.version >= (11, 0, 0) def supports_default_partition(self) -> bool: - """DEFAULT partition is native feature, PG 11+.""" + """Check if DEFAULT partition is supported. + + Native feature, PostgreSQL 11+. Allows a DEFAULT partition to catch + rows that do not match any other partition. + + Returns: + True if PostgreSQL version >= 11. + """ return self.version >= (11, 0, 0) def supports_partition_key_update(self) -> bool: - """Partition key row movement is native feature, PG 11+.""" + """Check if partition key update row movement is supported. + + Native feature, PostgreSQL 11+. When a partition key is updated, + rows automatically move to the correct partition. + + Returns: + True if PostgreSQL version >= 11. + """ return self.version >= (11, 0, 0) def supports_concurrent_detach(self) -> bool: - """Concurrent DETACH is native feature, PG 14+.""" + """Check if CONCURRENTLY DETACH is supported. + + Native feature, PostgreSQL 14+. Enables non-blocking partition + detachment without exclusive locks. + + Returns: + True if PostgreSQL version >= 14. + """ + return self.version >= (14, 0, 0) + + def supports_concurrent_attach(self) -> bool: + """Check if CONCURRENTLY ATTACH is supported. + + Native feature, PostgreSQL 14+. Enables non-blocking partition + attachment without blocking concurrent queries. + + Returns: + True if PostgreSQL version >= 14. + """ return self.version >= (14, 0, 0) def supports_partition_bounds_expression(self) -> bool: - """Partition bounds expression is native feature, PG 12+.""" + """Check if partition bounds expressions are supported. + + Native feature, PostgreSQL 12+. Enables non-constant expressions + in partition bound specifications. + + Returns: + True if PostgreSQL version >= 12. + """ return self.version >= (12, 0, 0) def supports_partitionwise_join(self) -> bool: - """Partitionwise join is native feature, PG 11+.""" + """Check if partitionwise join optimization is supported. + + Native feature, PostgreSQL 11+. Enables join optimization for + partitioned tables. + + Returns: + True if PostgreSQL version >= 11. + """ return self.version >= (11, 0, 0) def supports_partitionwise_aggregate(self) -> bool: - """Partitionwise aggregate is native feature, PG 11+.""" + """Check if partitionwise aggregate optimization is supported. + + Native feature, PostgreSQL 11+. Enables aggregate optimization for + partitioned tables. + + Returns: + True if PostgreSQL version >= 11. + """ return self.version >= (11, 0, 0) + def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: + """Format a partition bound value from expression. + + Public method implementing the PartitionValue expression's + SQL generation, following the Expression-Dialect-Protocol pattern. + + Value handling: + - None → 'NULL' + - String 'MAXVALUE', 'MINVALUE', or 'DEFAULT' (case-insensitive) → as-is + - String/date/datetime/Decimal/numeric values → whitelist-formatted SQL literal + - Arbitrary objects are rejected by PartitionValue before formatting + + Args: + expr: PartitionValue with the bound value. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + from datetime import date, datetime + from decimal import Decimal + from math import isfinite + + value = expr.value + if value is None: + return "NULL", () + if isinstance(value, bool): + raise TypeError("partition value must not be bool") + if isinstance(value, str): + upper_val = value.upper() + if upper_val in {"MAXVALUE", "MINVALUE", "DEFAULT"}: + return upper_val, () + return f"'{value.replace(chr(39), chr(39)+chr(39))}'", () + if isinstance(value, int): + return str(value), () + if isinstance(value, float): + if not isfinite(value): + raise ValueError("partition value float must be finite") + return repr(value), () + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("partition value Decimal must be finite") + return str(value), () + if isinstance(value, datetime): + return f"'{value.isoformat(sep=' ')}'", () + if isinstance(value, date): + return f"'{value.isoformat()}'", () + raise TypeError( + "partition value must be str, int, float, Decimal, " + f"date, datetime, or None, got {type(value).__name__}" + ) + def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpression") -> Tuple[str, tuple]: - """Format CREATE TABLE ... PARTITION OF statement from expression.""" + """Format CREATE TABLE ... PARTITION OF statement from expression. + + Supported partition types are ``RANGE``, ``LIST``, and ``HASH``. + + - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). + - ``expr.if_not_exists`` — add ``IF NOT EXISTS``. + - ``expr.schema`` — optional schema qualifier. + - ``expr.partition_name`` — new partition name. + - ``expr.parent_table`` — parent partitioned table. + - ``expr.partition_values`` — bound values (see below). + - ``expr.tablespace`` — optional tablespace. + + ``partition_values`` dict format: + + - RANGE: ``{"from": ..., "to": ...}`` or ``{"default": True}``. + - LIST: ``{"values": [..., ...]}`` or ``{"default": True}``. + - HASH: ``{"modulus": N, "remainder": M}``. + + Args: + expr: PostgresCreatePartitionExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If partition_type is invalid or required bound values are missing. + + """ partition_type = expr.partition_type.upper() if partition_type not in ("RANGE", "LIST", "HASH"): raise ValueError(f"Invalid partition_type: {partition_type}") @@ -74,32 +385,37 @@ def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpres else: parts.append(f"PARTITION OF {self.format_identifier(expr.parent_table)}") - # FOR VALUES clause - parts.append("FOR VALUES") - if partition_type == "RANGE": if "default" in expr.partition_values and expr.partition_values["default"]: parts.append("DEFAULT") else: + parts.append("FOR VALUES") from_val = expr.partition_values.get("from") to_val = expr.partition_values.get("to") if from_val is None or to_val is None: raise ValueError("RANGE partition requires 'from' and 'to' values") - parts.append( - f"FROM ({self._format_partition_value(from_val)}) TO ({self._format_partition_value(to_val)})" - ) + from ...expression.ddl import PartitionValue + from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() + to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() + parts.append(f"FROM ({from_sql}) TO ({to_sql})") elif partition_type == "LIST": if "default" in expr.partition_values and expr.partition_values["default"]: parts.append("DEFAULT") else: + parts.append("FOR VALUES") values = expr.partition_values.get("values", []) if not values: raise ValueError("LIST partition requires 'values' list") - vals_str = ", ".join(self._format_partition_value(v) for v in values) + from ...expression.ddl import PartitionValue + vals_str = ", ".join( + PartitionValue(dialect=self, value=v).to_sql()[0] + for v in values + ) parts.append(f"IN ({vals_str})") elif partition_type == "HASH": + parts.append("FOR VALUES") modulus = expr.partition_values.get("modulus") remainder = expr.partition_values.get("remainder") if modulus is None or remainder is None: @@ -114,34 +430,22 @@ def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpres return (" ".join(parts), ()) - def _format_partition_value(self, value: Any) -> str: - """Format a partition bound value. + def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpression") -> Tuple[str, tuple]: + """Format ALTER TABLE ... DETACH PARTITION statement from expression. + + - ``expr.parent_table`` — partitioned table name. + - ``expr.schema`` — optional schema qualifier. + - ``expr.partition_name`` — partition to detach. + - ``expr.concurrently`` — add ``DETACH CONCURRENTLY`` (PG 14+). + - ``expr.finalize`` — add ``FINALIZE`` (valid only with CONCURRENTLY). Args: - value: The partition bound value. Can be: - - None: Returns 'NULL' - - String 'MAXVALUE' or 'MINVALUE' (case-insensitive): Returns as-is - - String: Returns the string value quoted - - Other types: Returns str(value) + expr: PostgresDetachPartitionExpression instance - Note: - For string values that represent dates or other literals, - pass the raw value without quotes. The method will add quotes. - Example: Pass '2024-01-01' not "'2024-01-01'" - """ - if value is None: - return "NULL" - elif isinstance(value, str): - upper_val = value.upper() - if upper_val == "MAXVALUE" or upper_val == "MINVALUE": - return upper_val - # Add quotes around string values, escaping internal single quotes - return f"'{value.replace(chr(39), chr(39)+chr(39))}'" # escape ' to '' - else: - return str(value) + Returns: + Tuple of (SQL string, empty params tuple) - def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpression") -> Tuple[str, tuple]: - """Format ALTER TABLE ... DETACH PARTITION statement from expression.""" + """ parts = ["ALTER TABLE"] if expr.schema: @@ -149,18 +453,18 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres else: parts.append(self.format_identifier(expr.parent_table)) - if expr.concurrently: - if not self.supports_concurrent_detach(): - raise ValueError("DETACH CONCURRENTLY requires PostgreSQL 14+") - parts.append("DETACH CONCURRENTLY") - else: - parts.append("DETACH PARTITION") + if expr.concurrently and not self.supports_concurrent_detach(): + raise ValueError("DETACH CONCURRENTLY requires PostgreSQL 14+") + parts.append("DETACH PARTITION") if expr.schema: parts.append(f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.partition_name)}") else: parts.append(self.format_identifier(expr.partition_name)) + if expr.concurrently: + parts.append("CONCURRENTLY") + if expr.finalize: if not expr.concurrently: raise ValueError("FINALIZE only valid with CONCURRENTLY") @@ -169,7 +473,26 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres return (" ".join(parts), ()) def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: - """Format ALTER TABLE ... ATTACH PARTITION statement from expression.""" + """Format ALTER TABLE ... ATTACH PARTITION statement from expression. + + - ``expr.parent_table`` — partitioned table name. + - ``expr.schema`` — optional schema qualifier. + - ``expr.partition_name`` — partition to attach. + - ``expr.partition_type`` — partition method (``RANGE`` / ``LIST`` / ``HASH``). + - ``expr.partition_values`` — bound values (see ``format_create_partition_statement``). + - ``expr.concurrently`` — add ``CONCURRENTLY`` for non-blocking attach (PG 14+). + + Args: + expr: PostgresAttachPartitionExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If partition_type is invalid or required bound values are missing. + ValueError: If concurrently is used on PostgreSQL < 14. + + """ parts = ["ALTER TABLE"] if expr.schema: @@ -184,23 +507,112 @@ def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpres else: parts.append(self.format_identifier(expr.partition_name)) - # FOR VALUES clause (same as create partition) - parts.append("FOR VALUES") - partition_type = expr.partition_type.upper() - if partition_type == "RANGE": - from_val = expr.partition_values.get("from") - to_val = expr.partition_values.get("to") - parts.append(f"FROM ({self._format_partition_value(from_val)}) TO ({self._format_partition_value(to_val)})") + if partition_type not in ("RANGE", "LIST", "HASH"): + raise ValueError(f"Invalid partition_type: {partition_type}") - elif partition_type == "LIST": - values = expr.partition_values.get("values", []) - vals_str = ", ".join(self._format_partition_value(v) for v in values) - parts.append(f"IN ({vals_str})") + # FOR VALUES / DEFAULT clause (same as create partition) + if "default" in expr.partition_values and expr.partition_values["default"]: + parts.append("DEFAULT") + else: + parts.append("FOR VALUES") - elif partition_type == "HASH": - modulus = expr.partition_values.get("modulus") - remainder = expr.partition_values.get("remainder") - parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") + if partition_type == "RANGE": + from_val = expr.partition_values.get("from") + to_val = expr.partition_values.get("to") + if from_val is None or to_val is None: + raise ValueError("RANGE partition requires 'from' and 'to' values") + from ...expression.ddl import PartitionValue + from_sql, _ = PartitionValue(dialect=self, value=from_val).to_sql() + to_sql, _ = PartitionValue(dialect=self, value=to_val).to_sql() + parts.append(f"FROM ({from_sql}) TO ({to_sql})") + + elif partition_type == "LIST": + values = expr.partition_values.get("values", []) + if not values: + raise ValueError("LIST partition requires 'values' list") + from ...expression.ddl import PartitionValue + vals_str = ", ".join( + PartitionValue(dialect=self, value=v).to_sql()[0] + for v in values + ) + parts.append(f"IN ({vals_str})") + + elif partition_type == "HASH": + modulus = expr.partition_values.get("modulus") + remainder = expr.partition_values.get("remainder") + if modulus is None or remainder is None: + raise ValueError("HASH partition requires 'modulus' and 'remainder'") + if not self.supports_hash_partitioning(): + raise ValueError("HASH partitioning requires PostgreSQL 11+") + parts.append(f"WITH (MODULUS {modulus}, REMAINDER {remainder})") + + # CONCURRENTLY (PG 14+) + if expr.concurrently: + if not self.supports_concurrent_attach(): + raise ValueError("ATTACH CONCURRENTLY requires PostgreSQL 14+") + parts.append("CONCURRENTLY") return (" ".join(parts), ()) + + def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpression") -> Tuple[str, tuple]: + """Format pg_catalog query for partition metadata introspection. + + Builds a parameterised query against pg_catalog to inspect a partitioned + table's partition key, child partitions, and their bounds. + + - ``expr.parent_table`` — name of the parent partitioned table. + - ``expr.schema`` — optional schema to scope the query (None = all schemas). + - ``expr.include_partitions`` — if True, lists child partitions with bounds; + if False, returns only parent metadata without partition details. + + The query uses parameter binding (%s placeholders) with + (parent_table, schema, schema) as parameters. When schema is None, + the WHERE clause omits the namespace filter. + + Args: + expr: PostgresPartitionMetadataExpression with parent table details. + + Returns: + Tuple of (SQL string with %s placeholders, params tuple). + + Raises: + UnsupportedFeatureError: If PostgreSQL version < 10. + """ + from typing import List + + if not self.supports_partition_metadata_introspection(): + raise UnsupportedFeatureError( + self.name, + "partition metadata introspection", + "Partition metadata introspection requires PostgreSQL 10+.", + ) + + params: List[Any] = [expr.parent_table] + schema_filter = "" + if expr.schema is not None: + schema_filter = " AND parent_ns.nspname = %s" + params.append(expr.schema) + + if expr.include_partitions: + sql = f""" + SELECT pg_get_partkeydef(parent.oid) AS partition_key, + child.relname AS name, + pg_get_expr(child.relpartbound, child.oid) AS bound + FROM pg_class parent + JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + LEFT JOIN pg_inherits i ON i.inhparent = parent.oid + LEFT JOIN pg_class child ON child.oid = i.inhrelid + WHERE parent.relname = %s{schema_filter} + ORDER BY child.relname + """ + else: + sql = f""" + SELECT pg_get_partkeydef(parent.oid) AS partition_key, + NULL::text AS name, + NULL::text AS bound + FROM pg_class parent + JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace + WHERE parent.relname = %s{schema_filter} + """ + return sql, tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py index e7d16d32..fd2f2172 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/trigger.py @@ -9,6 +9,18 @@ class PostgresTriggerMixin: the SQL:1999 standard 'EXECUTE func_name'. """ + def supports_trigger(self) -> bool: + """Triggers are supported in all versions.""" + return True + + def supports_create_trigger(self) -> bool: + """CREATE TRIGGER is supported in all versions.""" + return True + + def supports_drop_trigger(self) -> bool: + """DROP TRIGGER is supported in all versions.""" + return True + def supports_trigger_referencing(self) -> bool: """REFERENCING clause is supported since PostgreSQL 10.""" return self.version >= (10, 0, 0) @@ -32,7 +44,27 @@ def supports_trigger_if_not_exists(self) -> bool: def format_create_trigger_statement(self, expr) -> Tuple[str, tuple]: """Format CREATE TRIGGER statement (PostgreSQL syntax). - PostgreSQL uses 'EXECUTE FUNCTION func_name()' instead of standard 'EXECUTE func_name'. + PostgreSQL uses ``EXECUTE FUNCTION func_name()`` instead of standard ``EXECUTE func_name``. + + Supported expression attributes: + + - ``expr.if_not_exists`` — add ``IF NOT EXISTS`` (PG 9.5+). + - ``expr.trigger_name`` — trigger name (identifier). + - ``expr.timing`` — ``BEFORE``, ``AFTER``, or ``INSTEAD OF``. + - ``expr.events`` — list of event types (``INSERT``, ``UPDATE``, ``DELETE``, ``TRUNCATE``). + - ``expr.update_columns`` — column list for ``UPDATE OF``. + - ``expr.table_name`` — target table (identifier). + - ``expr.referencing`` — ``REFERENCING`` clause string (PG 10+). + - ``expr.level`` — ``FOR EACH ROW`` or ``FOR EACH STATEMENT``. + - ``expr.condition`` — ``WHEN`` predicate expression. + - ``expr.function_name`` — function to execute. + + Args: + expr: Expression instance with trigger attributes + + Returns: + Tuple of (SQL string, params tuple) + """ parts = ["CREATE TRIGGER"] @@ -71,7 +103,19 @@ def format_create_trigger_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) def format_drop_trigger_statement(self, expr) -> Tuple[str, tuple]: - """Format DROP TRIGGER statement (PostgreSQL syntax).""" + """Format DROP TRIGGER statement (PostgreSQL syntax). + + - ``expr.if_exists`` — add ``IF EXISTS``. + - ``expr.trigger_name`` — trigger name (identifier). + - ``expr.table_name`` — optional ``ON table_name`` clause. + + Args: + expr: Expression instance with trigger attributes + + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["DROP TRIGGER"] if expr.if_exists: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/extended_statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/extended_statistics.py index 05f83efd..bfba0e91 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/extended_statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/extended_statistics.py @@ -37,17 +37,32 @@ def supports_statistics_mcv(self) -> bool: def format_create_statistics_statement(self, expr: "PostgresCreateStatisticsExpression") -> Tuple[str, tuple]: """Format CREATE STATISTICS statement for extended statistics. + - ``expr.schema`` — optional schema qualifier. + - ``expr.name`` — statistics object name. + - ``expr.table_name`` — source table name. + - ``expr.if_not_exists`` — add ``IF NOT EXISTS``. + - ``expr.statistics_type`` — optional type (``ndistinct``, ``dependencies``, ``mcv``; MCV requires PG 12+). + - ``expr.columns`` — list of column names. + Args: - expr: PostgresCreateStatisticsExpression containing all options + expr: PostgresCreateStatisticsExpression instance Returns: - Tuple of (SQL statement, parameters tuple) + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If statistics type is unsupported or MCV is used before PG 12. + """ if not self.supports_create_statistics(): raise ValueError("CREATE STATISTICS requires PostgreSQL 10+") - full_name = f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.name)}" if expr.schema else self.format_identifier(expr.name) - table_full = f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.table_name)}" if expr.schema else self.format_identifier(expr.table_name) + if expr.schema: + full_name = f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.name)}" + table_full = f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.table_name)}" + else: + full_name = self.format_identifier(expr.name) + table_full = self.format_identifier(expr.table_name) exists_clause = "IF NOT EXISTS " if expr.if_not_exists else "" @@ -72,13 +87,22 @@ def format_create_statistics_statement(self, expr: "PostgresCreateStatisticsExpr def format_drop_statistics_statement(self, expr: "PostgresDropStatisticsExpression") -> Tuple[str, tuple]: """Format DROP STATISTICS statement. + - ``expr.schema`` — optional schema qualifier. + - ``expr.name`` — statistics object name. + - ``expr.if_exists`` — add ``IF EXISTS``. + Args: - expr: PostgresDropStatisticsExpression containing all options + expr: PostgresDropStatisticsExpression instance Returns: - Tuple of (SQL statement, parameters tuple) + Tuple of (SQL string, empty params tuple) + """ - full_name = f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.name)}" if expr.schema else self.format_identifier(expr.name) + full_name = ( + f"{self.format_identifier(expr.schema)}.{self.format_identifier(expr.name)}" + if expr.schema + else self.format_identifier(expr.name) + ) exists_clause = "IF EXISTS " if expr.if_exists else "" sql = f"DROP STATISTICS {exists_clause}{full_name}" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py index f1c093df..56c6eb40 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py @@ -47,8 +47,7 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: all_params = [] - # Handle both base ForUpdateClause (no strength) and PostgresForUpdateClause - strength = getattr(clause, 'strength', LockStrength.UPDATE) + strength = clause.dialect_options.get("lock_strength", LockStrength.UPDATE) # Check version support for lock strength if strength == LockStrength.NO_KEY_UPDATE: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py index 3023aa17..9a6e5cfb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py @@ -63,7 +63,7 @@ def format_create_procedure_statement( if not self.supports_call_statement(): raise ValueError("CREATE PROCEDURE requires PostgreSQL 11+") - full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) + full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) # noqa: E501 # OR REPLACE clause replace_clause = "OR REPLACE " if or_replace else "" @@ -126,7 +126,7 @@ def format_drop_procedure_statement( Returns: Tuple of (SQL statement, parameters tuple) """ - full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) + full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) # noqa: E501 exists_clause = "IF EXISTS " if if_exists else "" @@ -158,7 +158,7 @@ def format_call_statement( if not self.supports_call_statement(): raise ValueError("CALL statement requires PostgreSQL 11+") - full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) + full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) # noqa: E501 if arguments: placeholders = ", ".join(["%s"] * len(arguments)) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py index f64dbc9d..88a83129 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/vacuum.py @@ -30,7 +30,34 @@ def supports_vacuum_process_toast(self) -> bool: return self.version >= (14, 0, 0) def format_vacuum_statement(self, expr: "PostgresVacuumExpression") -> Tuple[str, tuple]: - """Format VACUUM statement from PostgresVacuumExpression.""" + """Format VACUUM statement from PostgresVacuumExpression. + + Supported expression attributes: + + - ``expr.full`` — ``FULL``. + - ``expr.freeze`` — ``FREEZE``. + - ``expr.verbose`` — ``VERBOSE``. + - ``expr.analyze`` — ``ANALYZE``. + - ``expr.parallel`` — ``PARALLEL N`` (PG 13+). + - ``expr.index_cleanup`` — ``INDEX_CLEANUP {AUTO|ON|OFF}`` (PG 14+). + - ``expr.process_toast`` — ``PROCESS_TOAST {TRUE|FALSE}`` (PG 14+). + - ``expr.skip_locked`` — ``SKIP_LOCKED``. + - ``expr.truncate`` — ``TRUNCATE`` (mutually exclusive with FULL). + - ``expr.table_name`` — optional table name. + - ``expr.schema`` — optional schema qualifier. + - ``expr.columns`` — optional column list (for ANALYZE). + + Args: + expr: PostgresVacuumExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + Raises: + ValueError: If a version-gated option is used on an unsupported PostgreSQL version, + or if TRUNCATE and FULL are used together. + + """ parts = ["VACUUM"] # Add options @@ -91,7 +118,21 @@ def format_vacuum_statement(self, expr: "PostgresVacuumExpression") -> Tuple[str return (" ".join(parts), ()) def format_analyze_statement(self, expr: "PostgresAnalyzeExpression") -> Tuple[str, tuple]: - """Format ANALYZE statement from PostgresAnalyzeExpression.""" + """Format ANALYZE statement from PostgresAnalyzeExpression. + + - ``expr.verbose`` — ``VERBOSE``. + - ``expr.skip_locked`` — ``SKIP_LOCKED``. + - ``expr.table_name`` — optional table name. + - ``expr.schema`` — optional schema qualifier. + - ``expr.columns`` — optional column list. + + Args: + expr: PostgresAnalyzeExpression instance + + Returns: + Tuple of (SQL string, empty params tuple) + + """ parts = ["ANALYZE"] if expr.verbose: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py new file mode 100644 index 00000000..65236cad --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py @@ -0,0 +1,17 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py +"""PostgreSQL explain feature support implementation.""" + + +class PostgresExplainMixin: + """PostgreSQL explain override implementation. + + All features are native, using version number for detection. + """ + + def supports_explain_analyze(self) -> bool: + return True + + def supports_explain_format(self, format_type: str) -> bool: + format_type_upper = format_type.upper() + supported_formats = ["TEXT", "XML", "JSON", "YAML"] + return format_type_upper in supported_formats diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/intarray.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/intarray.py index 91a6b475..0b4b1116 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/intarray.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/intarray.py @@ -1,3 +1,4 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/intarray.py """PostgreSQL intarray integer array functionality mixin. This module provides functionality to check intarray extension features, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pg_partman.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pg_partman.py index f5d7d2b1..e71f98b4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pg_partman.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pg_partman.py @@ -3,57 +3,229 @@ PostgreSQL pg_partman partition management functionality mixin. This module provides functionality to check pg_partman extension features -and generate partition configuration DDL statements. +and generate pg_partman expression SQL. -For SQL expression generation of partition creation and maintenance functions, -use the function factories in ``functions/pg_partman.py`` instead of the -removed format_* methods. -For partition configuration updates, use ``format_auto_partition_config``. +All SQL generation for pg_partman operations must go through expression +classes and dialect formatter methods. Do not build pg_partman SQL directly +in tests or callers. """ -from typing import Optional +from typing import Any, List, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl import ( + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, + PostgresPgPartmanDeleteConfigExpression, + ) class PostgresPgPartmanMixin: - """pg_partman partition management functionality implementation.""" + """pg_partman partition management functionality implementation. + + pg_partman is a PostgreSQL extension for automated time- and serial-based + partition management. This mixin provides SQL generation for its core + functions: create_parent, run_maintenance, and config management. + + All methods follow the Expression-Dialect-Protocol pattern: expressions + hold data, mixins generate SQL, and protocols define the interface. + + Extension detection relies on self.is_extension_installed() and + self.check_extension_feature(), which must be provided by the host class. + """ def supports_pg_partman(self) -> bool: - """Check if pg_partman extension is available.""" + """Check if pg_partman extension is available. + + Queries pg_extension catalog to determine if pg_partman is installed. + + Returns: + True if pg_partman extension is installed. + """ return self.is_extension_installed("pg_partman") def supports_pg_partman_auto_partition(self) -> bool: - """Check if pg_partman supports auto partitioning.""" + """Check if pg_partman supports auto partitioning. + + Verifies that the installed pg_partman version supports the + auto partitioning feature set. + + Returns: + True if auto partitioning is supported. + """ return self.check_extension_feature("pg_partman", "auto_partition") - def format_auto_partition_config( + def format_pg_partman_create_parent( + self, + expr: "PostgresPgPartmanCreateParentExpression", + ) -> tuple: + """Format pg_partman create_parent() expression. + + Builds a SELECT statement that calls pg_partman's create_parent + function with named parameters. All core parameters (parent_table, + control, interval, type, premake) are always included. Optional + parameters (start_partition, primary_key, default_table, + constraint_cols, template_table, epoch, jobmon) are included only + when set on the expression. + + Args: + expr: PostgresPgPartmanCreateParentExpression with parent table, + control column, interval, type, premake, and optional params. + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + from typing import List, Any + from rhosocial.activerecord.backend.expression import QualifiedIdentifierExpression + + schema = expr.schema or "partman" + function_name_sql, _ = QualifiedIdentifierExpression( + dialect=self, schema=schema, name="create_parent" + ).to_sql() + placeholder = self.get_parameter_placeholder() + + arg_specs: List[str] = [] + params: List[Any] = [] + + # Core required parameters (always included) + core_params = [ + ("p_parent_table", expr.parent_table, "text"), + ("p_control", expr.control, "text"), + ("p_interval", expr.interval, "text"), + ("p_type", expr.partition_type, "text"), + ("p_premake", expr.premake, "int"), + ] + for name, value, cast in core_params: + arg_specs.append(f"{name} := {placeholder}::{cast}") + params.append(value) + + # Optional parameters (included only when not None) + optional_params = [ + ("p_start_partition", expr.start_partition, "text"), + ("p_primary_key", expr.primary_key, "text"), + ("p_default_table", expr.default_table, "boolean"), + ("p_constraint_cols", expr.constraint_cols, "text[]"), + ("p_template_table", expr.template_table, "text"), + ("p_epoch", expr.epoch, "text"), + ("p_jobmon", expr.jobmon, "boolean"), + ] + for name, value, cast in optional_params: + if value is not None: + arg_specs.append(f"{name} := {placeholder}::{cast}") + params.append(value) + + return ( + f"SELECT {function_name_sql}({', '.join(arg_specs)})", + tuple(params), + ) + + def format_pg_partman_run_maintenance( self, - table_name: str, - automatic: bool = True, - infinite_time_partitions: bool = False, - retention: Optional[str] = None, - ) -> str: - """Format auto partition configuration update. + expr: "PostgresPgPartmanRunMaintenanceExpression", + ) -> tuple: + """Format pg_partman run_maintenance() expression. + + Builds a SELECT statement that calls pg_partman's run_maintenance + function. If a parent_table is specified, the maintenance is scoped + to that table; otherwise it runs globally. + + Args: + expr: PostgresPgPartmanRunMaintenanceExpression with optional + parent_table scope. + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + from rhosocial.activerecord.backend.expression import QualifiedIdentifierExpression + schema = expr.schema or "partman" + function_name_sql, _ = QualifiedIdentifierExpression( + dialect=self, schema=schema, name="run_maintenance" + ).to_sql() + if expr.parent_table is None: + return f"SELECT {function_name_sql}()", () + placeholder = self.get_parameter_placeholder() + return f"SELECT {function_name_sql}({placeholder}::text)", (expr.parent_table,) + + def format_pg_partman_update_config( + self, + expr: "PostgresPgPartmanUpdateConfigExpression", + ) -> tuple: + """Format pg_partman part_config update expression. + + Builds an UPDATE statement targeting the pg_partman part_config table. + Only columns corresponding to non-None expr attributes are included + in the SET clause. At least one config option must be specified. + + Args: + expr: PostgresPgPartmanUpdateConfigExpression with config values + to update. Only non-None attributes are applied. + + Returns: + Tuple of (UPDATE SQL, params tuple). + + Raises: + ValueError: If all config options are None (nothing to update). + """ + from rhosocial.activerecord.backend.expression import QualifiedIdentifierExpression + assignments: List[str] = [] + params: List[Any] = [] + placeholder = self.get_parameter_placeholder() + + if expr.automatic_maintenance is not None: + assignments.append(f"automatic_maintenance = {placeholder}") + params.append(expr.automatic_maintenance) + if expr.infinite_time_partitions is not None: + assignments.append(f"infinite_time_partitions = {placeholder}") + params.append(expr.infinite_time_partitions) + if expr.retention is not None: + assignments.append(f"retention = {placeholder}") + params.append(expr.retention) + if expr.retention_keep_table is not None: + assignments.append(f"retention_keep_table = {placeholder}") + params.append(expr.retention_keep_table) + if expr.retention_keep_index is not None: + assignments.append(f"retention_keep_index = {placeholder}") + params.append(expr.retention_keep_index) + + if not assignments: + raise ValueError("At least one pg_partman config option must be specified.") + + schema = expr.schema or "partman" + config_table_sql, _ = QualifiedIdentifierExpression( + dialect=self, schema=schema, name="part_config" + ).to_sql() + params.append(expr.parent_table) + return ( + f"UPDATE {config_table_sql} " + f"SET {', '.join(assignments)} " + f"WHERE parent_table = {placeholder}", + tuple(params), + ) + + def format_pg_partman_delete_config( + self, + expr: "PostgresPgPartmanDeleteConfigExpression", + ) -> tuple: + """Format pg_partman part_config delete expression. + + Builds a DELETE statement targeting the pg_partman part_config table + for a specific parent table, removing its automated partition config. Args: - table_name: Name of the partitioned table - automatic: Enable automatic partition creation - infinite_time_partitions: Create partitions infinitely into the future - retention: Optional retention period (e.g., '30 days') + expr: PostgresPgPartmanDeleteConfigExpression with parent_table + identifying the config to remove. Returns: - SQL UPDATE statement for part_config + Tuple of (DELETE SQL, params tuple). """ - parts = [] - if not automatic: - parts.append("automatic = false") - if infinite_time_partitions: - parts.append("infinite_time_partitions = true") - if retention: - parts.append(f"retention = '{retention}'") - if not parts: - parts.append("automatic = true") - set_clause = ", ".join(parts) + from rhosocial.activerecord.backend.expression import QualifiedIdentifierExpression + schema = expr.schema or "partman" + config_table_sql, _ = QualifiedIdentifierExpression( + dialect=self, schema=schema, name="part_config" + ).to_sql() + placeholder = self.get_parameter_placeholder() return ( - f"UPDATE partman.part_config SET {set_clause} " - f"WHERE parent_table = '{table_name}'" + f"DELETE FROM {config_table_sql} WHERE parent_table = {placeholder}", + (expr.parent_table,), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pglogical.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pglogical.py index c73bec17..7f6bf1c4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pglogical.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pglogical.py @@ -1,3 +1,4 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pglogical.py """PostgreSQL pglogical logical replication functionality mixin. This module provides functionality to check pglogical extension features. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py new file mode 100644 index 00000000..9f083272 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py +"""PostgreSQL features feature support implementation.""" + + +class PostgresFeaturesMixin: + """PostgreSQL features override implementation. + + All features are native, using version number for detection. + """ + + def supports_generated_columns(self) -> bool: + return self.version >= (12, 0, 0) + + def supports_cte_search_cycle(self) -> bool: + return self.version >= (14, 0, 0) + + def supports_fetch_with_ties(self) -> bool: + return self.version >= (13, 0, 0) + + def supports_call_statement(self) -> bool: + return self.version >= (11, 0, 0) + + def supports_stored_procedure_transaction_control(self) -> bool: + return self.version >= (11, 0, 0) + + def supports_sql_body_functions(self) -> bool: + return self.version >= (14, 0, 0) + + def supports_nulls_not_distinct_unique(self) -> bool: + return self.version >= (15, 0, 0) + + def supports_regexp_like(self) -> bool: + return self.version >= (16, 0, 0) + + def supports_random_normal(self) -> bool: + return self.version >= (16, 0, 0) + + def supports_json_table_nested_path(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_merge_with_cte(self) -> bool: + return self.version >= (17, 0, 0) + + def supports_update_returning_old(self) -> bool: + return self.version >= (17, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/filter_clause.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/filter_clause.py new file mode 100644 index 00000000..9c9e2e8e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/filter_clause.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/filter_clause.py +"""PostgreSQL filter clause feature support implementation.""" + + +class PostgresFilterMixin: + """PostgreSQL filter clause override implementation. + + All features are native, using version number for detection. + """ + + def supports_filter_clause(self) -> bool: + return self.version >= (9, 4, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/grouping.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/grouping.py new file mode 100644 index 00000000..542e7973 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/grouping.py @@ -0,0 +1,18 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/grouping.py +"""PostgreSQL grouping feature support implementation.""" + + +class PostgresGroupingMixin: + """PostgreSQL grouping override implementation. + + All features are native, using version number for detection. + """ + + def supports_rollup(self) -> bool: + return self.version >= (9, 5, 0) + + def supports_cube(self) -> bool: + return self.version >= (9, 5, 0) + + def supports_grouping_sets(self) -> bool: + return self.version >= (9, 5, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py new file mode 100644 index 00000000..ce9033a6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py +"""PostgreSQL ilike feature support implementation.""" + + +class PostgresILIKEMixin: + """PostgreSQL ilike override implementation. + + All features are native, using version number for detection. + """ + + def supports_ilike(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/join.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/join.py new file mode 100644 index 00000000..05d8862c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/join.py @@ -0,0 +1,18 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/join.py +"""PostgreSQL join feature support implementation.""" + + +class PostgresJoinMixin: + """PostgreSQL join override implementation. + + All features are native, using version number for detection. + """ + + def supports_right_join(self) -> bool: + return True + + def supports_full_join(self) -> bool: + return True + + def supports_wildcard(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/lateral_join.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/lateral_join.py new file mode 100644 index 00000000..86831c2a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/lateral_join.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/lateral_join.py +"""PostgreSQL lateral join feature support implementation.""" + + +class PostgresLateralJoinMixin: + """PostgreSQL lateral join override implementation. + + All features are native, using version number for detection. + """ + + def supports_lateral_join(self) -> bool: + return self.version >= (9, 3, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/merge.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/merge.py new file mode 100644 index 00000000..0ca3ba03 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/merge.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/merge.py +"""PostgreSQL merge feature support implementation.""" + + +class PostgresMergeMixin: + """PostgreSQL merge override implementation. + + All features are native, using version number for detection. + """ + + def supports_merge_statement(self) -> bool: + return self.version >= (15, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ordered_set_aggregation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ordered_set_aggregation.py new file mode 100644 index 00000000..dcb8239b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ordered_set_aggregation.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ordered_set_aggregation.py +"""PostgreSQL ordered set aggregation feature support implementation.""" + + +class PostgresOrderedSetAggMixin: + """PostgreSQL ordered set aggregation override implementation. + + All features are native, using version number for detection. + """ + + def supports_ordered_set_aggregation(self) -> bool: + return self.version >= (9, 4, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py new file mode 100644 index 00000000..8af4c289 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -0,0 +1,23 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +"""PostgreSQL Property Graph Query (SQL/PGQ) support mixin. + +PostgreSQL 19+ natively supports SQL/PGQ: +- GRAPH_TABLE (MATCH ... COLUMNS ...) +- CREATE / DROP / ALTER PROPERTY GRAPH +- Vertex/edge pattern matching with WHERE +""" + + +class PostgresPropertyGraphQueryMixin: + """PostgreSQL Property Graph Query support implementation. + + SQL/PGQ is a native feature since PostgreSQL 19. + """ + + def supports_graph_match(self) -> bool: + """MATCH clause is supported since PostgreSQL 19.""" + return self.version >= (19, 0, 0) + + def supports_graph_table(self) -> bool: + """GRAPH_TABLE expression is supported since PostgreSQL 19.""" + return self.version >= (19, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py new file mode 100644 index 00000000..62ac985e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py @@ -0,0 +1,18 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py +"""PostgreSQL returning feature support implementation.""" + + +class PostgresReturningMixin: + """PostgreSQL returning override implementation. + + All features are native, using version number for detection. + """ + + def supports_returning_insert(self) -> bool: + return self.version >= (8, 2, 0) + + def supports_returning_update(self) -> bool: + return self.version >= (8, 2, 0) + + def supports_returning_delete(self) -> bool: + return self.version >= (8, 2, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py new file mode 100644 index 00000000..8273b0c4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py @@ -0,0 +1,24 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py +"""PostgreSQL schema feature support implementation.""" + + +class PostgresSchemaMixin: + """PostgreSQL schema override implementation. + + All features are native, using version number for detection. + """ + + def supports_create_schema(self) -> bool: + return True + + def supports_drop_schema(self) -> bool: + return True + + def supports_schema_if_not_exists(self) -> bool: + return True + + def supports_schema_if_exists(self) -> bool: + return True + + def supports_schema_cascade(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/sequence.py new file mode 100644 index 00000000..a71928ef --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/sequence.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/sequence.py +"""PostgreSQL sequence feature support implementation.""" + + +class PostgresSequenceMixin: + """PostgreSQL sequence override implementation. + + All features are native, using version number for detection. + """ + + def supports_create_sequence(self) -> bool: + return True + + def supports_drop_sequence(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/set_operation.py new file mode 100644 index 00000000..eed17ce0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/set_operation.py @@ -0,0 +1,30 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/set_operation.py +"""PostgreSQL set operation feature support implementation.""" + + +class PostgresSetOperationMixin: + """PostgreSQL set operation override implementation. + + All features are native, using version number for detection. + """ + + def supports_union(self) -> bool: + return True + + def supports_union_all(self) -> bool: + return True + + def supports_intersect(self) -> bool: + return True + + def supports_except(self) -> bool: + return True + + def supports_set_operation_order_by(self) -> bool: + return True + + def supports_set_operation_limit_offset(self) -> bool: + return True + + def supports_set_operation_for_update(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/sql_syntax.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/sql_syntax.py deleted file mode 100644 index 8109826f..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/sql_syntax.py +++ /dev/null @@ -1,126 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/mixins/sql_syntax.py -"""PostgreSQL SQL syntax enhancements mixin.""" - - -class PostgresSQLSyntaxMixin: - """PostgreSQL SQL syntax enhancements implementation. - - All features are native, using version number for detection. - """ - - def supports_generated_columns(self) -> bool: - """Generated columns are native feature, PG 12+.""" - return self.version >= (12, 0, 0) - - def supports_cte_search_cycle(self) -> bool: - """CTE SEARCH/CYCLE is native feature, PG 14+.""" - return self.version >= (14, 0, 0) - - def supports_fetch_with_ties(self) -> bool: - """FETCH WITH TIES is native feature, PG 13+.""" - return self.version >= (13, 0, 0) - - # ========================================================================= - # Stored Procedures Support (PostgreSQL 11+) - # ========================================================================= - - def supports_call_statement(self) -> bool: - """CALL statement for stored procedures is supported since PostgreSQL 11. - - PostgreSQL 11 introduced stored procedures (not to be confused - with functions) that can manage their own transactions. - - Returns: - True if CALL statement is supported - """ - return self.version >= (11, 0, 0) - - def supports_stored_procedure_transaction_control(self) -> bool: - """Stored procedures with transaction control are supported since PostgreSQL 11. - - Procedures can use COMMIT and ROLLBACK within the procedure body. - - Returns: - True if procedure transaction control is supported - """ - return self.version >= (11, 0, 0) - - def supports_sql_body_functions(self) -> bool: - """SQL-body functions are supported since PostgreSQL 14. - - Functions can use SQL-standard function bodies with - BEGIN ATOMIC ... END syntax. - - Returns: - True if SQL body functions are supported - """ - return self.version >= (14, 0, 0) - - # ========================================================================= - # PostgreSQL 15+ Features - # ========================================================================= - - def supports_nulls_not_distinct_unique(self) -> bool: - """UNIQUE NULLS NOT DISTINCT is supported since PostgreSQL 15. - - Creates unique constraints that treat NULL values as equal, - allowing only one NULL in the column. - - Returns: - True if supported - """ - return self.version >= (15, 0, 0) - - # ========================================================================= - # PostgreSQL 16+ Features - # ========================================================================= - - def supports_regexp_like(self) -> bool: - """REGEXP_LIKE function is supported since PostgreSQL 16. - - SQL-standard regular expression matching function. - - Returns: - True if supported - """ - return self.version >= (16, 0, 0) - - def supports_random_normal(self) -> bool: - """random_normal() function is supported since PostgreSQL 16. - - Returns normally distributed random values. - - Returns: - True if supported - """ - return self.version >= (16, 0, 0) - - # ========================================================================= - # PostgreSQL 17+ Features - # ========================================================================= - - def supports_json_table_nested_path(self) -> bool: - """Enhanced JSON_TABLE NESTED PATH is supported since PostgreSQL 17. - - Returns: - True if supported - """ - return self.version >= (17, 0, 0) - - def supports_merge_with_cte(self) -> bool: - """MERGE statement with CTE is supported since PostgreSQL 17. - - Returns: - True if supported - """ - return self.version >= (17, 0, 0) - - def supports_update_returning_old(self) -> bool: - """UPDATE RETURNING OLD is supported since PostgreSQL 17. - - Allows returning old values in UPDATE RETURNING. - - Returns: - True if supported - """ - return self.version >= (17, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index b8f67f30..77e7c10f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -1,7 +1,57 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +import re +from typing import Any, List, Tuple + class PostgresTableMixin: """PostgreSQL table extended features implementation.""" + def supports_if_not_exists_table(self) -> bool: + """CREATE TABLE IF NOT EXISTS is supported since PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_if_exists_table(self) -> bool: + """DROP TABLE IF EXISTS is supported in all versions.""" + return True + + def supports_temporary_table(self) -> bool: + """TEMPORARY tables are supported in all versions.""" + return True + def supports_table_inheritance(self) -> bool: """PostgreSQL supports table inheritance.""" return True + + def supports_table_tablespace(self) -> bool: + """TABLESPACE specification is supported in all versions.""" + return True + + def supports_table_like_syntax(self) -> bool: + """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" + return True + + def format_create_table_like(self, expr) -> tuple: + """Format CREATE TABLE (LIKE ...) statement for PostgreSQL. + + Delegates to format_create_table_statement which already handles + the 'like_table' key in dialect_options. + """ + return self.format_create_table_statement(expr) + + def format_column_definition(self, col_def) -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + all_params: List[Any] = [] + data_type = col_def.data_type + if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type): + raise ValueError( + f"Invalid data type '{data_type}': " + "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." + ) + col_sql = f"{self.format_identifier(col_def.name)} {data_type}" + for constraint in col_def.constraints: + suffix, params = self.format_column_constraint(constraint) + col_sql += suffix + all_params.extend(params) + if col_def.comment: + escaped_comment = SQLDialectBase._escape_sql_string(col_def.comment) + col_sql += f" COMMENT '{escaped_comment}'" + return col_sql, tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py new file mode 100644 index 00000000..aa7eafb2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py @@ -0,0 +1,24 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py +"""PostgreSQL transaction feature support implementation.""" + + +class PostgresTransactionMixin: + """PostgreSQL transaction override implementation. + + All features are native, using version number for detection. + """ + + def supports_transaction_mode(self) -> bool: + return True + + def supports_isolation_level_in_begin(self) -> bool: + return True + + def supports_read_only_transaction(self) -> bool: + return True + + def supports_deferrable_transaction(self) -> bool: + return True + + def supports_savepoint(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py new file mode 100644 index 00000000..3e260464 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +"""PostgreSQL truncate feature support implementation.""" + + +class PostgresTruncateMixin: + """PostgreSQL truncate override implementation. + + All features are native, using version number for detection. + """ + + def supports_truncate_restart_identity(self) -> bool: + return self.version >= (8, 4, 0) + + def supports_truncate_cascade(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py index 90b061c5..af5381c0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py @@ -30,6 +30,7 @@ def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: Returns: Formatted type name (e.g., 'schema.name' or 'name') + """ if schema: return f"{schema}.{name}" @@ -43,6 +44,7 @@ def format_enum_values(self, values: List[str]) -> str: Returns: SQL-formatted values string + """ return ", ".join(f"'{v}'" for v in values) @@ -59,6 +61,7 @@ def format_create_enum_type_raw( Returns: SQL statement string + """ full_name = self.format_enum_type_name(name, schema) values_str = self.format_enum_values(values) @@ -78,6 +81,7 @@ def format_drop_enum_type_raw( Returns: SQL statement string + """ full_name = self.format_enum_type_name(name, schema) exists_clause = "IF EXISTS " if if_exists else "" @@ -99,6 +103,7 @@ def format_alter_enum_add_value_raw( Returns: SQL statement string + """ full_name = self.format_enum_type_name(type_name, schema) sql = f"ALTER TYPE {full_name} ADD VALUE '{new_value}'" @@ -127,6 +132,7 @@ def create_enum_type( Returns: SQL statement string + """ return self.format_create_enum_type_raw(name, values, schema, if_not_exists) @@ -145,6 +151,7 @@ def drop_enum_type( Returns: SQL statement string + """ return self.format_drop_enum_type_raw(name, schema, if_exists, cascade) @@ -171,6 +178,7 @@ def alter_enum_add_value( Returns: SQL statement string + """ return self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) @@ -185,7 +193,30 @@ def format_create_enum_type( schema: Optional[str] = None, if_not_exists: bool = False, ) -> Union[str, Tuple[str, tuple]]: - """Format CREATE TYPE for either the legacy or expression-based API.""" + """Format CREATE TYPE for either the legacy or expression-based API. + + **Expression mode** — if ``expr_or_name`` has a ``values`` attribute, + its ``.name``, ``.values``, ``.schema``, and ``.if_not_exists`` are + used and the result is wrapped in ``(sql, ())``. + + **Legacy mode** — ``expr_or_name`` is treated as a type name string; + ``values``, ``schema``, and ``if_not_exists`` must be supplied as + keyword arguments. + + Args: + expr_or_name: Expression instance or type name string. + values: List of enum values (required in legacy mode). + schema: Optional schema name. + if_not_exists: Add ``IF NOT EXISTS``. + + Returns: + Tuple of (SQL string, params tuple) in expression mode, + plain SQL string in legacy mode. + + Raises: + TypeError: If ``values`` is ``None`` in legacy mode. + + """ if hasattr(expr_or_name, "values"): sql = self.format_create_enum_type_raw( expr_or_name.name, @@ -207,7 +238,27 @@ def format_drop_enum_type( if_exists: bool = False, cascade: bool = False, ) -> Union[str, Tuple[str, tuple]]: - """Format DROP TYPE for either the legacy or expression-based API.""" + """Format DROP TYPE for either the legacy or expression-based API. + + **Expression mode** — if ``expr_or_name`` has an ``if_exists`` attribute, + its ``.name``, ``.schema``, ``.if_exists``, and ``.cascade`` are used + and the result is wrapped in ``(sql, ())``. + + **Legacy mode** — ``expr_or_name`` is treated as a type name string; + ``schema``, ``if_exists``, and ``cascade`` must be supplied as keyword + arguments. + + Args: + expr_or_name: Expression instance or type name string. + schema: Optional schema name. + if_exists: Add ``IF EXISTS``. + cascade: Add ``CASCADE``. + + Returns: + Tuple of (SQL string, params tuple) in expression mode, + plain SQL string in legacy mode. + + """ if hasattr(expr_or_name, "if_exists"): sql = self.format_drop_enum_type_raw( expr_or_name.name, @@ -227,7 +278,31 @@ def format_alter_enum_add_value( before: Optional[str] = None, after: Optional[str] = None, ) -> Union[str, Tuple[str, tuple]]: - """Format ALTER TYPE ADD VALUE for either the legacy or expression-based API.""" + """Format ALTER TYPE ADD VALUE for either the legacy or expression-based API. + + **Expression mode** — if ``expr_or_type_name`` has a ``new_value`` attribute, + its ``.type_name``, ``.new_value``, ``.schema``, ``.before``, and ``.after`` + are used and the result is wrapped in ``(sql, ())``. + + **Legacy mode** — ``expr_or_type_name`` is treated as a type name string; + ``new_value``, ``schema``, ``before``, and ``after`` must be supplied as + keyword arguments. + + Args: + expr_or_type_name: Expression instance or type name string. + new_value: New enum value (required in legacy mode). + schema: Optional schema name. + before: Insert before this existing value. + after: Insert after this existing value. + + Returns: + Tuple of (SQL string, params tuple) in expression mode, + plain SQL string in legacy mode. + + Raises: + TypeError: If ``new_value`` is ``None`` in legacy mode. + + """ if hasattr(expr_or_type_name, "new_value"): sql = self.format_alter_enum_add_value_raw( expr_or_type_name.type_name, @@ -251,6 +326,7 @@ def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple) + """ sql = self.format_alter_enum_add_value_raw( expr.type_name, @@ -269,6 +345,7 @@ def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, empty params tuple) + """ full_name = self.format_enum_type_name(expr.type_name, expr.schema) sql = f"ALTER TYPE {full_name} RENAME VALUE '{expr.old_value}' TO '{expr.new_value}'" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py index fa88737c..cf9706f9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py @@ -13,6 +13,31 @@ class PostgresJSONBEnhancedMixin: JSON and JSONB features beyond the standard JSONSupport protocol. """ + def format_json_expression(self, expr): + from rhosocial.activerecord.backend.expression import bases + + if isinstance(expr.column, bases.BaseExpression): + col_sql, col_params = expr.column.to_sql() + else: + col_sql, col_params = self.format_identifier(str(expr.column)), () + + placeholder = self.get_parameter_placeholder() + value_sql = f"jsonb_path_query_first({col_sql}, {placeholder}::jsonpath)" + if expr.operation == "->>": + sql = f"({value_sql} #>> '{{}}')" + else: + sql = value_sql + params = col_params + (expr.path,) + + if expr.cast_types: + for target_type in expr.cast_types: + sql, params = self.format_cast_expression(sql, target_type, params, None) + + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + + return sql, params + def supports_json_type(self) -> bool: """JSON is supported since PostgreSQL 9.2.""" return self.version >= (9, 2, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py new file mode 100644 index 00000000..b5c4140b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -0,0 +1,12 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +"""PostgreSQL upsert feature support implementation.""" + + +class PostgresUpsertMixin: + """PostgreSQL upsert override implementation. + + All features are native, using version number for detection. + """ + + def supports_upsert(self) -> bool: + return self.version >= (9, 5, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py new file mode 100644 index 00000000..14dcd2a0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py @@ -0,0 +1,33 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py +"""PostgreSQL view feature support implementation.""" + + +class PostgresViewMixin: + """PostgreSQL view feature support implementation.""" + + def supports_or_replace_view(self) -> bool: + return True + + def supports_temporary_view(self) -> bool: + return True + + def supports_if_exists_view(self) -> bool: + return True + + def supports_view_check_option(self) -> bool: + return True + + def supports_cascade_view(self) -> bool: + return True + + def supports_materialized_view(self) -> bool: + return True + + def supports_refresh_materialized_view(self) -> bool: + return True + + def supports_materialized_view_tablespace(self) -> bool: + return True + + def supports_materialized_view_storage_options(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/window.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/window.py new file mode 100644 index 00000000..4d5480db --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/window.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/window.py +"""PostgreSQL window feature support implementation.""" + + +class PostgresWindowMixin: + """PostgreSQL window override implementation. + + All features are native, using version number for detection. + """ + + def supports_window_functions(self) -> bool: + return True + + def supports_window_frame_clause(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/xml.py new file mode 100644 index 00000000..c8b2694e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/xml.py @@ -0,0 +1,48 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/xml.py +"""PostgreSQL xml feature support implementation.""" + + +class PostgresXMLMixin: + """PostgreSQL xml override implementation. + + All features are native, using version number for detection. + """ + + def supports_xmlparse(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlserialize(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlelement(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlattributes(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlforest(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlconcat(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlcomment(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlpi(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlroot(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlagg(self) -> bool: + return self.version >= (8, 3, 0) + + def supports_xmlquery(self) -> bool: + return False + + def supports_xmlexists(self) -> bool: + return self.version >= (8, 4, 0) + + def supports_xmltable(self) -> bool: + return self.version >= (10, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/__init__.py new file mode 100644 index 00000000..ce432eca --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/__init__.py @@ -0,0 +1,39 @@ +# src/rhosocial/activerecord/backend/impl/postgres/named_expressions/__init__.py +"""PostgreSQL named expressions for partition auto-management. + +This package provides named expression functions for PostgreSQL partition +management. Each function follows the ``named_expression`` protocol: + +1. First argument is ``dialect`` (the PostgreSQL dialect instance). +2. Returns a ``BaseExpression`` (or a ``ProcedureGraph`` for multi-step workflows). +3. Is discoverable via ``list_named_expressions_in_module()``. + +Usage via CLI:: + + # Create next month's partition + rhosocial-activerecord named-expr \\ + rhosocial.activerecord.backend.impl.postgres.named_expressions.partition.create_next_monthly_partition \\ + --param parent_table=orders + + # List all available partition helpers + rhosocial-activerecord named-expr \\ + rhosocial.activerecord.backend.impl.postgres.named_expressions.partition --list + +Reference: + - Named Expression docs: https://rhosocial.ai/activerecord/named-expressions + - PostgreSQL Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html +""" + +from .partition import ( + create_next_monthly_partition, + create_range_partition_for_month, + create_range_partition_for_quarter, + create_range_partitions_for_interval, +) + +__all__ = [ + "create_next_monthly_partition", + "create_range_partition_for_month", + "create_range_partition_for_quarter", + "create_range_partitions_for_interval", +] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py new file mode 100644 index 00000000..d8a2f623 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py @@ -0,0 +1,311 @@ +"""Partition auto-management named expression functions. + +This module provides named expression helpers for automated partition +management in PostgreSQL. Each function accepts ``dialect`` as its first +argument and returns a ``BaseExpression`` representing the partition DDL +operation. + +All functions follow the named expression contract: + - First parameter: ``dialect`` (the PostgreSQL dialect instance). + - Returns: A ``BaseExpression`` whose ``to_sql()`` produces the DDL SQL. + - Parameters: Additional keyword arguments control partition naming, + bounds, and schema qualifiers. + +Reference: + - PostgreSQL Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html +""" + +from datetime import date, datetime, timedelta +from typing import Dict, Optional, Tuple + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( + PostgresCreatePartitionExpression, + PostgresPartitionMetadataExpression, +) + + +def create_next_monthly_partition( + dialect, + parent_table: str, + schema: Optional[str] = None, + reference_date: Optional[str] = None, + lead_months: int = 1, +) -> PostgresCreatePartitionExpression: + """Create the next monthly RANGE partition for a partitioned table. + + Generates a ``PostgresCreatePartitionExpression`` for a monthly RANGE + partition whose start boundary is computed from the reference date + (or today) advanced by ``lead_months``. + + Partition naming convention: ``_YYYY_MM``. + + Args: + dialect: The PostgreSQL dialect instance. + parent_table: Name of the partitioned parent table. + schema: Optional schema name for the partition. + reference_date: Reference date in ``YYYY-MM-DD`` format. + Defaults to today. + lead_months: Number of months ahead to create the partition. + ``1`` (default) creates next month's partition. + + Returns: + PostgresCreatePartitionExpression for the monthly partition. + + Raises: + ValueError: If reference_date is not a valid date string. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = create_next_monthly_partition( + ... dialect, "orders", reference_date="2026-06-01") + >>> sql, _ = expr.to_sql() + >>> print(sql) + CREATE TABLE orders_2026_07 PARTITION OF orders FOR VALUES FROM ('2026-07-01') TO ('2026-08-01') + """ + if reference_date is not None: + try: + ref = date.fromisoformat(reference_date) + except (ValueError, TypeError) as exc: + raise ValueError( + f"Invalid reference_date: {reference_date!r}. " + "Expected format: YYYY-MM-DD." + ) from exc + else: + ref = date.today() + + # Advance by lead_months + target_month = ref.month + lead_months - 1 + target_year = ref.year + target_month // 12 + target_month = (target_month % 12) + 1 + + start_date = date(target_year, target_month, 1) + # Compute first day of next month + next_month = target_month + 1 + next_year = target_year + if next_month > 12: + next_month = 1 + next_year += 1 + end_date = date(next_year, next_month, 1) + + partition_name = f"{parent_table}_{target_year}_{target_month:02d}" + + return PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=parent_table, + partition_type="RANGE", + partition_values={"from": start_date.isoformat(), "to": end_date.isoformat()}, + schema=schema, + ) + + +def create_range_partition_for_month( + dialect, + parent_table: str, + year: int, + month: int, + schema: Optional[str] = None, +) -> PostgresCreatePartitionExpression: + """Create a monthly RANGE partition for a specific year and month. + + Generates a ``PostgresCreatePartitionExpression`` for a single monthly + RANGE partition covering the given year-month. + + Partition naming convention: ``_YYYY_MM``. + + Args: + dialect: The PostgreSQL dialect instance. + parent_table: Name of the partitioned parent table. + year: The year (e.g., 2026). + month: The month (1-12). + schema: Optional schema name for the partition. + + Returns: + PostgresCreatePartitionExpression for the specified month. + + Raises: + ValueError: If month is not in 1..12. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = create_range_partition_for_month(dialect, "orders", 2026, 6) + >>> sql, _ = expr.to_sql() + >>> print(sql) + CREATE TABLE orders_2026_06 PARTITION OF orders FOR VALUES FROM ('2026-06-01') TO ('2026-07-01') + """ + if not 1 <= month <= 12: + raise ValueError(f"Invalid month: {month}. Must be 1-12.") + + start_date = date(year, month, 1) + next_month = month + 1 + next_year = year + if next_month > 12: + next_month = 1 + next_year += 1 + end_date = date(next_year, next_month, 1) + + partition_name = f"{parent_table}_{year}_{month:02d}" + + return PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=parent_table, + partition_type="RANGE", + partition_values={"from": start_date.isoformat(), "to": end_date.isoformat()}, + schema=schema, + ) + + +def create_range_partition_for_quarter( + dialect, + parent_table: str, + year: int, + quarter: int, + schema: Optional[str] = None, +) -> PostgresCreatePartitionExpression: + """Create a quarterly RANGE partition for a specific year and quarter. + + Generates a ``PostgresCreatePartitionExpression`` for a quarterly RANGE + partition covering the given year-quarter. + + Partition naming convention: ``_YYYY_Q``. + + Args: + dialect: The PostgreSQL dialect instance. + parent_table: Name of the partitioned parent table. + year: The year (e.g., 2026). + quarter: The quarter (1-4). + schema: Optional schema name for the partition. + + Returns: + PostgresCreatePartitionExpression for the specified quarter. + + Raises: + ValueError: If quarter is not in 1..4. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = create_range_partition_for_quarter(dialect, "orders", 2026, 1) + >>> sql, _ = expr.to_sql() + >>> print(sql) + CREATE TABLE orders_2026_q1 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-04-01') + """ + if not 1 <= quarter <= 4: + raise ValueError(f"Invalid quarter: {quarter}. Must be 1-4.") + + start_month = (quarter - 1) * 3 + 1 + start_date = date(year, start_month, 1) + end_month = start_month + 3 + end_year = year + if end_month > 12: + end_month = 1 + end_year += 1 + end_date = date(end_year, end_month, 1) + + partition_name = f"{parent_table}_{year}_q{quarter}" + + return PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=parent_table, + partition_type="RANGE", + partition_values={"from": start_date.isoformat(), "to": end_date.isoformat()}, + schema=schema, + ) + + +def create_range_partitions_for_interval( + dialect, + parent_table: str, + from_date: str, + to_date: str, + interval: str = "month", + schema: Optional[str] = None, +) -> Tuple[BaseExpression, ...]: + """Create multiple RANGE partitions covering a date interval. + + Generates a tuple of ``PostgresCreatePartitionExpression`` objects, + one for each unit (month or quarter) in the interval from + ``from_date`` to ``to_date``. + + This function returns **multiple** expressions instead of a single + one, so it must be called programmatically rather than through the + standard named expression resolver (which expects a single + ``BaseExpression``). + + Args: + dialect: The PostgreSQL dialect instance. + parent_table: Name of the partitioned parent table. + from_date: Start date in ``YYYY-MM-DD`` format (inclusive). + to_date: End date in ``YYYY-MM-DD`` format (exclusive). + interval: Partition interval: ``"month"`` or ``"quarter"``. + schema: Optional schema name. + + Returns: + Tuple of PostgresCreatePartitionExpression instances, one per + interval unit. + + Raises: + ValueError: If interval is not ``"month"`` or ``"quarter"``, or + if date range is invalid. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> exprs = create_range_partitions_for_interval( + ... dialect, "orders", "2026-01-01", "2026-03-01") + >>> len(exprs) + 2 + >>> exprs[0].partition_name + 'orders_2026_01' + """ + if interval not in ("month", "quarter"): + raise ValueError(f"Invalid interval: {interval!r}. Must be 'month' or 'quarter'.") + + try: + start = date.fromisoformat(from_date) + end = date.fromisoformat(to_date) + except (ValueError, TypeError) as exc: + raise ValueError( + f"Invalid date format. Expected YYYY-MM-DD, got from_date={from_date!r}, to_date={to_date!r}." + ) from exc + + if start >= end: + raise ValueError("from_date must be before to_date.") + + results: Tuple[BaseExpression, ...] = () + + current = start + while current < end: + if interval == "month": + expr = create_range_partition_for_month( + dialect, parent_table, current.year, current.month, schema=schema, + ) + # Advance to next month + next_month = current.month + 1 + next_year = current.year + if next_month > 12: + next_month = 1 + next_year += 1 + current = date(next_year, next_month, 1) + else: # quarter + # Find quarter for current date + q = (current.month - 1) // 3 + 1 + expr = create_range_partition_for_quarter( + dialect, parent_table, current.year, q, schema=schema, + ) + # Advance to next quarter start + q_start_month = (q * 3) + 1 + q_start_year = current.year + if q_start_month > 12: + q_start_month = 1 + q_start_year += 1 + current = date(q_start_year, q_start_month, 1) + + results = results + (expr,) + + return results diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index eb8b009c..90c64355 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -71,10 +71,32 @@ # Additional protocols from original file from .query_optimization import PostgresQueryOptimizationSupport -from .sql_syntax import PostgresSQLSyntaxSupport from .logical_replication import PostgresLogicalReplicationSupport from .parallel_query import PostgresParallelQuerySupport +# Per-feature protocol imports +from .cte import PostgresCTESupport +from .window import PostgresWindowSupport +from .filter_clause import PostgresFilterSupport +from .returning import PostgresReturningSupport +from .grouping import PostgresGroupingSupport +from .explain import PostgresExplainSupport +from .merge import PostgresMergeSupport +from .upsert import PostgresUpsertSupport +from .lateral_join import PostgresLateralJoinSupport +from .set_operation import PostgresSetOperationSupport +from .ilike import PostgresILIKESupport +from .join import PostgresJoinSupport +from .truncate import PostgresTruncateSupport +from .schema import PostgresSchemaSupport +from .sequence import PostgresSequenceSupport +from .transaction import PostgresTransactionSupport +from .view import PostgresViewSupport +from .xml import PostgresXMLSupport +from .collation import PostgresCollationSupport +from .ordered_set_aggregation import PostgresOrderedSetAggSupport +from .features import PostgresFeaturesSupport + __all__ = [ # Base "PostgresExtensionInfo", @@ -135,7 +157,28 @@ "PostgresAddressStandardizerSupport", # Additional protocols "PostgresQueryOptimizationSupport", - "PostgresSQLSyntaxSupport", "PostgresLogicalReplicationSupport", "PostgresParallelQuerySupport", + # Per-feature protocols + "PostgresCTESupport", + "PostgresWindowSupport", + "PostgresFilterSupport", + "PostgresReturningSupport", + "PostgresGroupingSupport", + "PostgresExplainSupport", + "PostgresMergeSupport", + "PostgresUpsertSupport", + "PostgresLateralJoinSupport", + "PostgresSetOperationSupport", + "PostgresILIKESupport", + "PostgresJoinSupport", + "PostgresTruncateSupport", + "PostgresSchemaSupport", + "PostgresSequenceSupport", + "PostgresTransactionSupport", + "PostgresViewSupport", + "PostgresXMLSupport", + "PostgresCollationSupport", + "PostgresOrderedSetAggSupport", + "PostgresFeaturesSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/collation.py new file mode 100644 index 00000000..7d78fc2b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/collation.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/collation.py +"""PostgreSQL collation feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresCollationSupport(Protocol): + """PostgreSQL collation feature support protocol.""" + + def supports_collate_expression(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/cte.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/cte.py new file mode 100644 index 00000000..384986c5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/cte.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/cte.py +"""PostgreSQL cte feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresCTESupport(Protocol): + """PostgreSQL cte feature support protocol.""" + + def supports_basic_cte(self)-> bool: ... + + def supports_recursive_cte(self)-> bool: ... + + def supports_materialized_cte(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py index 1d311f6f..8ae185da 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py @@ -5,12 +5,17 @@ that extend beyond standard SQL. """ +from enum import Enum # noqa: F401 from typing import Protocol, runtime_checkable, Optional, Tuple, List, Dict, Any, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import IndexSupport -if TYPE_CHECKING: - from ...expression.ddl import PostgresReindexExpression +if TYPE_CHECKING: # pragma: no cover + from ...expression.ddl import ( + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, # noqa: F401 + PostgresReindexExpression, + ) @runtime_checkable @@ -53,7 +58,7 @@ def supports_safe_hash_index(self) -> bool: Native feature, PostgreSQL 10+. Hash indexes are now fully WAL-logged and crash-safe. """ - ... + ... # pragma: no cover def supports_parallel_create_index(self) -> bool: """Whether parallel B-tree index build is supported. @@ -61,7 +66,7 @@ def supports_parallel_create_index(self) -> bool: Native feature, PostgreSQL 11+. Enables parallel workers for B-tree index creation. """ - ... + ... # pragma: no cover def supports_gist_include(self) -> bool: """Whether GiST indexes support INCLUDE clause. @@ -69,7 +74,7 @@ def supports_gist_include(self) -> bool: Native feature, PostgreSQL 12+. Allows including non-key columns in GiST indexes. """ - ... + ... # pragma: no cover def supports_reindex_concurrently(self) -> bool: """Whether REINDEX CONCURRENTLY is supported. @@ -77,7 +82,7 @@ def supports_reindex_concurrently(self) -> bool: Native feature, PostgreSQL 12+. Enables non-blocking index rebuilding. """ - ... + ... # pragma: no cover def supports_btree_deduplication(self) -> bool: """Whether B-tree deduplication is supported. @@ -85,7 +90,7 @@ def supports_btree_deduplication(self) -> bool: Native feature, PostgreSQL 13+. Reduces index size for columns with many duplicate values. """ - ... + ... # pragma: no cover def supports_brin_multivalue(self) -> bool: """Whether BRIN indexes support multiple min/max values. @@ -93,7 +98,7 @@ def supports_brin_multivalue(self) -> bool: Native feature, PostgreSQL 14+. Allows BRIN indexes to store multiple value ranges per block. """ - ... + ... # pragma: no cover def supports_brin_bloom(self) -> bool: """Whether BRIN bloom filter indexes are supported. @@ -101,7 +106,7 @@ def supports_brin_bloom(self) -> bool: Native feature, PostgreSQL 14+. Enables bloom filter ops for BRIN indexes. """ - ... + ... # pragma: no cover def supports_spgist_include(self) -> bool: """Whether SP-GiST indexes support INCLUDE clause. @@ -109,7 +114,7 @@ def supports_spgist_include(self) -> bool: Native feature, PostgreSQL 14+. Allows including non-key columns in SP-GiST indexes. """ - ... + ... # pragma: no cover def supports_streaming_btree_index_build(self) -> bool: """Whether streaming B-tree index build is supported. @@ -117,7 +122,7 @@ def supports_streaming_btree_index_build(self) -> bool: Native feature, PostgreSQL 18+. Allows concurrent index builds with less memory usage. """ - ... + ... # pragma: no cover def supports_create_statistics(self) -> bool: """Whether CREATE STATISTICS for extended statistics is supported. @@ -126,7 +131,7 @@ def supports_create_statistics(self) -> bool: Allows creating extended statistics objects for better query planning on column correlations. """ - ... + ... # pragma: no cover def supports_statistics_mcv(self) -> bool: """Whether MCV (Most Common Values) statistics are supported. @@ -135,14 +140,14 @@ def supports_statistics_mcv(self) -> bool: Extended statistics can include MCV lists for better estimation of combined column values. """ - ... + ... # pragma: no cover def supports_concurrent_unique_nulls_not_distinct(self) -> bool: """Whether CONCURRENT unique index with NULLS NOT DISTINCT is supported. Native feature, PostgreSQL 16+. """ - ... + ... # pragma: no cover def supports_statistics_dependencies(self) -> bool: """Whether statistics dependencies are supported. @@ -151,7 +156,7 @@ def supports_statistics_dependencies(self) -> bool: Functional dependencies between columns can be tracked for better row estimation. """ - ... + ... # pragma: no cover def supports_statistics_ndistinct(self) -> bool: """Whether NDistinct statistics are supported. @@ -159,7 +164,23 @@ def supports_statistics_ndistinct(self) -> bool: Native feature, PostgreSQL 10+. Tracks number of distinct values for column groups. """ - ... + ... # pragma: no cover + + def format_alter_index_statement( + self, expr: "PostgresAlterIndexExpression" + ) -> Tuple[str, tuple]: + """Format ALTER INDEX statement with PostgreSQL-specific operations. + + Supports RENAME TO, SET TABLESPACE, SET/RESET storage parameters, + ALTER COLUMN SET STATISTICS, and ALL IN TABLESPACE operations. + + Args: + expr: PostgresAlterIndexExpression containing action details + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... # pragma: no cover def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[str, tuple]: """Format REINDEX statement with PostgreSQL-specific options. @@ -170,7 +191,7 @@ def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[s Returns: Tuple of (SQL string, parameters tuple) """ - ... + ... # pragma: no cover def format_create_index_pg_statement( self, @@ -209,4 +230,4 @@ def format_create_index_pg_statement( Returns: Tuple of (SQL string, parameters tuple) """ - ... + ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py index aba185d7..f856c361 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/partition.py @@ -7,16 +7,20 @@ from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport + if TYPE_CHECKING: from ...expression.ddl import ( + PartitionValue, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, + PostgresPartitionMetadataExpression, ) @runtime_checkable -class PostgresPartitionSupport(Protocol): +class PostgresPartitionSupport(PartitionSupport, Protocol): """PostgreSQL partitioning enhancements protocol. Feature Source: Native support (no extension required) @@ -76,6 +80,14 @@ def supports_concurrent_detach(self) -> bool: """ ... + def supports_concurrent_attach(self) -> bool: + """Whether CONCURRENTLY ATTACH PARTITION is supported. + + Native feature, PostgreSQL 14+. + Enables non-blocking partition attachment. + """ + ... + def supports_partition_bounds_expression(self) -> bool: """Whether partition bounds can use expressions. @@ -111,6 +123,17 @@ def format_create_partition_statement(self, expr: "PostgresCreatePartitionExpres """ ... + def format_partition_value(self, expr: "PartitionValue") -> Tuple[str, tuple]: + """Format a partition bound value from expression. + + Args: + expr: PartitionValue with the bound value. + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... + def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... DETACH PARTITION statement from expression. @@ -125,10 +148,40 @@ def format_detach_partition_statement(self, expr: "PostgresDetachPartitionExpres def format_attach_partition_statement(self, expr: "PostgresAttachPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ATTACH PARTITION statement from expression. + The expression supports: + - RANGE / LIST / HASH partition types with corresponding bound values. + - DEFAULT partition (PG 11+) via ``partition_values={"default": True}``. + - CONCURRENTLY mode (PG 14+) via ``concurrently=True``. + Args: - expr: PostgresAttachPartitionExpression with attach details + expr: PostgresAttachPartitionExpression with attach details. + ``expr.concurrently`` controls CONCURRENTLY mode. Returns: Tuple of (SQL string, parameters tuple) + + Raises: + ValueError: If partition_type is invalid or required bounds missing. + ValueError: If concurrently is used on PostgreSQL < 14. + """ + ... + + def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpression") -> Tuple[str, tuple]: + """Format partition metadata introspection query from expression. + + Builds a parameterised query against pg_catalog to inspect a partitioned + table's partition key, child partitions, and their bounds. + + When schema is None, the query omits the namespace filter to search + across all schemas. + + Args: + expr: PostgresPartitionMetadataExpression with parent table details. + + Returns: + Tuple of (SQL string with %s placeholders, params tuple). + + Raises: + UnsupportedFeatureError: If PostgreSQL version < 10. """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py new file mode 100644 index 00000000..470f9a55 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py @@ -0,0 +1,13 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py +"""PostgreSQL explain feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresExplainSupport(Protocol): + """PostgreSQL explain feature support protocol.""" + + def supports_explain_analyze(self)-> bool: ... + + def supports_explain_format(self, format_type: str)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pg_partman.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pg_partman.py index a43f4f86..86ce3860 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pg_partman.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pg_partman.py @@ -1,45 +1,118 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pg_partman.py """pg_partman extension protocol definition. -This module defines the protocol for pg_partman partition management -functionality in PostgreSQL. +Defines the PostgresPgPartmanSupport protocol which declares the interface +for pg_partman automated partition management operations including +create_parent, run_maintenance, and part_config management. -For SQL expression generation of partition creation and maintenance functions, -use the function factories in ``functions/pg_partman.py`` instead of the -removed format_* methods. +Reference: + https://github.com/pgpartman/pg_partman """ -from typing import Optional, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ...expression.ddl import ( + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, + PostgresPgPartmanDeleteConfigExpression, + ) @runtime_checkable class PostgresPgPartmanSupport(Protocol): """pg_partman partition management extension protocol. - Feature Source: Extension support (requires pg_partman extension) - - pg_partman provides partition management: - - Auto create partitions - - Partition maintenance - - Time and serial partitioning - - Extension Information: - - Extension name: pg_partman - - Install command: CREATE EXTENSION pg_partman; - - Minimum version: 4.0 - - Documentation: https://github.com/pgpartman/pg_partman + Declares the interface for pg_partman operations: + - supports_pg_partman / supports_pg_partman_auto_partition: capability checks + - format_pg_partman_create_parent: initialize automated partitioning + - format_pg_partman_run_maintenance: trigger maintenance + - format_pg_partman_update_config: update partition config + - format_pg_partman_delete_config: remove partition config """ def supports_pg_partman(self) -> bool: - """Whether pg_partman extension is available.""" + """Whether pg_partman extension is installed. + + Returns: + True if the pg_partman extension is available in the current database. + """ ... def supports_pg_partman_auto_partition(self) -> bool: - """Whether pg_partman supports auto partitioning.""" + """Whether pg_partman supports auto partitioning. + + Returns: + True if pg_partman's auto partitioning feature is available. + """ ... - def format_auto_partition_config(self, table_name: str, automatic: bool = True, - infinite_time_partitions: bool = False, - retention: Optional[str] = None) -> str: - """Format auto partition configuration update.""" + def format_pg_partman_create_parent( + self, + expr: "PostgresPgPartmanCreateParentExpression", + ) -> tuple: + """Format pg_partman create_parent() expression. + + Builds a SELECT statement that calls pg_partman's create_parent + function with named parameters. Core parameters (parent_table, + control, interval, type, premake) are always included; optional + parameters (start_partition, primary_key, default_table, + constraint_cols, template_table, epoch, jobmon) are included + only when set. + + Args: + expr: PostgresPgPartmanCreateParentExpression with all params. + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + ... + + def format_pg_partman_run_maintenance( + self, + expr: "PostgresPgPartmanRunMaintenanceExpression", + ) -> tuple: + """Format pg_partman run_maintenance() expression. + + Args: + expr: PostgresPgPartmanRunMaintenanceExpression with optional + parent_table scope. + + Returns: + Tuple of (SELECT function_call SQL, params tuple). + """ + ... + + def format_pg_partman_update_config( + self, + expr: "PostgresPgPartmanUpdateConfigExpression", + ) -> tuple: + """Format pg_partman part_config update expression. + + Args: + expr: PostgresPgPartmanUpdateConfigExpression with config options + to update. + + Returns: + Tuple of (UPDATE SQL, params tuple). + + Raises: + ValueError: If no config options are specified. + """ + ... + + def format_pg_partman_delete_config( + self, + expr: "PostgresPgPartmanDeleteConfigExpression", + ) -> tuple: + """Format pg_partman part_config delete expression. + + Args: + expr: PostgresPgPartmanDeleteConfigExpression identifying the + parent table whose config should be removed. + + Returns: + Tuple of (DELETE SQL, params tuple). + """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py new file mode 100644 index 00000000..3bfb58fe --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py @@ -0,0 +1,33 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py +"""PostgreSQL features feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresFeaturesSupport(Protocol): + """PostgreSQL features feature support protocol.""" + + def supports_generated_columns(self)-> bool: ... + + def supports_cte_search_cycle(self)-> bool: ... + + def supports_fetch_with_ties(self)-> bool: ... + + def supports_call_statement(self)-> bool: ... + + def supports_stored_procedure_transaction_control(self)-> bool: ... + + def supports_sql_body_functions(self)-> bool: ... + + def supports_nulls_not_distinct_unique(self)-> bool: ... + + def supports_regexp_like(self)-> bool: ... + + def supports_random_normal(self)-> bool: ... + + def supports_json_table_nested_path(self)-> bool: ... + + def supports_merge_with_cte(self)-> bool: ... + + def supports_update_returning_old(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/filter_clause.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/filter_clause.py new file mode 100644 index 00000000..6a83507f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/filter_clause.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/filter_clause.py +"""PostgreSQL filter clause feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresFilterSupport(Protocol): + """PostgreSQL filter clause feature support protocol.""" + + def supports_filter_clause(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/grouping.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/grouping.py new file mode 100644 index 00000000..7e0610b7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/grouping.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/grouping.py +"""PostgreSQL grouping feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresGroupingSupport(Protocol): + """PostgreSQL grouping feature support protocol.""" + + def supports_rollup(self)-> bool: ... + + def supports_cube(self)-> bool: ... + + def supports_grouping_sets(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py new file mode 100644 index 00000000..815eb213 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py +"""PostgreSQL ilike feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresILIKESupport(Protocol): + """PostgreSQL ilike feature support protocol.""" + + def supports_ilike(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/join.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/join.py new file mode 100644 index 00000000..b01bc5a0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/join.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/join.py +"""PostgreSQL join feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresJoinSupport(Protocol): + """PostgreSQL join feature support protocol.""" + + def supports_right_join(self)-> bool: ... + + def supports_full_join(self)-> bool: ... + + def supports_wildcard(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/lateral_join.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/lateral_join.py new file mode 100644 index 00000000..c4a75034 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/lateral_join.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/lateral_join.py +"""PostgreSQL lateral join feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresLateralJoinSupport(Protocol): + """PostgreSQL lateral join feature support protocol.""" + + def supports_lateral_join(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/merge.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/merge.py new file mode 100644 index 00000000..8b9e4446 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/merge.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/merge.py +"""PostgreSQL merge feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresMergeSupport(Protocol): + """PostgreSQL merge feature support protocol.""" + + def supports_merge_statement(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ordered_set_aggregation.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ordered_set_aggregation.py new file mode 100644 index 00000000..91b0f570 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ordered_set_aggregation.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ordered_set_aggregation.py +"""PostgreSQL ordered set aggregation feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresOrderedSetAggSupport(Protocol): + """PostgreSQL ordered set aggregation feature support protocol.""" + + def supports_ordered_set_aggregation(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py new file mode 100644 index 00000000..d0c8d38d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py +"""PostgreSQL returning feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresReturningSupport(Protocol): + """PostgreSQL returning feature support protocol.""" + + def supports_returning_insert(self)-> bool: ... + + def supports_returning_update(self)-> bool: ... + + def supports_returning_delete(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py new file mode 100644 index 00000000..0356671e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py @@ -0,0 +1,19 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py +"""PostgreSQL schema feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresSchemaSupport(Protocol): + """PostgreSQL schema feature support protocol.""" + + def supports_create_schema(self)-> bool: ... + + def supports_drop_schema(self)-> bool: ... + + def supports_schema_if_not_exists(self)-> bool: ... + + def supports_schema_if_exists(self)-> bool: ... + + def supports_schema_cascade(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/sequence.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/sequence.py new file mode 100644 index 00000000..ce507088 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/sequence.py @@ -0,0 +1,13 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/sequence.py +"""PostgreSQL sequence feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresSequenceSupport(Protocol): + """PostgreSQL sequence feature support protocol.""" + + def supports_create_sequence(self)-> bool: ... + + def supports_drop_sequence(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/set_operation.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/set_operation.py new file mode 100644 index 00000000..6fc9037e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/set_operation.py @@ -0,0 +1,23 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/set_operation.py +"""PostgreSQL set operation feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresSetOperationSupport(Protocol): + """PostgreSQL set operation feature support protocol.""" + + def supports_union(self)-> bool: ... + + def supports_union_all(self)-> bool: ... + + def supports_intersect(self)-> bool: ... + + def supports_except(self)-> bool: ... + + def supports_set_operation_order_by(self)-> bool: ... + + def supports_set_operation_limit_offset(self)-> bool: ... + + def supports_set_operation_for_update(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/sql_syntax.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/sql_syntax.py deleted file mode 100644 index 789fcfec..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/sql_syntax.py +++ /dev/null @@ -1,126 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/protocols/sql_syntax.py -"""PostgreSQL SQL syntax enhancements protocol. - -This module defines the PostgresSQLSyntaxSupport protocol for PostgreSQL-specific -SQL syntax features that extend beyond the SQL standard. -""" - -from typing import Protocol, runtime_checkable - - -@runtime_checkable -class PostgresSQLSyntaxSupport(Protocol): - """PostgreSQL SQL syntax enhancements protocol. - - Feature Source: Native support (no extension required) - - PostgreSQL SQL syntax features: - - Generated columns (PG 12+) - - CTE SEARCH/CYCLE (PG 14+) - - FETCH WITH TIES (PG 13+) - - Official Documentation: - - Generated Columns: https://www.postgresql.org/docs/current/ddl-generated-columns.html - - CTE SEARCH/CYCLE: https://www.postgresql.org/docs/current/queries-with.html - - FETCH WITH TIES: https://www.postgresql.org/docs/current/sql-select.html#SQL-LIMIT - - Version Requirements: - - Generated columns: PostgreSQL 12+ - - FETCH WITH TIES: PostgreSQL 13+ - - CTE SEARCH/CYCLE: PostgreSQL 14+ - """ - - def supports_generated_columns(self) -> bool: - """Whether generated columns are supported. - - Native feature, PostgreSQL 12+. - Enables GENERATED ALWAYS AS columns. - """ - ... - - def supports_cte_search_cycle(self) -> bool: - """Whether CTE SEARCH and CYCLE clauses are supported. - - Native feature, PostgreSQL 14+. - Enables SQL-standard SEARCH and CYCLE in CTEs. - """ - ... - - def supports_fetch_with_ties(self) -> bool: - """Whether FETCH FIRST WITH TIES is supported. - - Native feature, PostgreSQL 13+. - Includes tied rows in result set. - """ - ... - - def supports_call_statement(self) -> bool: - """Whether CALL statement for stored procedures is supported. - - Native feature, PostgreSQL 11+. - PostgreSQL 11 introduced stored procedures that can manage their own transactions. - """ - ... - - def supports_stored_procedure_transaction_control(self) -> bool: - """Whether stored procedures with transaction control are supported. - - Native feature, PostgreSQL 11+. - Procedures can use COMMIT and ROLLBACK within the procedure body. - """ - ... - - def supports_sql_body_functions(self) -> bool: - """Whether SQL-body functions are supported. - - Native feature, PostgreSQL 14+. - Functions can use SQL-standard function bodies with - BEGIN ATOMIC ... END syntax. - """ - ... - - def supports_nulls_not_distinct_unique(self) -> bool: - """Whether UNIQUE NULLS NOT DISTINCT is supported. - - Native feature, PostgreSQL 15+. - Creates unique constraints that treat NULL values as equal. - """ - ... - - def supports_regexp_like(self) -> bool: - """Whether REGEXP_LIKE function is supported. - - Native feature, PostgreSQL 16+. - SQL-standard regular expression matching function. - """ - ... - - def supports_random_normal(self) -> bool: - """Whether random_normal() function is supported. - - Native feature, PostgreSQL 16+. - Returns normally distributed random values. - """ - ... - - def supports_json_table_nested_path(self) -> bool: - """Whether enhanced JSON_TABLE NESTED PATH is supported. - - Native feature, PostgreSQL 17+. - """ - ... - - def supports_merge_with_cte(self) -> bool: - """Whether MERGE statement with CTE is supported. - - Native feature, PostgreSQL 17+. - """ - ... - - def supports_update_returning_old(self) -> bool: - """Whether UPDATE RETURNING OLD is supported. - - Native feature, PostgreSQL 17+. - Allows returning old values in UPDATE RETURNING. - """ - ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index 7fb984c1..a7842ac2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -36,3 +36,18 @@ def supports_table_inheritance(self) -> bool: Syntax: CREATE TABLE child (...) INHERITS (parent); """ ... + + def supports_table_like_syntax(self) -> bool: + """Whether CREATE TABLE (LIKE ...) syntax is supported. + + PostgreSQL supports CREATE TABLE (LIKE other_table) with + INCLUDING/EXCLUDING options to control what gets copied. + """ + ... + + def format_create_table_like(self, expr) -> tuple: + """Format CREATE TABLE (LIKE ...) statement.""" + + def format_column_definition(self, col_def) -> tuple: + """Format a column definition for PostgreSQL, allowing array types.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py new file mode 100644 index 00000000..50fb4665 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py @@ -0,0 +1,19 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py +"""PostgreSQL transaction feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresTransactionSupport(Protocol): + """PostgreSQL transaction feature support protocol.""" + + def supports_transaction_mode(self)-> bool: ... + + def supports_isolation_level_in_begin(self)-> bool: ... + + def supports_read_only_transaction(self)-> bool: ... + + def supports_deferrable_transaction(self)-> bool: ... + + def supports_savepoint(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py new file mode 100644 index 00000000..ee45d091 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py @@ -0,0 +1,13 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py +"""PostgreSQL truncate feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresTruncateSupport(Protocol): + """PostgreSQL truncate feature support protocol.""" + + def supports_truncate_restart_identity(self)-> bool: ... + + def supports_truncate_cascade(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py index c3a652e9..1e47db69 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py @@ -38,6 +38,10 @@ class PostgresJSONBEnhancedSupport(Protocol): - Numeric infinity in JSONB: PostgreSQL 17+ """ + def format_json_expression(self, expr): + """Format a PostgreSQL JSON expression.""" + ... + def supports_json_type(self) -> bool: """Whether JSON data type is supported. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py new file mode 100644 index 00000000..a6e144e4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py +"""PostgreSQL upsert feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresUpsertSupport(Protocol): + """PostgreSQL upsert feature support protocol.""" + + def supports_upsert(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py new file mode 100644 index 00000000..8cc0e29a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py @@ -0,0 +1,19 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py +"""PostgreSQL view feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresViewSupport(Protocol): + """PostgreSQL view feature support protocol.""" + + def supports_or_replace_view(self) -> bool: ... + def supports_temporary_view(self) -> bool: ... + def supports_if_exists_view(self) -> bool: ... + def supports_view_check_option(self) -> bool: ... + def supports_cascade_view(self) -> bool: ... + def supports_materialized_view(self) -> bool: ... + def supports_refresh_materialized_view(self) -> bool: ... + def supports_materialized_view_tablespace(self) -> bool: ... + def supports_materialized_view_storage_options(self) -> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/window.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/window.py new file mode 100644 index 00000000..ceff0e35 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/window.py @@ -0,0 +1,13 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/window.py +"""PostgreSQL window feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresWindowSupport(Protocol): + """PostgreSQL window feature support protocol.""" + + def supports_window_functions(self)-> bool: ... + + def supports_window_frame_clause(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/xml.py new file mode 100644 index 00000000..3c041794 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/xml.py @@ -0,0 +1,35 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/xml.py +"""PostgreSQL xml feature support protocol.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PostgresXMLSupport(Protocol): + """PostgreSQL xml feature support protocol.""" + + def supports_xmlparse(self)-> bool: ... + + def supports_xmlserialize(self)-> bool: ... + + def supports_xmlelement(self)-> bool: ... + + def supports_xmlattributes(self)-> bool: ... + + def supports_xmlforest(self)-> bool: ... + + def supports_xmlconcat(self)-> bool: ... + + def supports_xmlcomment(self)-> bool: ... + + def supports_xmlpi(self)-> bool: ... + + def supports_xmlroot(self)-> bool: ... + + def supports_xmlagg(self)-> bool: ... + + def supports_xmlquery(self)-> bool: ... + + def supports_xmlexists(self)-> bool: ... + + def supports_xmltable(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py index bd635af9..e0dbf7ae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/transaction.py @@ -5,7 +5,7 @@ All SQL generation is delegated to the dialect's format_* methods via the Expression system, ensuring single SQL statement per format method. """ -from typing import Dict, Optional, List, TYPE_CHECKING +from typing import Dict, Optional, List, TYPE_CHECKING, Tuple import logging from rhosocial.activerecord.backend.errors import TransactionError @@ -44,7 +44,7 @@ class PostgresTransactionMixin: IsolationLevel.SERIALIZABLE: "SERIALIZABLE", } - def _build_begin_sql(self) -> tuple: + def _build_begin_sql(self) -> Tuple[str, tuple]: """Build BEGIN TRANSACTION SQL with PostgreSQL-specific options. This method creates a BeginTransactionExpression and delegates @@ -115,7 +115,11 @@ class PostgresTransactionManager(PostgresTransactionMixin, TransactionManager): PostgreSQL-specific features like DEFERRABLE are supported. """ - def __init__(self, backend: "PostgresBackend", logger=None): + def __init__( + self, + backend: "PostgresBackend", + logger: Optional[logging.Logger] = None, + ) -> None: """Initialize PostgreSQL transaction manager. Args: @@ -197,7 +201,11 @@ class AsyncPostgresTransactionManager(PostgresTransactionMixin, AsyncTransaction PostgreSQL-specific features like DEFERRABLE are supported. """ - def __init__(self, backend: "AsyncPostgresBackend", logger=None): + def __init__( + self, + backend: "AsyncPostgresBackend", + logger: Optional[logging.Logger] = None, + ) -> None: """Initialize async PostgreSQL transaction manager. Args: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/__init__.py b/tests/benchmark/activerecord_bulk/__init__.py similarity index 100% rename from tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/__init__.py rename to tests/benchmark/activerecord_bulk/__init__.py diff --git a/tests/benchmark/activerecord_bulk/conftest.py b/tests/benchmark/activerecord_bulk/conftest.py new file mode 100644 index 00000000..e28d1fc9 --- /dev/null +++ b/tests/benchmark/activerecord_bulk/conftest.py @@ -0,0 +1,30 @@ +"""Conftest for ActiveRecord bulk benchmark bridge tests.""" + +from rhosocial.activerecord.testsuite.benchmark.conftest import ( # noqa: F401 + benchmark_size, +) +from rhosocial.activerecord.testsuite.benchmark.crud.conftest import ( # noqa: F401 + crud_sync_context, + crud_async_context, +) + + +def pytest_addoption(parser): + try: + parser.addoption( + "--benchmark-size", + action="store", + default="small", + choices=("small", "medium", "large"), + help="Data size for rhosocial benchmark scenarios.", + ) + except ValueError: + pass + + +def pytest_configure(config): + config.addinivalue_line("markers", "benchmark: Mark tests as performance benchmarks") + config.addinivalue_line("markers", "benchmark_crud: Mark CRUD benchmark tests") + config.addinivalue_line("markers", "benchmark_sync: Mark synchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_async: Mark asynchronous benchmark tests") + config.addinivalue_line("markers", "benchmark_write: Mark write-oriented benchmark tests") diff --git a/tests/benchmark/activerecord_bulk/test_bulk_create.py b/tests/benchmark/activerecord_bulk/test_bulk_create.py new file mode 100644 index 00000000..6713f01e --- /dev/null +++ b/tests/benchmark/activerecord_bulk/test_bulk_create.py @@ -0,0 +1,3 @@ +# Bridge file for bulk_create benchmark tests from the testsuite. +from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create_sync import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create_async import * # noqa: F401, F403 diff --git a/tests/benchmark/backend/conftest.py b/tests/benchmark/backend/conftest.py index 543e4860..58825d1c 100644 --- a/tests/benchmark/backend/conftest.py +++ b/tests/benchmark/backend/conftest.py @@ -42,13 +42,16 @@ class BackendBenchmarkContext: def pytest_addoption(parser): - parser.addoption( - "--benchmark-size", - action="store", - default="small", - choices=("small", "medium", "large"), - help="Data size for PostgreSQL backend benchmark scenarios.", - ) + try: + parser.addoption( + "--benchmark-size", + action="store", + default="small", + choices=("small", "medium", "large"), + help="Data size for PostgreSQL backend benchmark scenarios.", + ) + except ValueError: + pass @pytest.fixture(scope="function") diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 52ecf7db..ab385dbb 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -13,20 +13,20 @@ import os import sys import logging -from typing import Type, List, Tuple, Optional +from typing import Type, List, Tuple, Optional # noqa: F401 logger = logging.getLogger(__name__) -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter -from rhosocial.activerecord.testsuite.feature.basic.interfaces import IBasicProvider -from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol +from rhosocial.activerecord.model import ActiveRecord # noqa: E402 +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter # noqa: E402 +from rhosocial.activerecord.testsuite.feature.basic.interfaces import IBasicProvider # noqa: E402 +from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol # noqa: E402 # Import the fixture selector utility -from rhosocial.activerecord.testsuite.utils import select_fixture +from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 # Import base version models (Python 3.8+) -from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( +from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 User as UserBase, TypeCase as TypeCaseBase, ValidatedFieldUser as ValidatedFieldUserBase, TypeTestModel as TypeTestModelBase, ValidatedUser as ValidatedUserBase, TypeAdapterTest as TypeAdapterTestBase, YesOrNoBooleanAdapter, @@ -34,8 +34,11 @@ MappedUser as MappedUserBase, MappedPost as MappedPostBase, MappedComment as MappedCommentBase, ColumnMappingModel as ColumnMappingModelBase, MixedAnnotationModel as MixedAnnotationModelBase ) +from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 + BulkUser as BulkUserBase, AsyncBulkUser as AsyncBulkUserBase +) # Import async base models -from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( +from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 AsyncUser as AsyncUserBase, AsyncTypeCase as AsyncTypeCaseBase, AsyncValidatedUser as AsyncValidatedUserBase, AsyncValidatedFieldUser as AsyncValidatedFieldUserBase, AsyncTypeTestModel as AsyncTypeTestModelBase, AsyncTypeAdapterTest as AsyncTypeAdapterTestBase, @@ -50,7 +53,7 @@ TypeAdapterTest310 = PydanticValidatedModel310 = MappedUser310 = MappedPost310 = MappedComment310 = None ColumnMappingModel310 = MixedAnnotationModel310 = None AsyncUser310 = AsyncTypeCase310 = AsyncValidatedFieldUser310 = AsyncTypeTestModel310 = None -AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncPydanticValidatedModel310 = AsyncMappedUser310 = AsyncMappedPost310 = None +AsyncValidatedUser310 = AsyncTypeAdapterTest310 = AsyncPydanticValidatedModel310 = AsyncMappedUser310 = AsyncMappedPost310 = None # noqa: E501 AsyncMappedComment310 = AsyncColumnMappingModel310 = AsyncMixedAnnotationModel310 = None if sys.version_info >= (3, 10): @@ -69,7 +72,7 @@ AsyncPydanticValidatedModel as AsyncPydanticValidatedModel310, AsyncMappedUser as AsyncMappedUser310, AsyncMappedPost as AsyncMappedPost310, AsyncMappedComment as AsyncMappedComment310, - AsyncColumnMappingModel as AsyncColumnMappingModel310, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel310 + AsyncColumnMappingModel as AsyncColumnMappingModel310, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel310 # noqa: E501 ) except ImportError as e: logger.warning(f"Failed to import Python 3.10+ fixtures: {e}") @@ -79,7 +82,7 @@ TypeAdapterTest311 = PydanticValidatedModel311 = MappedUser311 = MappedPost311 = MappedComment311 = None ColumnMappingModel311 = MixedAnnotationModel311 = None AsyncUser311 = AsyncTypeCase311 = AsyncValidatedFieldUser311 = AsyncTypeTestModel311 = None -AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncPydanticValidatedModel311 = AsyncMappedUser311 = AsyncMappedPost311 = None +AsyncValidatedUser311 = AsyncTypeAdapterTest311 = AsyncPydanticValidatedModel311 = AsyncMappedUser311 = AsyncMappedPost311 = None # noqa: E501 AsyncMappedComment311 = AsyncColumnMappingModel311 = AsyncMixedAnnotationModel311 = None if sys.version_info >= (3, 11): @@ -98,7 +101,7 @@ AsyncPydanticValidatedModel as AsyncPydanticValidatedModel311, AsyncMappedUser as AsyncMappedUser311, AsyncMappedPost as AsyncMappedPost311, AsyncMappedComment as AsyncMappedComment311, - AsyncColumnMappingModel as AsyncColumnMappingModel311, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel311 + AsyncColumnMappingModel as AsyncColumnMappingModel311, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel311 # noqa: E501 ) except ImportError as e: logger.warning(f"Failed to import Python 3.11+ fixtures: {e}") @@ -108,7 +111,7 @@ TypeAdapterTest312 = PydanticValidatedModel312 = MappedUser312 = MappedPost312 = MappedComment312 = None ColumnMappingModel312 = MixedAnnotationModel312 = None AsyncUser312 = AsyncTypeCase312 = AsyncValidatedFieldUser312 = AsyncTypeTestModel312 = None -AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncPydanticValidatedModel312 = AsyncMappedUser312 = AsyncMappedPost312 = None +AsyncValidatedUser312 = AsyncTypeAdapterTest312 = AsyncPydanticValidatedModel312 = AsyncMappedUser312 = AsyncMappedPost312 = None # noqa: E501 AsyncMappedComment312 = AsyncColumnMappingModel312 = AsyncMixedAnnotationModel312 = None if sys.version_info >= (3, 12): @@ -127,7 +130,7 @@ AsyncPydanticValidatedModel as AsyncPydanticValidatedModel312, AsyncMappedUser as AsyncMappedUser312, AsyncMappedPost as AsyncMappedPost312, AsyncMappedComment as AsyncMappedComment312, - AsyncColumnMappingModel as AsyncColumnMappingModel312, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel312 + AsyncColumnMappingModel as AsyncColumnMappingModel312, AsyncMixedAnnotationModel as AsyncMixedAnnotationModel312 # noqa: E501 ) except ImportError as e: logger.warning(f"Failed to import Python 3.12+ fixtures: {e}") @@ -145,33 +148,35 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s # Select sync models User = _select_model_class(UserBase, User312, User311, User310, "User") TypeCase = _select_model_class(TypeCaseBase, TypeCase312, TypeCase311, TypeCase310, "TypeCase") -ValidatedFieldUser = _select_model_class(ValidatedFieldUserBase, ValidatedFieldUser312, ValidatedFieldUser311, ValidatedFieldUser310, "ValidatedFieldUser") -TypeTestModel = _select_model_class(TypeTestModelBase, TypeTestModel312, TypeTestModel311, TypeTestModel310, "TypeTestModel") -ValidatedUser = _select_model_class(ValidatedUserBase, ValidatedUser312, ValidatedUser311, ValidatedUser310, "ValidatedUser") -TypeAdapterTest = _select_model_class(TypeAdapterTestBase, TypeAdapterTest312, TypeAdapterTest311, TypeAdapterTest310, "TypeAdapterTest") -PydanticValidatedModel = _select_model_class(PydanticValidatedModelBase, PydanticValidatedModel312, PydanticValidatedModel311, PydanticValidatedModel310, "PydanticValidatedModel") +ValidatedFieldUser = _select_model_class(ValidatedFieldUserBase, ValidatedFieldUser312, ValidatedFieldUser311, ValidatedFieldUser310, "ValidatedFieldUser") # noqa: E501 +TypeTestModel = _select_model_class(TypeTestModelBase, TypeTestModel312, TypeTestModel311, TypeTestModel310, "TypeTestModel") # noqa: E501 +ValidatedUser = _select_model_class(ValidatedUserBase, ValidatedUser312, ValidatedUser311, ValidatedUser310, "ValidatedUser") # noqa: E501 +TypeAdapterTest = _select_model_class(TypeAdapterTestBase, TypeAdapterTest312, TypeAdapterTest311, TypeAdapterTest310, "TypeAdapterTest") # noqa: E501 +PydanticValidatedModel = _select_model_class(PydanticValidatedModelBase, PydanticValidatedModel312, PydanticValidatedModel311, PydanticValidatedModel310, "PydanticValidatedModel") # noqa: E501 MappedUser = _select_model_class(MappedUserBase, MappedUser312, MappedUser311, MappedUser310, "MappedUser") MappedPost = _select_model_class(MappedPostBase, MappedPost312, MappedPost311, MappedPost310, "MappedPost") -MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") -ColumnMappingModel = _select_model_class(ColumnMappingModelBase, ColumnMappingModel312, ColumnMappingModel311, ColumnMappingModel310, "ColumnMappingModel") -MixedAnnotationModel = _select_model_class(MixedAnnotationModelBase, MixedAnnotationModel312, MixedAnnotationModel311, MixedAnnotationModel310, "MixedAnnotationModel") +MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") # noqa: E501 +ColumnMappingModel = _select_model_class(ColumnMappingModelBase, ColumnMappingModel312, ColumnMappingModel311, ColumnMappingModel310, "ColumnMappingModel") # noqa: E501 +MixedAnnotationModel = _select_model_class(MixedAnnotationModelBase, MixedAnnotationModel312, MixedAnnotationModel311, MixedAnnotationModel310, "MixedAnnotationModel") # noqa: E501 # Select async models AsyncUser = _select_model_class(AsyncUserBase, AsyncUser312, AsyncUser311, AsyncUser310, "AsyncUser") -AsyncTypeCase = _select_model_class(AsyncTypeCaseBase, AsyncTypeCase312, AsyncTypeCase311, AsyncTypeCase310, "AsyncTypeCase") -AsyncValidatedFieldUser = _select_model_class(AsyncValidatedFieldUserBase, AsyncValidatedFieldUser312, AsyncValidatedFieldUser311, AsyncValidatedFieldUser310, "AsyncValidatedFieldUser") -AsyncTypeTestModel = _select_model_class(AsyncTypeTestModelBase, AsyncTypeTestModel312, AsyncTypeTestModel311, AsyncTypeTestModel310, "AsyncTypeTestModel") -AsyncValidatedUser = _select_model_class(AsyncValidatedUserBase, AsyncValidatedUser312, AsyncValidatedUser311, AsyncValidatedUser310, "AsyncValidatedUser") -AsyncTypeAdapterTest = _select_model_class(AsyncTypeAdapterTestBase, AsyncTypeAdapterTest312, AsyncTypeAdapterTest311, AsyncTypeAdapterTest310, "AsyncTypeAdapterTest") -AsyncPydanticValidatedModel = _select_model_class(AsyncPydanticValidatedModelBase, AsyncPydanticValidatedModel312, AsyncPydanticValidatedModel311, AsyncPydanticValidatedModel310, "AsyncPydanticValidatedModel") -AsyncMappedUser = _select_model_class(AsyncMappedUserBase, AsyncMappedUser312, AsyncMappedUser311, AsyncMappedUser310, "AsyncMappedUser") -AsyncMappedPost = _select_model_class(AsyncMappedPostBase, AsyncMappedPost312, AsyncMappedPost311, AsyncMappedPost310, "AsyncMappedPost") -AsyncMappedComment = _select_model_class(AsyncMappedCommentBase, AsyncMappedComment312, AsyncMappedComment311, AsyncMappedComment310, "AsyncMappedComment") -AsyncColumnMappingModel = _select_model_class(AsyncColumnMappingModelBase, AsyncColumnMappingModel312, AsyncColumnMappingModel311, AsyncColumnMappingModel310, "AsyncColumnMappingModel") -AsyncMixedAnnotationModel = _select_model_class(AsyncMixedAnnotationModelBase, AsyncMixedAnnotationModel312, AsyncMixedAnnotationModel311, AsyncMixedAnnotationModel310, "AsyncMixedAnnotationModel") +AsyncTypeCase = _select_model_class(AsyncTypeCaseBase, AsyncTypeCase312, AsyncTypeCase311, AsyncTypeCase310, "AsyncTypeCase") # noqa: E501 +AsyncValidatedFieldUser = _select_model_class(AsyncValidatedFieldUserBase, AsyncValidatedFieldUser312, AsyncValidatedFieldUser311, AsyncValidatedFieldUser310, "AsyncValidatedFieldUser") # noqa: E501 +AsyncTypeTestModel = _select_model_class(AsyncTypeTestModelBase, AsyncTypeTestModel312, AsyncTypeTestModel311, AsyncTypeTestModel310, "AsyncTypeTestModel") # noqa: E501 +AsyncValidatedUser = _select_model_class(AsyncValidatedUserBase, AsyncValidatedUser312, AsyncValidatedUser311, AsyncValidatedUser310, "AsyncValidatedUser") # noqa: E501 +AsyncTypeAdapterTest = _select_model_class(AsyncTypeAdapterTestBase, AsyncTypeAdapterTest312, AsyncTypeAdapterTest311, AsyncTypeAdapterTest310, "AsyncTypeAdapterTest") # noqa: E501 +AsyncPydanticValidatedModel = _select_model_class(AsyncPydanticValidatedModelBase, AsyncPydanticValidatedModel312, AsyncPydanticValidatedModel311, AsyncPydanticValidatedModel310, "AsyncPydanticValidatedModel") # noqa: E501 +AsyncMappedUser = _select_model_class(AsyncMappedUserBase, AsyncMappedUser312, AsyncMappedUser311, AsyncMappedUser310, "AsyncMappedUser") # noqa: E501 +AsyncMappedPost = _select_model_class(AsyncMappedPostBase, AsyncMappedPost312, AsyncMappedPost311, AsyncMappedPost310, "AsyncMappedPost") # noqa: E501 +AsyncMappedComment = _select_model_class(AsyncMappedCommentBase, AsyncMappedComment312, AsyncMappedComment311, AsyncMappedComment310, "AsyncMappedComment") # noqa: E501 +AsyncColumnMappingModel = _select_model_class(AsyncColumnMappingModelBase, AsyncColumnMappingModel312, AsyncColumnMappingModel311, AsyncColumnMappingModel310, "AsyncColumnMappingModel") # noqa: E501 +AsyncMixedAnnotationModel = _select_model_class(AsyncMixedAnnotationModelBase, AsyncMixedAnnotationModel312, AsyncMixedAnnotationModel311, AsyncMixedAnnotationModel310, "AsyncMixedAnnotationModel") # noqa: E501 +BulkUser = BulkUserBase +AsyncBulkUser = AsyncBulkUserBase # ...and the scenarios are defined specifically for this backend. -from .scenarios import get_enabled_scenarios, get_scenario +from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 class BasicProvider(IBasicProvider, WorkerTestProtocol): @@ -203,7 +208,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl self._reset_table_sync(model_class, table_name) return model_class - async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: # noqa: E501 """A generic helper method to handle the setup for any given async model.""" from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend @@ -242,7 +247,7 @@ async def _initialize_async_model_schema(self, model_class: Type[ActiveRecord], """Initialize schema for an async model that shares backend with another model.""" await self._reset_table_async(model_class, table_name) - def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 """Helper to set up multiple related models for a test, sharing a single backend.""" if not model_classes: return tuple() @@ -263,7 +268,7 @@ def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], s return tuple(result) - async def _setup_multiple_models_async(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + async def _setup_multiple_models_async(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 """Helper to set up multiple related async models for a test, sharing a single backend.""" if not model_classes: return tuple() @@ -310,7 +315,7 @@ def setup_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecor """Sets up the database for Pydantic native validation tests.""" return self._setup_model(PydanticValidatedModel, scenario_name, "pydantic_validated_models") - def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" return self._setup_multiple_models([ (MappedUser, "users"), @@ -338,6 +343,14 @@ async def setup_async_type_adapter_model_and_schema(self, scenario_name: str = N scenario_name = self.get_test_scenarios()[0] if self.get_test_scenarios() else "default" return await self._setup_async_model(AsyncTypeAdapterTest, scenario_name, "type_adapter_tests") + def setup_bulk_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for the `BulkUser` model tests.""" + return self._setup_model(BulkUser, scenario_name, "bulk_users") + + async def setup_async_bulk_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Sets up the database for the `AsyncBulkUser` model tests.""" + return await self._setup_async_model(AsyncBulkUser, scenario_name, "bulk_users") + def get_yes_no_adapter(self) -> 'BaseSQLTypeAdapter': """Returns an instance of the YesOrNoBooleanAdapter.""" return YesOrNoBooleanAdapter() @@ -368,7 +381,7 @@ async def setup_async_pydantic_validated_model(self, scenario_name: str) -> Type """Sets up the database for async Pydantic native validation tests.""" return await self._setup_async_model(AsyncPydanticValidatedModel, scenario_name, "pydantic_validated_models") - async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" return await self._setup_multiple_models_async([ (AsyncMappedUser, "users"), @@ -403,7 +416,7 @@ async def cleanup_after_test_async(self, scenario_name: str): finally: try: await backend_instance.disconnect() - except: + except: # noqa: E722 pass self._active_async_backends.clear() @@ -411,7 +424,7 @@ async def cleanup_after_test_async(self, scenario_name: str): def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for basic feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "basic", "schema") + schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "basic", "schema") # noqa: E501 schema_path = os.path.join(schema_dir, filename) with open(schema_path, 'r', encoding='utf-8') as f: @@ -440,7 +453,7 @@ def cleanup_after_test(self, scenario_name: str): # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: + except: # noqa: E722 # Ignore errors during disconnect pass diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py index c28dcb75..ebcfa369 100644 --- a/tests/providers/basic_connection.py +++ b/tests/providers/basic_connection.py @@ -9,7 +9,7 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.backend.impl.postgres import PostgresBackend -from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig # noqa: F401 from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -65,7 +65,7 @@ def _create_test_table(self, backend): async def _create_test_table_async(self, backend): """Create the test_users table asynchronously.""" try: - await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) # noqa: E501 except Exception: pass diff --git a/tests/providers/events.py b/tests/providers/events.py index bb336b76..b27cecc5 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -21,10 +21,10 @@ logger = logging.getLogger(__name__) # Import the fixture selector utility -from rhosocial.activerecord.testsuite.utils import select_fixture +from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 # Import base version models (Python 3.8+) -from rhosocial.activerecord.testsuite.feature.events.fixtures.models import ( +from rhosocial.activerecord.testsuite.feature.events.fixtures.models import ( # noqa: E402 EventTestModel as EventTestModelBase, EventTrackingModel as EventTrackingModelBase ) @@ -76,12 +76,12 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s # Select models -EventTestModel = _select_model_class(EventTestModelBase, EventTestModel312, EventTestModel311, EventTestModel310, "EventTestModel") -EventTrackingModel = _select_model_class(EventTrackingModelBase, EventTrackingModel312, EventTrackingModel311, EventTrackingModel310, "EventTrackingModel") +EventTestModel = _select_model_class(EventTestModelBase, EventTestModel312, EventTestModel311, EventTestModel310, "EventTestModel") # noqa: E501 +EventTrackingModel = _select_model_class(EventTrackingModelBase, EventTrackingModel312, EventTrackingModel311, EventTrackingModel310, "EventTrackingModel") # noqa: E501 -from rhosocial.activerecord.testsuite.feature.events.interfaces import IEventsProvider +from rhosocial.activerecord.testsuite.feature.events.interfaces import IEventsProvider # noqa: E402 # ...and the scenarios are defined specifically for this backend. -from .scenarios import get_enabled_scenarios, get_scenario +from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 class EventsProvider(IEventsProvider): @@ -140,7 +140,7 @@ def setup_event_tracking_model(self, scenario_name: str) -> Type[ActiveRecord]: def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for events feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "events", "schema") + schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "events", "schema") # noqa: E501 schema_path = os.path.join(schema_dir, filename) with open(schema_path, 'r', encoding='utf-8') as f: @@ -164,7 +164,7 @@ def cleanup_after_test(self, scenario_name: str): # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: + except: # noqa: E722 # Ignore errors during disconnect pass diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 2cd55593..d803b6de 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -21,10 +21,10 @@ logger = logging.getLogger(__name__) # Import the fixture selector utility -from rhosocial.activerecord.testsuite.utils import select_fixture +from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 # Import base version models (Python 3.8+) -from rhosocial.activerecord.testsuite.feature.mixins.fixtures.models import ( +from rhosocial.activerecord.testsuite.feature.mixins.fixtures.models import ( # noqa: E402 TimestampedPost as TimestampedPostBase, VersionedProduct as VersionedProductBase, Task as TaskBase, @@ -84,14 +84,14 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s # Select models -TimestampedPost = _select_model_class(TimestampedPostBase, TimestampedPost312, TimestampedPost311, TimestampedPost310, "TimestampedPost") -VersionedProduct = _select_model_class(VersionedProductBase, VersionedProduct312, VersionedProduct311, VersionedProduct310, "VersionedProduct") +TimestampedPost = _select_model_class(TimestampedPostBase, TimestampedPost312, TimestampedPost311, TimestampedPost310, "TimestampedPost") # noqa: E501 +VersionedProduct = _select_model_class(VersionedProductBase, VersionedProduct312, VersionedProduct311, VersionedProduct310, "VersionedProduct") # noqa: E501 Task = _select_model_class(TaskBase, Task312, Task311, Task310, "Task") -CombinedArticle = _select_model_class(CombinedArticleBase, CombinedArticle312, CombinedArticle311, CombinedArticle310, "CombinedArticle") +CombinedArticle = _select_model_class(CombinedArticleBase, CombinedArticle312, CombinedArticle311, CombinedArticle310, "CombinedArticle") # noqa: E501 -from rhosocial.activerecord.testsuite.feature.mixins.interfaces import IMixinsProvider +from rhosocial.activerecord.testsuite.feature.mixins.interfaces import IMixinsProvider # noqa: E402 # ...and the scenarios are defined specifically for this backend. -from .scenarios import get_enabled_scenarios, get_scenario +from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 class MixinsProvider(IMixinsProvider): @@ -168,7 +168,7 @@ def setup_combined_article_model(self, scenario_name: str) -> Type[ActiveRecord] def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for mixins feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "mixins", "schema") + schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "mixins", "schema") # noqa: E501 schema_path = os.path.join(schema_dir, filename) with open(schema_path, 'r', encoding='utf-8') as f: @@ -193,7 +193,7 @@ def cleanup_after_test(self, scenario_name: str): # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: + except: # noqa: E722 # Ignore errors during disconnect pass diff --git a/tests/providers/partition.py b/tests/providers/partition.py new file mode 100644 index 00000000..2f99935c --- /dev/null +++ b/tests/providers/partition.py @@ -0,0 +1,366 @@ +"""PostgreSQL provider for shared partition testsuite scenarios.""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, ClassVar, Dict, List, Optional, Tuple, Type + +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + PartitionClause, + PartitionStrategy, + TruncateExpression, +) +from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresAttachPartitionExpression, + PostgresCreatePartitionExpression, + PostgresDetachPartitionExpression, + PostgresPartitionMetadataExpression, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.testsuite.feature.partition.interfaces import IPartitionProvider + +from .scenarios import get_enabled_scenarios, get_scenario + + +class PartitionProvider(IPartitionProvider): + """Concrete partition provider backed by real PostgreSQL scenarios.""" + + TABLE_NAME = "ar_testsuite_partition_events" + PARTITIONS = { + "p2026_01": ("ar_testsuite_partition_events_p2026_01", "2026-01-01", "2026-02-01"), + "p2026_02": ("ar_testsuite_partition_events_p2026_02", "2026-02-01", "2026-03-01"), + "p2026_03": ("ar_testsuite_partition_events_p2026_03", "2026-03-01", "2026-04-01"), + } + + def __init__(self): + self._active_backends = [] + self._active_async_backends = [] + + def get_test_scenarios(self) -> List[str]: + """Return PostgreSQL scenarios configured for real backend tests.""" + return list(get_enabled_scenarios().keys()) + + def get_partition_capabilities(self, scenario_name: str) -> Dict[str, bool]: + backend = self._ensure_backend(scenario_name) + return self._capabilities(backend) + + async def async_get_partition_capabilities(self, scenario_name: str) -> Dict[str, bool]: + backend = await self._ensure_async_backend(scenario_name) + return self._capabilities(backend) + + def setup_range_partitioned_event_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], ...]: + backend = self._ensure_backend(scenario_name) + self._reset_partition_table(backend) + Event = self._event_model(backend) + return (Event,) + + async def setup_async_range_partitioned_event_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], ...]: + backend = await self._ensure_async_backend(scenario_name) + await self._reset_partition_table_async(backend) + Event = self._async_event_model(backend) + return (Event,) + + def add_future_range_partition(self, scenario_name: str) -> None: + backend = self._ensure_backend(scenario_name) + name, from_value, to_value = self.PARTITIONS["p2026_03"] + sql, params = self._create_range_partition_sql(backend.dialect, name, from_value, to_value) + backend.execute(sql, params) + + async def async_add_future_range_partition(self, scenario_name: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + name, from_value, to_value = self.PARTITIONS["p2026_03"] + sql, params = self._create_range_partition_sql(backend.dialect, name, from_value, to_value) + await backend.execute(sql, params) + + def truncate_partition(self, scenario_name: str, partition_key: str) -> None: + backend = self._ensure_backend(scenario_name) + sql, params = self._truncate_partition_sql(backend.dialect, self._partition_name(partition_key)) + backend.execute(sql, params) + + async def async_truncate_partition(self, scenario_name: str, partition_key: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + sql, params = self._truncate_partition_sql(backend.dialect, self._partition_name(partition_key)) + await backend.execute(sql, params) + + def detach_partition(self, scenario_name: str, partition_key: str) -> None: + backend = self._ensure_backend(scenario_name) + sql, params = self._detach_partition_sql(backend.dialect, self._partition_name(partition_key)) + backend.execute(sql, params) + + async def async_detach_partition(self, scenario_name: str, partition_key: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + sql, params = self._detach_partition_sql(backend.dialect, self._partition_name(partition_key)) + await backend.execute(sql, params) + + def attach_partition(self, scenario_name: str, partition_key: str) -> None: + backend = self._ensure_backend(scenario_name) + name, from_value, to_value = self.PARTITIONS[partition_key] + sql, params = self._attach_partition_sql(backend.dialect, name, from_value, to_value) + backend.execute(sql, params) + + async def async_attach_partition(self, scenario_name: str, partition_key: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + name, from_value, to_value = self.PARTITIONS[partition_key] + sql, params = self._attach_partition_sql(backend.dialect, name, from_value, to_value) + await backend.execute(sql, params) + + def get_partition_metadata(self, scenario_name: str) -> Dict[str, Any]: + backend = self._ensure_backend(scenario_name) + return self._metadata(backend) + + async def async_get_partition_metadata(self, scenario_name: str) -> Dict[str, Any]: + backend = await self._ensure_async_backend(scenario_name) + return await self._metadata_async(backend) + + def create_valid_unique_constraint(self, scenario_name: str) -> None: + backend = self._ensure_backend(scenario_name) + backend.execute( + f'CREATE UNIQUE INDEX IF NOT EXISTS "{self.TABLE_NAME}_id_created_at_uq" ' + f'ON "{self.TABLE_NAME}" (id, created_at)' + ) + + async def async_create_valid_unique_constraint(self, scenario_name: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + await backend.execute( + f'CREATE UNIQUE INDEX IF NOT EXISTS "{self.TABLE_NAME}_id_created_at_uq" ' + f'ON "{self.TABLE_NAME}" (id, created_at)' + ) + + def create_invalid_unique_constraint(self, scenario_name: str) -> None: + backend = self._ensure_backend(scenario_name) + backend.execute( + f'CREATE UNIQUE INDEX "{self.TABLE_NAME}_id_only_uq" ' + f'ON "{self.TABLE_NAME}" (id)' + ) + + async def async_create_invalid_unique_constraint(self, scenario_name: str) -> None: + backend = await self._ensure_async_backend(scenario_name) + await backend.execute( + f'CREATE UNIQUE INDEX "{self.TABLE_NAME}_id_only_uq" ' + f'ON "{self.TABLE_NAME}" (id)' + ) + + def cleanup_after_test(self, scenario_name: str) -> None: + """Drop partition test tables and disconnect sync backends.""" + for backend in self._active_backends: + try: + self._drop_partition_tables(backend) + finally: + backend.disconnect() + self._active_backends.clear() + + async def cleanup_after_test_async(self, scenario_name: str) -> None: + """Drop partition test tables and disconnect async backends.""" + for backend in self._active_async_backends: + try: + await self._drop_partition_tables_async(backend) + finally: + await backend.disconnect() + self._active_async_backends.clear() + + def _ensure_backend(self, scenario_name: str): + if self._active_backends: + return self._active_backends[0] + backend_class, config = get_scenario(scenario_name) + backend = backend_class(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + self._active_backends.append(backend) + return backend + + async def _ensure_async_backend(self, scenario_name: str): + if self._active_async_backends: + return self._active_async_backends[0] + _, config = get_scenario(scenario_name) + backend = AsyncPostgresBackend(connection_config=config) + await backend.connect() + await backend.introspect_and_adapt() + self._active_async_backends.append(backend) + return backend + + def _capabilities(self, backend) -> Dict[str, bool]: + dialect = backend.dialect + supports_creation = dialect.supports_partitioned_table_creation() + supports_pg11 = dialect.version >= (11, 0, 0) + return { + "range_partitioning": supports_creation and dialect.supports_range_table_partitioning(), + "add_partition": dialect.supports_add_partition(), + "truncate_partition": dialect.supports_truncate_partition(), + "detach_partition": dialect.supports_detach_partition(), + "attach_partition": dialect.supports_attach_partition(), + "partition_introspection": dialect.supports_partition_metadata_introspection(), + "partition_bounds": dialect.supports_partition_bounds_expression(), + "partitioned_unique_constraint": supports_creation and supports_pg11, + "unique_requires_partition_key": supports_creation and supports_pg11, + } + + def _reset_partition_table(self, backend) -> None: + self._drop_partition_tables(backend) + sql, params = self._create_parent_sql(backend.dialect) + backend.execute(sql, params) + for key in ("p2026_01", "p2026_02"): + name, from_value, to_value = self.PARTITIONS[key] + sql, params = self._create_range_partition_sql(backend.dialect, name, from_value, to_value) + backend.execute(sql, params) + + async def _reset_partition_table_async(self, backend) -> None: + await self._drop_partition_tables_async(backend) + sql, params = self._create_parent_sql(backend.dialect) + await backend.execute(sql, params) + for key in ("p2026_01", "p2026_02"): + name, from_value, to_value = self.PARTITIONS[key] + sql, params = self._create_range_partition_sql(backend.dialect, name, from_value, to_value) + await backend.execute(sql, params) + + def _drop_partition_tables(self, backend) -> None: + for table_name in self._all_table_names(): + sql, params = self._drop_partition_sql(backend.dialect, table_name) + backend.execute(sql, params) + + async def _drop_partition_tables_async(self, backend) -> None: + for table_name in self._all_table_names(): + sql, params = self._drop_partition_sql(backend.dialect, table_name) + await backend.execute(sql, params) + + def _all_table_names(self) -> Tuple[str, ...]: + return tuple(partition[0] for partition in self.PARTITIONS.values()) + (self.TABLE_NAME,) + + def _partition_name(self, partition_key: str) -> str: + return self.PARTITIONS[partition_key][0] + + def _create_parent_sql(self, dialect): + expr = CreateTableExpression( + dialect=dialect, + table=self.TABLE_NAME, + columns=[ + ColumnDefinition("id", "BIGINT GENERATED BY DEFAULT AS IDENTITY"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("tenant_id", "INTEGER NOT NULL"), + ColumnDefinition("payload", "TEXT"), + ColumnDefinition("amount", "NUMERIC(12, 2)"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + return expr.to_sql() + + def _create_range_partition_sql(self, dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=self.TABLE_NAME, + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + def _detach_partition_sql(self, dialect, partition_name: str): + expr = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=self.TABLE_NAME, + ) + return expr.to_sql() + + def _attach_partition_sql(self, dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=self.TABLE_NAME, + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + def _truncate_partition_sql(self, dialect, partition_name: str): + expr = TruncateExpression(dialect=dialect, table_name=partition_name) + return expr.to_sql() + + def _drop_partition_sql(self, dialect, table_name: str): + expr = DropTableExpression( + dialect=dialect, + table=table_name, + if_exists=True, + cascade=True, + ) + return expr.to_sql() + + def _partition_metadata_sql(self, dialect): + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table=self.TABLE_NAME, + ) + return expr.to_sql() + + def _event_model(self, backend): + table_name = self.TABLE_NAME + + class PartitionEvent(ActiveRecord): + __table_name__ = table_name + __primary_key__ = "id" + __backend__ = backend + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + created_at: datetime + tenant_id: int + payload: Optional[str] = None + amount: Optional[Decimal] = None + + return PartitionEvent + + def _async_event_model(self, backend): + table_name = self.TABLE_NAME + + class AsyncPartitionEvent(AsyncActiveRecord): + __table_name__ = table_name + __primary_key__ = "id" + __backend__ = backend + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + created_at: datetime + tenant_id: int + payload: Optional[str] = None + amount: Optional[Decimal] = None + + return AsyncPartitionEvent + + def _metadata(self, backend) -> Dict[str, Any]: + sql, params = self._partition_metadata_sql(backend.dialect) + rows = backend.fetch_all(sql, params) + return self._metadata_dict(rows) + + async def _metadata_async(self, backend) -> Dict[str, Any]: + sql, params = self._partition_metadata_sql(backend.dialect) + rows = await backend.fetch_all(sql, params) + return self._metadata_dict(rows) + + def _metadata_dict(self, rows) -> Dict[str, Any]: + first = rows[0] if rows else None + partition_key = first["partition_key"] if first else "" + return { + "is_partitioned": bool(partition_key), + "strategy": "range" if partition_key.upper().startswith("RANGE") else "unknown", + "key_columns": ["created_at"] if "created_at" in partition_key.lower() else [], + "partitions": [ + {"name": row["name"], "bound": row["bound"]} + for row in rows + if row["name"] is not None + ], + } diff --git a/tests/providers/query.py b/tests/providers/query.py index 8d93134a..6fac6c84 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -21,20 +21,21 @@ logger = logging.getLogger(__name__) # Import the fixture selector utility -from rhosocial.activerecord.testsuite.utils import select_fixture +from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 # Import base version models (Python 3.8+) -from rhosocial.activerecord.testsuite.feature.query.fixtures.models import ( +from rhosocial.activerecord.testsuite.feature.query.fixtures.models import ( # noqa: E402 User as UserBase, JsonUser as JsonUserBase, Order as OrderBase, OrderItem as OrderItemBase, Post as PostBase, Comment as CommentBase, + Profile as ProfileBase, MappedUser as MappedUserBase, MappedPost as MappedPostBase, MappedComment as MappedCommentBase ) -from rhosocial.activerecord.testsuite.feature.query.fixtures.cte_models import Node -from rhosocial.activerecord.testsuite.feature.query.fixtures.extended_models import ExtendedOrder, ExtendedOrderItem +from rhosocial.activerecord.testsuite.feature.query.fixtures.cte_models import Node # noqa: E402 +from rhosocial.activerecord.testsuite.feature.query.fixtures.extended_models import ExtendedOrder, ExtendedOrderItem # noqa: E402 # Conditionally import Python 3.10+ models -User310 = JsonUser310 = Order310 = OrderItem310 = Post310 = Comment310 = None +User310 = JsonUser310 = Order310 = OrderItem310 = Post310 = Comment310 = Profile310 = None MappedUser310 = MappedPost310 = MappedComment310 = None if sys.version_info >= (3, 10): @@ -43,13 +44,14 @@ User as User310, JsonUser as JsonUser310, Order as Order310, OrderItem as OrderItem310, Post as Post310, Comment as Comment310, + Profile as Profile310, MappedUser as MappedUser310, MappedPost as MappedPost310, MappedComment as MappedComment310 ) except ImportError as e: logger.warning(f"Failed to import Python 3.10+ fixtures: {e}") # Conditionally import Python 3.11+ models -User311 = JsonUser311 = Order311 = OrderItem311 = Post311 = Comment311 = None +User311 = JsonUser311 = Order311 = OrderItem311 = Post311 = Comment311 = Profile311 = None MappedUser311 = MappedPost311 = MappedComment311 = None if sys.version_info >= (3, 11): @@ -58,13 +60,14 @@ User as User311, JsonUser as JsonUser311, Order as Order311, OrderItem as OrderItem311, Post as Post311, Comment as Comment311, + Profile as Profile311, MappedUser as MappedUser311, MappedPost as MappedPost311, MappedComment as MappedComment311 ) except ImportError as e: logger.warning(f"Failed to import Python 3.11+ fixtures: {e}") # Conditionally import Python 3.12+ models -User312 = JsonUser312 = Order312 = OrderItem312 = Post312 = Comment312 = None +User312 = JsonUser312 = Order312 = OrderItem312 = Post312 = Comment312 = Profile312 = None MappedUser312 = MappedPost312 = MappedComment312 = None if sys.version_info >= (3, 12): @@ -73,6 +76,7 @@ User as User312, JsonUser as JsonUser312, Order as Order312, OrderItem as OrderItem312, Post as Post312, Comment as Comment312, + Profile as Profile312, MappedUser as MappedUser312, MappedPost as MappedPost312, MappedComment as MappedComment312 ) except ImportError as e: @@ -97,12 +101,13 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s Comment = _select_model_class(CommentBase, Comment312, Comment311, Comment310, "Comment") MappedUser = _select_model_class(MappedUserBase, MappedUser312, MappedUser311, MappedUser310, "MappedUser") MappedPost = _select_model_class(MappedPostBase, MappedPost312, MappedPost311, MappedPost310, "MappedPost") -MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") +MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") # noqa: E501 +Profile = _select_model_class(ProfileBase, Profile312, Profile311, Profile310, "Profile") -from rhosocial.activerecord.testsuite.feature.query.interfaces import IQueryProvider -from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol +from rhosocial.activerecord.testsuite.feature.query.interfaces import IQueryProvider # noqa: E402 +from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol # noqa: E402 # The scenarios are defined specifically for this backend. -from .scenarios import get_enabled_scenarios, get_scenario +from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 class QueryProvider(IQueryProvider, WorkerTestProtocol): @@ -123,7 +128,7 @@ def _track_backend(self, backend_instance, collection): if backend_instance not in collection: collection.append(backend_instance) - def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: + def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: # noqa: E501 """A generic helper method to handle the setup for any given model.""" backend_class, config = get_scenario(scenario_name) @@ -147,7 +152,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl return model_class - async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: + async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: # noqa: E501 """A generic helper method to handle the setup for any given async model.""" from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend @@ -173,7 +178,7 @@ async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_nam return model_class - def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 """A helper to set up multiple models for fixture groups, sharing the same backend.""" result = [] shared_backend = None @@ -182,11 +187,11 @@ def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord configured_model = self._setup_model(model_class, scenario_name, table_name) shared_backend = configured_model.__backend__ else: - configured_model = self._setup_model(model_class, scenario_name, table_name, shared_backend=shared_backend) + configured_model = self._setup_model(model_class, scenario_name, table_name, shared_backend=shared_backend) # noqa: E501 result.append(configured_model) return tuple(result) - async def _setup_multiple_models_async(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + async def _setup_multiple_models_async(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 """A helper to set up multiple async models for fixture groups, sharing the same backend.""" result = [] shared_backend = None @@ -195,13 +200,13 @@ async def _setup_multiple_models_async(self, models_and_tables: List[Tuple[Type[ configured_model = await self._setup_model_async(model_class, scenario_name, table_name) shared_backend = configured_model.__backend__ else: - configured_model = await self._setup_model_async(model_class, scenario_name, table_name, shared_backend=shared_backend) + configured_model = await self._setup_model_async(model_class, scenario_name, table_name, shared_backend=shared_backend) # noqa: E501 result.append(configured_model) return tuple(result) # --- Implementation of the IQueryProvider interface --- - def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for order-related models (User, Order, OrderItem) tests.""" models_and_tables = [ (User, "users"), @@ -210,7 +215,7 @@ def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for blog-related models (User, Post, Comment) tests.""" models_and_tables = [ (User, "users"), @@ -246,7 +251,7 @@ def setup_tree_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], . ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for extended order-related models (User, ExtendedOrder, ExtendedOrderItem) tests.""" models_and_tables = [ (User, "users"), @@ -255,7 +260,7 @@ def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[Active ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for combined models (User, Order, OrderItem, Post, Comment) tests.""" models_and_tables = [ (User, "users"), @@ -273,7 +278,7 @@ def setup_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[Activ (SearchableItem, "searchable_items"), ], scenario_name) - def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" return self._setup_multiple_models([ (MappedUser, "users"), @@ -281,20 +286,28 @@ def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], T (MappedComment, "comments") ], scenario_name) + def setup_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 + """Sets up the database for User and Profile models.""" + return self._setup_multiple_models([ + (User, "users"), + (Profile, "profiles"), + ], scenario_name) + # --- Async implementations --- - async def setup_async_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + async def setup_async_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for async order-related models (AsyncUser, AsyncOrder, AsyncOrderItem) tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem # noqa: E501 return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncOrder, "orders"), (AsyncOrderItem, "order_items") ], scenario_name) - async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for async blog-related models (AsyncUser, AsyncPost, AsyncComment) tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_blog_models import AsyncUser, AsyncPost, AsyncComment + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_blog_models import AsyncPost, AsyncComment return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncPost, "posts"), @@ -328,18 +341,18 @@ async def setup_async_tree_fixtures(self, scenario_name: str) -> Tuple[Type[Acti (AsyncNode, "nodes"), ], scenario_name) - async def setup_async_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: - """Sets up the database for async extended order-related models (AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem) tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_extended_models import AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem + async def setup_async_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 + """Sets up the database for async extended order-related models (AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem) tests.""" # noqa: E501 + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_extended_models import AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem # noqa: E501 return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncExtendedOrder, "extended_orders"), (AsyncExtendedOrderItem, "extended_order_items") ], scenario_name) - async def setup_async_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: - """Sets up the database for async combined models (AsyncUser, AsyncOrder, AsyncOrderItem, AsyncPost, AsyncComment) tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem + async def setup_async_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 + """Sets up the database for async combined models (AsyncUser, AsyncOrder, AsyncOrderItem, AsyncPost, AsyncComment) tests.""" # noqa: E501 + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem # noqa: E501 from rhosocial.activerecord.testsuite.feature.query.fixtures.async_blog_models import AsyncPost, AsyncComment return await self._setup_multiple_models_async([ (AsyncUser, "users"), @@ -351,20 +364,28 @@ async def setup_async_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ async def setup_async_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: """Sets up the database for the async SearchableItem model tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_annotated_adapter_models import AsyncSearchableItem + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_annotated_adapter_models import AsyncSearchableItem # noqa: E501 return await self._setup_multiple_models_async([ (AsyncSearchableItem, "searchable_items"), ], scenario_name) - async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" - from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import AsyncMappedUser, AsyncMappedPost, AsyncMappedComment + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import AsyncMappedUser, AsyncMappedPost, AsyncMappedComment # noqa: E501 return await self._setup_multiple_models_async([ (AsyncMappedUser, "users"), (AsyncMappedPost, "posts"), (AsyncMappedComment, "comments") ], scenario_name) + async def setup_async_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 + """Sets up the database for AsyncUser and AsyncProfile models.""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncProfile + return await self._setup_multiple_models_async([ + (AsyncUser, "users"), + (AsyncProfile, "profiles"), + ], scenario_name) + async def cleanup_after_test_async(self, scenario_name: str): """Performs async cleanup after a test.""" tables_to_drop = [ @@ -381,14 +402,14 @@ async def cleanup_after_test_async(self, scenario_name: str): finally: try: await backend_instance.disconnect() - except: + except: # noqa: E722 pass self._active_async_backends.clear() def _load_postgres_schema(self, filename: str) -> str: """Helper to load a SQL schema file from this project's fixtures.""" # Schemas are stored in the centralized location for query feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "query", "schema") + schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "query", "schema") # noqa: E501 schema_path = os.path.join(schema_dir, filename) with open(schema_path, 'r', encoding='utf-8') as f: @@ -417,7 +438,7 @@ def cleanup_after_test(self, scenario_name: str): # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: + except: # noqa: E722 # Ignore errors during disconnect pass diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py index 985d390e..ab962f1e 100644 --- a/tests/providers/query_connection.py +++ b/tests/providers/query_connection.py @@ -9,7 +9,7 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.backend.impl.postgres import PostgresBackend -from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig # noqa: F401 from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -65,7 +65,7 @@ def _create_test_table(self, backend): async def _create_test_table_async(self, backend): """Create the test_users table asynchronously.""" try: - await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) + await backend.execute("DROP TABLE IF EXISTS test_users", options=ExecutionOptions(stmt_type=StatementType.DDL)) # noqa: E501 except Exception: pass diff --git a/tests/providers/registry.py b/tests/providers/registry.py index b6ab3719..152d0088 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -10,6 +10,7 @@ from .events import EventsProvider from .mixins import MixinsProvider from .query import QueryProvider +from .relation import RelationProvider from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider from .crud_benchmark import CrudBenchmarkProvider @@ -27,6 +28,7 @@ provider_registry.register("feature.events.IEventsProvider", EventsProvider) provider_registry.register("feature.mixins.IMixinsProvider", MixinsProvider) provider_registry.register("feature.query.IQueryProvider", QueryProvider) +provider_registry.register("feature.relation.IRelationProvider", RelationProvider) # Register connection pool context awareness providers provider_registry.register( diff --git a/tests/providers/relation.py b/tests/providers/relation.py new file mode 100644 index 00000000..c852ad55 --- /dev/null +++ b/tests/providers/relation.py @@ -0,0 +1,434 @@ +# tests/providers/relation.py +import asyncio +from typing import Dict, List, Tuple, Type + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend +from rhosocial.activerecord.backend.impl.postgres.adapters.json import PostgresJSONBAdapter +from rhosocial.activerecord.testsuite.feature.relation.interfaces import IRelationProvider +from rhosocial.activerecord.testsuite.feature.relation.fixtures.models import ( + Employee, Department, Author, Book, Chapter, Profile, + User, Post, Comment, + AsyncUser, AsyncPost, AsyncComment, + BoundaryOwner, BoundaryProfile, BoundaryPost, + AsyncBoundaryOwner, AsyncBoundaryProfile, AsyncBoundaryPost, +) +from .scenarios import get_enabled_scenarios, get_scenario + + +EMPLOYEE_DEPARTMENT_SCHEMA = """ + DROP TABLE IF EXISTS employees CASCADE; + DROP TABLE IF EXISTS departments CASCADE; + CREATE TABLE departments ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '' + ); + CREATE TABLE employees ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + department_id INTEGER NOT NULL + ); +""" + +AUTHOR_BOOK_SCHEMA = """ + DROP TABLE IF EXISTS chapters CASCADE; + DROP TABLE IF EXISTS books CASCADE; + DROP TABLE IF EXISTS profiles CASCADE; + DROP TABLE IF EXISTS authors CASCADE; + CREATE TABLE authors ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE books ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + author_id INTEGER NOT NULL + ); + CREATE TABLE chapters ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + book_id INTEGER NOT NULL + ); + CREATE TABLE profiles ( + id SERIAL PRIMARY KEY, + bio TEXT NOT NULL, + author_id INTEGER NOT NULL + ); +""" + +USER_POST_COMMENT_SCHEMA = """ + DROP TABLE IF EXISTS comments CASCADE; + DROP TABLE IF EXISTS posts CASCADE; + DROP TABLE IF EXISTS users CASCADE; + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + settings JSONB + ); + CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL, + user_id INTEGER NOT NULL, + view_count INTEGER NOT NULL DEFAULT 0, + metadata JSONB + ); + CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + body TEXT NOT NULL, + post_id INTEGER NOT NULL, + meta JSONB + ); +""" + +RELATION_BOUNDARY_SCHEMA = """ + DROP TABLE IF EXISTS relation_boundary_posts CASCADE; + DROP TABLE IF EXISTS relation_boundary_profiles CASCADE; + DROP TABLE IF EXISTS relation_boundary_owners CASCADE; + CREATE TABLE relation_boundary_owners ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE relation_boundary_profiles ( + id SERIAL PRIMARY KEY, + bio TEXT NOT NULL, + owner_id INTEGER NULL + ); + CREATE TABLE relation_boundary_posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + owner_id INTEGER NULL + ); +""" + + +class RelationProvider(IRelationProvider): + + def __init__(self): + self._active_backends = [] + self._active_async_backends = [] + self._sync_user_post_comment_setup = False + self._async_user_post_comment_setup = False + self._sync_relation_boundary_setup = False + self._async_relation_boundary_setup = False + + def get_test_scenarios(self) -> List[str]: + return list(get_enabled_scenarios().keys()) + + def _execute_script(self, backend, sql: str): + for statement in sql.split(";"): + statement = statement.strip() + if statement: + backend.execute(statement) + + async def _execute_script_async(self, backend, sql: str): + for statement in sql.split(";"): + statement = statement.strip() + if statement: + await backend.execute(statement) + + def _configure_with_shared_backend(self, model_class, config, backend_class, backend): + model_class.__connection_config__ = config + model_class.__backend_class__ = backend_class + model_class.__backend__ = backend + + def _configure_async_model_without_connection(self, model_class, config, backend=None): + if backend is None: + backend = AsyncPostgresBackend(connection_config=config) + model_class.__connection_config__ = config + model_class.__backend_class__ = AsyncPostgresBackend + model_class.__backend__ = backend + return backend + + def _configure_json_field_adapters(self): + adapter = PostgresJSONBAdapter() + User.__field_adapters__["settings"] = (adapter, str) + Post.__field_adapters__["metadata"] = (adapter, str) + Comment.__field_adapters__["meta"] = (adapter, str) + AsyncUser.__field_adapters__["settings"] = (adapter, str) + AsyncPost.__field_adapters__["metadata"] = (adapter, str) + AsyncComment.__field_adapters__["meta"] = (adapter, str) + + def _setup_employee_department(self, scenario_name): + backend_class, config = get_scenario(scenario_name) + Employee.configure(config, backend_class) + backend = Employee.backend() + backend.connect() + backend.introspect_and_adapt() + self._active_backends.append(backend) + self._execute_script(backend, EMPLOYEE_DEPARTMENT_SCHEMA) + self._configure_with_shared_backend(Department, config, backend_class, backend) + return Employee, Department + + def _setup_author_book(self, scenario_name): + backend_class, config = get_scenario(scenario_name) + Author.configure(config, backend_class) + backend = Author.backend() + backend.connect() + backend.introspect_and_adapt() + self._active_backends.append(backend) + self._execute_script(backend, AUTHOR_BOOK_SCHEMA) + self._configure_with_shared_backend(Book, config, backend_class, backend) + self._configure_with_shared_backend(Chapter, config, backend_class, backend) + self._configure_with_shared_backend(Profile, config, backend_class, backend) + return Author, Book, Chapter, Profile + + def _setup_user_post_comment_sync(self, scenario_name): + if not self._sync_user_post_comment_setup: + self._configure_json_field_adapters() + backend_class, config = get_scenario(scenario_name) + User.configure(config, backend_class) + backend = User.backend() + backend.connect() + backend.introspect_and_adapt() + self._active_backends.append(backend) + self._execute_script(backend, USER_POST_COMMENT_SCHEMA) + self._configure_with_shared_backend(Post, config, backend_class, backend) + self._configure_with_shared_backend(Comment, config, backend_class, backend) + self._sync_user_post_comment_setup = True + + def _setup_user_post_comment_async(self, scenario_name): + if not self._async_user_post_comment_setup: + self._configure_json_field_adapters() + _, config = get_scenario(scenario_name) + backend = self._configure_async_model_without_connection(AsyncUser, config) + self._configure_async_model_without_connection(AsyncPost, config, backend) + self._configure_async_model_without_connection(AsyncComment, config, backend) + self._active_async_backends.append(backend) + self._async_user_post_comment_setup = True + + async def _ensure_user_post_comment_async_schema(self): + backend = AsyncUser.backend() + if backend not in self._active_async_backends: + self._active_async_backends.append(backend) + await backend.introspect_and_adapt() + await self._execute_script_async(backend, USER_POST_COMMENT_SCHEMA) + + def _setup_relation_boundary_sync(self, scenario_name): + if not self._sync_relation_boundary_setup: + backend_class, config = get_scenario(scenario_name) + BoundaryOwner.configure(config, backend_class) + backend = BoundaryOwner.backend() + backend.connect() + backend.introspect_and_adapt() + self._active_backends.append(backend) + self._execute_script(backend, RELATION_BOUNDARY_SCHEMA) + self._configure_with_shared_backend( + BoundaryProfile, + config, + backend_class, + backend, + ) + self._configure_with_shared_backend( + BoundaryPost, + config, + backend_class, + backend, + ) + self._sync_relation_boundary_setup = True + + async def _setup_relation_boundary_async(self, scenario_name): + if not self._async_relation_boundary_setup: + _, config = get_scenario(scenario_name) + backend = self._configure_async_model_without_connection( + AsyncBoundaryOwner, + config, + ) + self._configure_async_model_without_connection( + AsyncBoundaryProfile, + config, + backend, + ) + self._configure_async_model_without_connection( + AsyncBoundaryPost, + config, + backend, + ) + self._active_async_backends.append(backend) + await backend.introspect_and_adapt() + await self._execute_script_async(backend, RELATION_BOUNDARY_SCHEMA) + self._async_relation_boundary_setup = True + + def setup_employee_department_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: + return self._setup_employee_department(scenario_name) + + def setup_author_book_fixtures( + self, + scenario_name: str, + ) -> Tuple[ + Type[ActiveRecord], + Type[ActiveRecord], + Type[ActiveRecord], + Type[ActiveRecord], + ]: + return self._setup_author_book(scenario_name) + + def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment_sync(scenario_name) + return User + + def setup_post_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment_sync(scenario_name) + return Post + + def setup_comment_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment_sync(scenario_name) + return Comment + + def setup_async_user_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + self._setup_user_post_comment_async(scenario_name) + return AsyncUser + + def setup_async_post_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + self._setup_user_post_comment_async(scenario_name) + return AsyncPost + + def setup_async_comment_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + self._setup_user_post_comment_async(scenario_name) + return AsyncComment + + def setup_relation_boundary_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + self._setup_relation_boundary_sync(scenario_name) + return BoundaryOwner, BoundaryProfile, BoundaryPost + + def setup_async_relation_boundary_fixtures( + self, + scenario_name: str, + ) -> Tuple[ + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + ]: + _, config = get_scenario(scenario_name) + self._configure_async_model_without_connection(AsyncBoundaryOwner, config) + self._configure_async_model_without_connection( + AsyncBoundaryProfile, + config, + AsyncBoundaryOwner.backend(), + ) + self._configure_async_model_without_connection( + AsyncBoundaryPost, + config, + AsyncBoundaryOwner.backend(), + ) + return AsyncBoundaryOwner, AsyncBoundaryProfile, AsyncBoundaryPost + + def load_relation_boundary_dataset(self, scenario_name: str, dataset_name: str) -> Dict[str, int]: + self._setup_relation_boundary_sync(scenario_name) + return self._load_relation_boundary_dataset(dataset_name) + + async def load_async_relation_boundary_dataset( + self, + scenario_name: str, + dataset_name: str, + ) -> Dict[str, int]: + await self._setup_relation_boundary_async(scenario_name) + return await self._load_async_relation_boundary_dataset(dataset_name) + + def _load_relation_boundary_dataset(self, dataset_name): + if dataset_name == "null_foreign_key": + profile = BoundaryProfile(bio="No owner", owner_id=None) + profile.save() + return {"profile_id": profile.id} + + if dataset_name == "orphan_foreign_key": + missing_owner_id = 999999 + post = BoundaryPost(title="Orphan post", owner_id=missing_owner_id) + post.save() + return {"post_id": post.id, "missing_owner_id": missing_owner_id} + + if dataset_name == "owner_without_children": + owner = BoundaryOwner(name="Owner without children") + owner.save() + return {"owner_id": owner.id} + + if dataset_name == "multiple_has_one_matches": + owner = BoundaryOwner(name="Owner with duplicate profiles") + owner.save() + first = BoundaryProfile(bio="First profile", owner_id=owner.id) + first.save() + second = BoundaryProfile(bio="Second profile", owner_id=owner.id) + second.save() + return { + "owner_id": owner.id, + "first_profile_id": first.id, + "second_profile_id": second.id, + } + + raise ValueError(f"Unknown relation boundary dataset: {dataset_name}") + + async def _load_async_relation_boundary_dataset(self, dataset_name): + if dataset_name == "null_foreign_key": + profile = AsyncBoundaryProfile(bio="No owner", owner_id=None) + await profile.save() + return {"profile_id": profile.id} + + if dataset_name == "orphan_foreign_key": + missing_owner_id = 999999 + post = AsyncBoundaryPost(title="Orphan post", owner_id=missing_owner_id) + await post.save() + return {"post_id": post.id, "missing_owner_id": missing_owner_id} + + if dataset_name == "owner_without_children": + owner = AsyncBoundaryOwner(name="Owner without children") + await owner.save() + return {"owner_id": owner.id} + + if dataset_name == "multiple_has_one_matches": + owner = AsyncBoundaryOwner(name="Owner with duplicate profiles") + await owner.save() + first = AsyncBoundaryProfile(bio="First profile", owner_id=owner.id) + await first.save() + second = AsyncBoundaryProfile(bio="Second profile", owner_id=owner.id) + await second.save() + return { + "owner_id": owner.id, + "first_profile_id": first.id, + "second_profile_id": second.id, + } + + raise ValueError(f"Unknown relation boundary dataset: {dataset_name}") + + def _reset_setup_state(self): + self._sync_user_post_comment_setup = False + self._async_user_post_comment_setup = False + self._sync_relation_boundary_setup = False + self._async_relation_boundary_setup = False + + def cleanup_after_test(self, scenario_name: str) -> None: + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + for backend in self._active_async_backends: + try: + asyncio.run(backend.disconnect()) + except Exception: + pass + self._active_async_backends.clear() + self._reset_setup_state() + + async def cleanup_after_test_async(self, scenario_name: str): + for backend in self._active_backends: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + for backend in self._active_async_backends: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() + self._reset_setup_state() diff --git a/tests/providers/scenarios.py b/tests/providers/scenarios.py index b9c43a99..db10b9e0 100644 --- a/tests/providers/scenarios.py +++ b/tests/providers/scenarios.py @@ -50,7 +50,7 @@ def _load_scenarios_from_config(): try: import yaml except ImportError: - raise ImportError("PyYAML is required to load postgres scenario configuration files. Please install it.") + raise ImportError("PyYAML is required to load postgres scenario configuration files. Please install it.") # noqa: B904 # First, try to load from path specified in environment variable env_config_path = os.getenv("POSTGRES_SCENARIOS_CONFIG_PATH") @@ -80,7 +80,7 @@ def _load_scenarios_from_config(): register_scenario(scenario_name, config) except Exception as e: - raise RuntimeError(f"Failed to load or parse scenario configuration file {config_path}: {e}") + raise RuntimeError(f"Failed to load or parse scenario configuration file {config_path}: {e}") # noqa: B904 def _register_default_scenarios(): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py index f7d2cd9c..cf90c588 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py @@ -5,7 +5,7 @@ import pytest_asyncio import yaml import os -from typing import Dict, Any, Tuple, Type +from typing import Dict, Any, Tuple, Type # noqa: F401 from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig @@ -63,7 +63,7 @@ def _load_scenarios_from_config(): register_scenario(scenario_name, config) except ImportError: - raise ImportError("PyYAML is required to load scenario configuration files") + raise ImportError("PyYAML is required to load scenario configuration files") # noqa: B904 _load_scenarios_from_config() @@ -265,8 +265,8 @@ def postgres_dialect(): # --- Extension Availability Utilities --- # These are defined in utils.py and automatically available via conftest # for tests that don't need explicit import. -# For explicit import in test files, use: from rhosocial.activerecord_postgres_test.feature.backend.utils import requires_extension -from rhosocial.activerecord_postgres_test.feature.backend.utils import ( # noqa: F401 +# For explicit import in test files, use: from rhosocial.activerecord_postgres_test.feature.backend.utils import requires_extension # noqa: E501 +from rhosocial.activerecord_postgres_test.feature.backend.utils import ( # noqa: E402, F401 requires_extension, requires_extension_available, skip_if_extension_missing, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py new file mode 100644 index 00000000..723d351c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py @@ -0,0 +1,399 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_pgq_format.py +""" +Tests for PostgreSQL PGQ dialect version gating and SQL formatting. + +Uses requires_protocol markers for protocol-level capability documentation. +""" +import pytest +from rhosocial.activerecord.backend.dialect.protocols import GraphSupport, GraphTableSupport +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, + GraphColumn, ColumnsClause, GraphTableExpression, + TablePropertiesClause, VertexTable, EdgeTable, + CreatePropertyGraphExpression, DropPropertyGraphExpression, + AlterPropertyGraphExpression, +) +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.core import Column, Literal + + +class TestPGQProtocolVersionGating: + """Version gating tests for PGQ support in PostgreSQL.""" + + def test_supports_graph_match_pg19_true(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_graph_match() is True + + def test_supports_graph_match_pg15_false(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_graph_match() is False + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_supports_graph_table_pg19_true(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_graph_table() is True + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_supports_graph_table_pg15_false(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_graph_table() is False + + @pytest.mark.requires_protocol((GraphSupport, "supports_graph_match")) + def test_graph_support_protocol_implemented_pg19(self): + d = PostgresDialect((19, 0, 0)) + assert isinstance(d, GraphSupport) + assert isinstance(d, GraphTableSupport) + + def test_graph_support_protocol_implemented_pg15(self): + d = PostgresDialect((15, 0, 0)) + assert isinstance(d, GraphSupport) + assert isinstance(d, GraphTableSupport) + + +@pytest.fixture +def pg19_dialect(): + return PostgresDialect((19, 0, 0)) + + +class TestPGQGraphVertexFormat: + """SQL formatting tests for GraphVertex with PG19 dialect.""" + + def test_basic(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + sql, params = v.to_sql() + assert sql == '(p IS "person")' + assert params == () + + def test_anonymous(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, variable=None, table="person") + sql, params = v.to_sql() + assert sql == '("person")' + assert params == () + + def test_with_where(self, pg19_dialect: PostgresDialect): + where = WhereClause(pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) + v = GraphVertex(pg19_dialect, "p", "person", where=where) + sql, params = v.to_sql() + assert "(p IS" in sql + assert "WHERE" in sql + assert params == (18,) + + def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "bad var", "person") + with pytest.raises(ValueError, match="Invalid variable name"): + v.to_sql() + + + + +class TestPGQGraphEdgeFormat: + """SQL formatting tests for GraphEdge with PG19 dialect.""" + + def test_right(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + sql, params = e.to_sql() + assert sql == '-[e IS "knows"]->' + + def test_left(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.LEFT) + assert e.to_sql()[0] == '<-[e IS "knows"]-' + + def test_any(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.ANY) + assert e.to_sql()[0] == '<-[e IS "knows"]->' + + def test_anonymous(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, direction=GraphEdgeDirection.RIGHT) + assert e.to_sql()[0] == '-[]->' + + def test_none_direction(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.NONE) + assert e.to_sql()[0] == '-[e IS "knows"]-' + + def test_table_without_variable(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, table="knows", direction=GraphEdgeDirection.RIGHT) + sql = e.to_sql()[0] + assert sql == '-[]->' + + def test_variable_only(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, variable="e", direction=GraphEdgeDirection.RIGHT) + assert e.to_sql()[0] == '-[e]->' + + def test_invalid_variable_name(self, pg19_dialect: PostgresDialect): + e = GraphEdge(pg19_dialect, variable="bad var", table="knows", direction=GraphEdgeDirection.RIGHT) + with pytest.raises(ValueError, match="Invalid variable name"): + e.to_sql() + + +class TestPGQMatchClauseFormat: + """SQL formatting tests for MatchClause with PG19 dialect.""" + + def test_single_vertex(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + m = MatchClause(pg19_dialect, v) + sql, params = m.to_sql() + assert "MATCH" in sql + assert "(p IS" in sql + + def test_path_pattern(self, pg19_dialect: PostgresDialect): + a = GraphVertex(pg19_dialect, "a", "person") + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + b = GraphVertex(pg19_dialect, "b", "person") + m = MatchClause(pg19_dialect, a, e, b) + sql, params = m.to_sql() + assert "MATCH" in sql + assert "(a IS" in sql + assert "[e IS" in sql + assert "(b IS" in sql + + def test_empty_path(self, pg19_dialect: PostgresDialect): + m = MatchClause(pg19_dialect) + sql, params = m.to_sql() + assert sql == "MATCH " + assert params == () + + def test_anonymous_vertex(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, variable=None, table="person") + m = MatchClause(pg19_dialect, v) + sql, params = m.to_sql() + assert sql == 'MATCH ("person")' + assert params == () + + +class TestPGQGraphTableFormat: + """SQL formatting tests for GraphTableExpression with PG19 dialect.""" + + def test_basic(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + m = MatchClause(pg19_dialect, v) + gt = GraphTableExpression(pg19_dialect, "g", m, cols) + sql, params = gt.to_sql() + assert 'GRAPH_TABLE ("g" MATCH' in sql + assert "COLUMNS" in sql + + def test_with_where(self, pg19_dialect: PostgresDialect): + where = WhereClause(pg19_dialect, + condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) + v = GraphVertex(pg19_dialect, "p", "person", where=where) + e = GraphEdge(pg19_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + b = GraphVertex(pg19_dialect, "b", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("b", "name")) + m = MatchClause(pg19_dialect, v, e, b) + gt = GraphTableExpression(pg19_dialect, "g", m, cols) + sql, params = gt.to_sql() + assert "WHERE" in sql + assert params == (18,) + + def test_with_alias(self, pg19_dialect: PostgresDialect): + v = GraphVertex(pg19_dialect, "p", "person") + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + m = MatchClause(pg19_dialect, v) + gt = GraphTableExpression(pg19_dialect, "g", m, cols, alias="t") + sql, params = gt.to_sql() + assert 'GRAPH_TABLE ("g" MATCH' in sql + assert 'AS "t"' in sql + + def test_graph_column_without_alias(self, pg19_dialect: PostgresDialect): + cols = ColumnsClause(pg19_dialect, GraphColumn("p", "name")) + sql, params = cols.to_sql() + assert sql == 'COLUMNS ("p"."name")' + assert params == () + + +class TestPGQDDLFormat: + """SQL formatting tests for PGQ DDL expressions with PG19 dialect.""" + + def test_create_property_graph(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "people", + labels=["person"], + key_columns=["id"], + properties=TablePropertiesClause(pg19_dialect, columns=["id", "name"])) + et = EdgeTable(pg19_dialect, "knows", ["person_a"], ["person_b"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["knows"], + properties=TablePropertiesClause(pg19_dialect, columns=["since"])) + expr = CreatePropertyGraphExpression(pg19_dialect, "test_graph", [vt], [et]) + sql, params = expr.to_sql() + assert "CREATE PROPERTY GRAPH" in sql + assert '"people"' in sql + assert '"knows"' in sql + assert "SOURCE KEY" in sql + assert "DESTINATION KEY" in sql + + def test_create_property_graph_if_not_exists(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + expr = CreatePropertyGraphExpression(pg19_dialect, "my_graph", [vt], + if_not_exists=True) + sql, params = expr.to_sql() + assert "CREATE PROPERTY GRAPH IF NOT EXISTS" in sql + + def test_create_property_graph_no_edges(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + expr = CreatePropertyGraphExpression(pg19_dialect, "my_graph", [vt]) + sql, params = expr.to_sql() + assert "EDGE TABLES" not in sql + assert 'VERTEX TABLES ("person"' in sql + + def test_vertex_table_with_alias(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person", alias="p", + labels=["Person"], + key_columns=["id"]) + sql, params = vt.to_sql() + assert '"person" AS "p"' in sql or '"person" AS p' in sql + assert "LABEL" in sql + assert "KEY" in sql + + def test_edge_table_with_alias(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"], + alias="k", + labels=["Knows"]) + sql, params = et.to_sql() + assert '"knows" AS "k"' in sql or '"knows" AS k' in sql + assert "SOURCE KEY" in sql + assert "DESTINATION KEY" in sql + assert "LABEL" in sql + + def test_properties_all_columns(self, pg19_dialect: PostgresDialect): + p = TablePropertiesClause(pg19_dialect, columns=None) + sql, params = p.to_sql() + assert sql == "PROPERTIES ALL COLUMNS" + assert params == () + + def test_properties_none(self, pg19_dialect: PostgresDialect): + p = TablePropertiesClause(pg19_dialect, columns=[]) + sql, params = p.to_sql() + assert sql == "PROPERTIES NONE" + assert params == () + + def test_drop_property_graph(self, pg19_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg19_dialect, "test_graph", if_exists=True) + sql, params = expr.to_sql() + assert 'DROP PROPERTY GRAPH IF EXISTS "test_graph"' in sql + + def test_drop_cascade(self, pg19_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg19_dialect, "test_graph", cascade=True) + sql, params = expr.to_sql() + assert "CASCADE" in sql + + def test_edge_table_no_references(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + sql, params = et.to_sql() + assert 'SOURCE KEY ("pid")' in sql + assert 'DESTINATION KEY ("fid")' in sql + assert "REFERENCES" not in sql + + def test_edge_table_with_key_columns(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"], + key_columns=["id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"])) + sql, params = et.to_sql() + assert 'KEY ("id")' in sql + + def test_alter_add_vertex(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "new_table", labels=["NewLabel"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "ADD", "VERTEX TABLES", + vertex_tables=[vt]) + sql, params = expr.to_sql() + assert "ALTER PROPERTY GRAPH" in sql + assert "ADD" in sql + + def test_alter_drop_vertex_tables(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "old_table") + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "DROP", "VERTEX TABLES", + vertex_tables=[vt]) + sql, params = expr.to_sql() + assert "DROP" in sql + assert "VERTEX TABLES" in sql + assert '"old_table"' in sql + + def test_alter_with_edge_tables(self, pg19_dialect: PostgresDialect): + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "DROP", "EDGE TABLES", + edge_tables=[et]) + sql, params = expr.to_sql() + assert "DROP" in sql + assert '"knows"' in sql + + def test_alter_with_both_tables(self, pg19_dialect: PostgresDialect): + vt = VertexTable(pg19_dialect, "person") + et = EdgeTable(pg19_dialect, "knows", ["pid"], ["fid"]) + expr = AlterPropertyGraphExpression(pg19_dialect, "g", "ADD", "TABLES", + vertex_tables=[vt], edge_tables=[et]) + sql, params = expr.to_sql() + assert '"person"' in sql + assert '"knows"' in sql + + +class TestPGQUnsupportedFormat: + """Tests that PG15 raises errors for PGQ formatting.""" + + @pytest.fixture + def pg15_dialect(self): + return PostgresDialect((15, 0, 0)) + + def test_graph_vertex_unsupported(self, pg15_dialect: PostgresDialect): + v = GraphVertex(pg15_dialect, "p", "person") + with pytest.raises(Exception): + v.to_sql() + + def test_graph_edge_unsupported(self, pg15_dialect: PostgresDialect): + e = GraphEdge(pg15_dialect, "e", "knows", GraphEdgeDirection.RIGHT) + with pytest.raises(Exception): + e.to_sql() + + def test_graph_table_unsupported(self, pg15_dialect: PostgresDialect): + v = GraphVertex(pg15_dialect, "p", "person") + cols = ColumnsClause(pg15_dialect, GraphColumn("p", "name")) + m = MatchClause(pg15_dialect, v) + gt = GraphTableExpression(pg15_dialect, "g", m, cols) + with pytest.raises(Exception): + gt.to_sql() + + def test_vertex_table_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + with pytest.raises(UnsupportedFeatureError): + vt.to_sql() + + def test_edge_table_unsupported(self, pg15_dialect: PostgresDialect): + et = EdgeTable(pg15_dialect, "knows", ["pid"], ["fid"]) + with pytest.raises(UnsupportedFeatureError): + et.to_sql() + + def test_create_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + expr = CreatePropertyGraphExpression(pg15_dialect, "g", [vt]) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_drop_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + expr = DropPropertyGraphExpression(pg15_dialect, "g") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_alter_property_graph_unsupported(self, pg15_dialect: PostgresDialect): + vt = VertexTable(pg15_dialect, "person") + expr = AlterPropertyGraphExpression(pg15_dialect, "g", "ADD", "VERTEX TABLES", + vertex_tables=[vt]) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_table_properties_clause_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=["id"]) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() + + def test_table_properties_all_columns_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=None) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() + + def test_table_properties_none_unsupported(self, pg15_dialect: PostgresDialect): + p = TablePropertiesClause(pg15_dialect, columns=[]) + with pytest.raises(UnsupportedFeatureError): + p.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py index 7b9167e0..47c45e19 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expressions_transaction.py @@ -9,7 +9,7 @@ ReleaseSavepointExpression, SetTransactionExpression, ) -from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode +from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode # noqa: F401 class TestPostgreSQLBeginTransactionExpression: @@ -188,20 +188,20 @@ class TestPostgreSQLTransactionCapabilities: def test_supports_transaction_mode(self, postgres_dialect): """Test PostgreSQL supports transaction mode.""" - assert postgres_dialect.supports_transaction_mode() == True + assert postgres_dialect.supports_transaction_mode() == True # noqa: E712 def test_supports_isolation_level_in_begin(self, postgres_dialect): """Test PostgreSQL supports isolation level in BEGIN.""" - assert postgres_dialect.supports_isolation_level_in_begin() == True + assert postgres_dialect.supports_isolation_level_in_begin() == True # noqa: E712 def test_supports_read_only_transaction(self, postgres_dialect): """Test PostgreSQL supports READ ONLY transactions.""" - assert postgres_dialect.supports_read_only_transaction() == True + assert postgres_dialect.supports_read_only_transaction() == True # noqa: E712 def test_supports_deferrable_transaction(self, postgres_dialect): """Test PostgreSQL supports DEFERRABLE transactions.""" - assert postgres_dialect.supports_deferrable_transaction() == True + assert postgres_dialect.supports_deferrable_transaction() == True # noqa: E712 def test_supports_savepoint(self, postgres_dialect): """Test PostgreSQL supports savepoints.""" - assert postgres_dialect.supports_savepoint() == True + assert postgres_dialect.supports_savepoint() == True # noqa: E712 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_basic.py index fe1cacde..3ffb7e98 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_basic.py @@ -7,7 +7,7 @@ - get_database_info """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.introspection.types import DatabaseInfo from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_columns.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_columns.py index ada1ac50..3ca217c2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_columns.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_columns.py @@ -6,7 +6,7 @@ """ import pytest -import pytest_asyncio +import pytest_asyncio # noqa: F401 from rhosocial.activerecord.backend.introspection.types import ( ColumnInfo, @@ -150,12 +150,12 @@ def test_column_primary_key_detection(self, backend_with_tables): id_col = next(col for col in columns if col.name == "id") assert id_col is not None - assert id_col.is_primary_key == True + assert id_col.is_primary_key == True # noqa: E712 # Other columns should not be primary key for col in columns: if col.name != "id": - assert col.is_primary_key == False + assert col.is_primary_key == False # noqa: E712 def test_column_comment(self, backend_with_tables): """Test that column comments are retrieved.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_indexes.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_indexes.py index 3101a64d..df558dc4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_indexes.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_indexes.py @@ -128,7 +128,7 @@ def test_get_primary_key_no_pk(self, backend_with_tables): """) backend_with_tables.introspector.clear_cache() - pk = backend_with_tables.introspector.get_primary_key("no_pk_test") + pk = backend_with_tables.introspector.get_primary_key("no_pk_test") # noqa: F841 # PostgreSQL always has some index; may return None or first index # depending on implementation diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_named_connection_cli.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_named_connection_cli.py index 74136ead..3ae58da7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_named_connection_cli.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_named_connection_cli.py @@ -4,11 +4,11 @@ This module tests the CLI parameter resolution priority for PostgreSQL. """ -import os -import tempfile -import types +import os # noqa: F401 +import tempfile # noqa: F401 +import types # noqa: F401 from unittest.mock import MagicMock, patch -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig @@ -114,7 +114,7 @@ def test_named_connection_with_params(self): "rhosocial.activerecord.backend.named_connection.NamedConnectionResolver", return_value=mock_resolver, ): - config = resolve_connection_config_from_args(args) + config = resolve_connection_config_from_args(args) # noqa: F841 mock_resolver.resolve.assert_called_once_with({"database": "custom_db"}) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_resolver.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_resolver.py index ff0a3dc3..26c123a0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_resolver.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/named_connection/test_resolver.py @@ -8,7 +8,7 @@ - Integration tests using example_connections module """ import types -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch # noqa: F401 import pytest from rhosocial.activerecord.backend.named_connection.resolver import ( @@ -17,12 +17,12 @@ list_named_connections_in_module, ) from rhosocial.activerecord.backend.named_connection.exceptions import ( - NamedConnectionNotFoundError, - NamedConnectionModuleNotFoundError, + NamedConnectionNotFoundError, # noqa: F401 + NamedConnectionModuleNotFoundError, # noqa: F401 NamedConnectionInvalidReturnTypeError, - NamedConnectionNotCallableError, + NamedConnectionNotCallableError, # noqa: F401 NamedConnectionMissingParameterError, - NamedConnectionInvalidParameterError, + NamedConnectionInvalidParameterError, # noqa: F401 ) from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py index b256da89..24a1a307 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py @@ -14,7 +14,12 @@ create_parent, run_maintenance, ) -from rhosocial.activerecord.backend.impl.postgres.mixins.extensions.pg_partman import PostgresPgPartmanMixin +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresPgPartmanCreateParentExpression, # noqa: F401 + PostgresPgPartmanRunMaintenanceExpression, # noqa: F401 + PostgresPgPartmanUpdateConfigExpression, + PostgresPgPartmanDeleteConfigExpression, +) class TestPgPartmanMixin: @@ -59,7 +64,26 @@ def test_run_maintenance_with_table(self): assert sql.count("%s") == 1 def test_format_auto_partition_config(self): - """Test auto partition config formatting.""" - mixin = PostgresPgPartmanMixin() - result = mixin.format_auto_partition_config('events') - assert "part_config" in result + """Test auto partition config formatting via dialect.""" + dialect = PostgresDialect((14, 0, 0)) + expr = PostgresPgPartmanUpdateConfigExpression( + dialect=dialect, + parent_table='public.events', + automatic_maintenance='on', + ) + sql, params = dialect.format_pg_partman_update_config(expr) + assert "part_config" in sql + + def test_format_delete_config(self): + """Test part_config delete formatting via dialect.""" + dialect = PostgresDialect((14, 0, 0)) + expr = PostgresPgPartmanDeleteConfigExpression( + dialect=dialect, + parent_table='public.events', + schema='custom_partman', + ) + sql, params = dialect.format_pg_partman_delete_config(expr) + assert "DELETE FROM" in sql + assert '"custom_partman"."part_config"' in sql + assert "WHERE parent_table = %s" in sql + assert params == ('public.events',) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py index 058b9f4b..8a775349 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py @@ -16,6 +16,8 @@ ensure_extension_installed, async_ensure_extension_installed, ) +from rhosocial.activerecord.backend.impl.postgres.adapters.pgvector import PostgresVectorAdapter +from rhosocial.activerecord.backend.impl.postgres.types.pgvector import PostgresVector from rhosocial.activerecord.backend.errors import DatabaseError from rhosocial.activerecord.backend.expression import ( CreateTableExpression, @@ -54,6 +56,36 @@ def _embedding_column(dim=3): return ColumnDefinition(name="embedding", data_type=f"VECTOR({dim})") +def _vector_literal(value, vector_type="vector"): + adapter = PostgresVectorAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::{vector_type}" + + +def _ensure_vector_subtype_supported(backend, vector_type): + probe_table = f"test_pgvector_{vector_type}_probe" + backend.execute(f"DROP TABLE IF EXISTS {probe_table}") + try: + backend.execute(f"CREATE TABLE {probe_table} (value {vector_type}(3))") + except DatabaseError: + pytest.skip(f"pgvector type '{vector_type}' not supported on this server") + finally: + backend.execute(f"DROP TABLE IF EXISTS {probe_table}") + + +async def _async_ensure_vector_subtype_supported(backend, vector_type): + probe_table = f"test_pgvector_{vector_type}_probe_async" + await backend.execute(f"DROP TABLE IF EXISTS {probe_table}") + try: + await backend.execute(f"CREATE TABLE {probe_table} (value {vector_type}(3))") + except DatabaseError: + pytest.skip(f"pgvector type '{vector_type}' not supported on this server") + finally: + await backend.execute(f"DROP TABLE IF EXISTS {probe_table}") + + # --- Sync fixture and tests --- @@ -218,6 +250,45 @@ def test_vector_type_crud(self, vector_env): assert result.data[0]['embedding'] is not None assert result.data[1]['embedding'] is not None + def test_halfvec_round_trip(self, vector_env): + """Insert a halfvec value and verify PostgreSQL returns canonical text.""" + backend, _ = vector_env + _ensure_vector_subtype_supported(backend, "halfvec") + literal = _vector_literal(PostgresVector([1.0, 2.0, 3.0]), "halfvec") + + result = backend.fetch_one(f"SELECT {literal}::text AS embedding") + + assert result["embedding"] == "[1,2,3]" + + def test_sparsevec_round_trip(self, vector_env): + """Insert a sparsevec value and verify PostgreSQL returns canonical text.""" + backend, _ = vector_env + _ensure_vector_subtype_supported(backend, "sparsevec") + + result = backend.fetch_one( + "SELECT '{1:1.0,3:3.0}/3'::sparsevec::text AS embedding" + ) + + assert result["embedding"] == "{1:1,3:3}/3" + + def test_halfvec_null_round_trip(self, vector_env): + """Insert a NULL halfvec value and verify fetched value is None.""" + backend, _ = vector_env + _ensure_vector_subtype_supported(backend, "halfvec") + + result = backend.fetch_one("SELECT NULL::halfvec AS embedding") + + assert result["embedding"] is None + + def test_sparsevec_null_round_trip(self, vector_env): + """Insert a NULL sparsevec value and verify fetched value is None.""" + backend, _ = vector_env + _ensure_vector_subtype_supported(backend, "sparsevec") + + result = backend.fetch_one("SELECT NULL::sparsevec AS embedding") + + assert result["embedding"] is None + def test_l2_distance(self, vector_env): """Test L2 (Euclidean) distance operator <->.""" backend, dialect = vector_env @@ -682,6 +753,49 @@ async def test_async_vector_type_crud(self, async_vector_env): assert result.data[0]['embedding'] is not None assert result.data[1]['embedding'] is not None + @pytest.mark.asyncio + async def test_async_halfvec_round_trip(self, async_vector_env): + """Insert a halfvec value asynchronously and verify canonical text.""" + backend, _ = async_vector_env + await _async_ensure_vector_subtype_supported(backend, "halfvec") + literal = _vector_literal(PostgresVector([1.0, 2.0, 3.0]), "halfvec") + + result = await backend.fetch_one(f"SELECT {literal}::text AS embedding") + + assert result["embedding"] == "[1,2,3]" + + @pytest.mark.asyncio + async def test_async_sparsevec_round_trip(self, async_vector_env): + """Insert a sparsevec value asynchronously and verify canonical text.""" + backend, _ = async_vector_env + await _async_ensure_vector_subtype_supported(backend, "sparsevec") + + result = await backend.fetch_one( + "SELECT '{1:1.0,3:3.0}/3'::sparsevec::text AS embedding" + ) + + assert result["embedding"] == "{1:1,3:3}/3" + + @pytest.mark.asyncio + async def test_async_halfvec_null_round_trip(self, async_vector_env): + """Insert a NULL halfvec value asynchronously and verify fetched value is None.""" + backend, _ = async_vector_env + await _async_ensure_vector_subtype_supported(backend, "halfvec") + + result = await backend.fetch_one("SELECT NULL::halfvec AS embedding") + + assert result["embedding"] is None + + @pytest.mark.asyncio + async def test_async_sparsevec_null_round_trip(self, async_vector_env): + """Insert a NULL sparsevec value asynchronously and verify fetched value is None.""" + backend, _ = async_vector_env + await _async_ensure_vector_subtype_supported(backend, "sparsevec") + + result = await backend.fetch_one("SELECT NULL::sparsevec AS embedding") + + assert result["embedding"] is None + @pytest.mark.asyncio async def test_async_l2_distance(self, async_vector_env): """Test L2 (Euclidean) distance operator <->.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_functions_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_functions_integration.py new file mode 100644 index 00000000..610588ed --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_functions_integration.py @@ -0,0 +1,222 @@ +# tests/.../feature/backend/postgres/test_bitstring_functions_integration.py +""" +Integration tests for PostgreSQL bit string functions and operators. + +These tests require a live PostgreSQL connection and execute expressions +created by functions.bit_string against the database. +""" +import pytest + +from rhosocial.activerecord.backend.expression import core +from rhosocial.activerecord.backend.impl.postgres.functions.bit_string import ( + bit_and, + bit_concat, + bit_count, + bit_get_bit, + bit_length, + bit_length_func, + bit_not, + bit_octet_length, + bit_or, + bit_set_bit, + bit_shift_left, + bit_shift_right, + bit_xor, +) + + +def _bit(dialect, value): + return core.Subquery(dialect, f"B'{value}'") + + +def _execute_expression(backend, expression, alias="result"): + sql, params = expression.to_sql() + return backend.fetch_one(f"SELECT {sql} AS {alias}", params) + + +def _execute_text_expression(backend, expression, alias="result"): + sql, params = expression.to_sql() + return backend.fetch_one(f"SELECT ({sql})::text AS {alias}", params) + + +class TestSyncBitStringOperatorsIntegration: + """Synchronous integration tests for bit string operators.""" + + def test_bit_concat_executes(self, postgres_backend): + """Test bit string concatenation operator.""" + expr = bit_concat( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1010"), + _bit(postgres_backend.dialect, "11"), + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "101011" + + def test_bitwise_and_executes(self, postgres_backend): + """Test bitwise AND operator.""" + expr = bit_and( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1100"), + _bit(postgres_backend.dialect, "1010"), + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "1000" + + def test_bitwise_or_executes(self, postgres_backend): + """Test bitwise OR operator.""" + expr = bit_or( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1100"), + _bit(postgres_backend.dialect, "1010"), + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "1110" + + def test_bitwise_xor_executes(self, postgres_backend): + """Test bitwise XOR operator.""" + expr = bit_xor( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1100"), + _bit(postgres_backend.dialect, "1010"), + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "0110" + + def test_bitwise_not_executes(self, postgres_backend): + """Test bitwise NOT operator.""" + expr = bit_not(postgres_backend.dialect, _bit(postgres_backend.dialect, "1010")) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "0101" + + def test_bit_shift_left_executes(self, postgres_backend): + """Test bitwise left shift operator.""" + expr = bit_shift_left( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1010"), + 1, + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "0100" + + def test_bit_shift_right_executes(self, postgres_backend): + """Test bitwise right shift operator.""" + expr = bit_shift_right( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "1010"), + 1, + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "0101" + + +class TestSyncBitStringFunctionsIntegration: + """Synchronous integration tests for bit string functions.""" + + def test_bit_length_executes(self, postgres_backend): + """Test length(bit) function.""" + expr = bit_length(postgres_backend.dialect, _bit(postgres_backend.dialect, "10101")) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == 5 + + def test_bit_length_func_executes(self, postgres_backend): + """Test bit_length(bit) function.""" + expr = bit_length_func(postgres_backend.dialect, _bit(postgres_backend.dialect, "10101")) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == 5 + + def test_bit_octet_length_executes(self, postgres_backend): + """Test octet_length(bit) function.""" + expr = bit_octet_length( + postgres_backend.dialect, + _bit(postgres_backend.dialect, "101010101"), + ) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == 2 + + def test_bit_get_bit_executes(self, postgres_backend): + """Test get_bit(bit, position) function.""" + expr = bit_get_bit(postgres_backend.dialect, _bit(postgres_backend.dialect, "1010"), 0) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == 1 + + def test_bit_set_bit_executes(self, postgres_backend): + """Test set_bit(bit, position, value) function.""" + expr = bit_set_bit(postgres_backend.dialect, _bit(postgres_backend.dialect, "1010"), 1, 1) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "1110" + + def test_bit_count_executes_on_postgres_14_plus(self, postgres_backend): + """Test bit_count(bit) function on PostgreSQL 14+.""" + if postgres_backend.get_server_version() < (14, 0, 0): + pytest.skip("bit_count requires PostgreSQL 14+") + + expr = bit_count(postgres_backend.dialect, _bit(postgres_backend.dialect, "101011")) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == 4 + + def test_bit_count_skips_before_postgres_14(self, postgres_backend): + """Test bit_count version gate for PostgreSQL versions before 14.""" + version = postgres_backend.get_server_version() + if version >= (14, 0, 0): + assert version >= (14, 0, 0) + return + + pytest.skip("bit_count requires PostgreSQL 14+") + + +class TestAsyncBitStringFunctionsIntegration: + """Asynchronous integration tests for representative bit string functions.""" + + @pytest.mark.asyncio + async def test_async_bit_concat_executes(self, async_postgres_backend): + """Test async bit string concatenation operator.""" + expr = bit_concat( + async_postgres_backend.dialect, + _bit(async_postgres_backend.dialect, "1010"), + _bit(async_postgres_backend.dialect, "11"), + ) + sql, params = expr.to_sql() + result = await async_postgres_backend.fetch_one(f"SELECT ({sql})::text AS result", params) + + assert result["result"] == "101011" + + @pytest.mark.asyncio + async def test_async_bit_get_bit_executes(self, async_postgres_backend): + """Test async get_bit(bit, position) function.""" + expr = bit_get_bit( + async_postgres_backend.dialect, + _bit(async_postgres_backend.dialect, "1010"), + 0, + ) + sql, params = expr.to_sql() + result = await async_postgres_backend.fetch_one(f"SELECT {sql} AS result", params) + + assert result["result"] == 1 + + @pytest.mark.asyncio + async def test_async_bit_count_executes_on_postgres_14_plus(self, async_postgres_backend): + """Test async bit_count(bit) function on PostgreSQL 14+.""" + if await async_postgres_backend.get_server_version() < (14, 0, 0): + pytest.skip("bit_count requires PostgreSQL 14+") + + expr = bit_count( + async_postgres_backend.dialect, + _bit(async_postgres_backend.dialect, "101011"), + ) + sql, params = expr.to_sql() + result = await async_postgres_backend.fetch_one(f"SELECT {sql} AS result", params) + + assert result["result"] == 4 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_integration.py new file mode 100644 index 00000000..be457157 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_integration.py @@ -0,0 +1,205 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_bitstring_integration.py +""" +Integration tests for PostgreSQL bit string types with real database. + +These tests require a live PostgreSQL connection and test: +- bit(n) and varbit(n) storage and retrieval +- PostgresBitStringAdapter output accepted by PostgreSQL +- Sync/async round-trip behavior +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters.bit_string import ( + PostgresBitStringAdapter, +) +from rhosocial.activerecord.backend.impl.postgres.types.bit_string import PostgresBitString + + +BITSTRING_TABLE = "test_bitstring_types" +ASYNC_BITSTRING_TABLE = "test_bitstring_types_async" + + +def _bit_literal(value, length=None): + adapter = PostgresBitStringAdapter() + options = {"length": length} if length is not None else None + return adapter.to_database(value, str, options) + + +class TestSyncBitStringIntegration: + """Synchronous integration tests for bit string types.""" + + @pytest.fixture + def bitstring_test_table(self, postgres_backend): + """Create test table with bit string columns.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {BITSTRING_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {BITSTRING_TABLE} ( + id SERIAL PRIMARY KEY, + fixed_bits bit(8), + variable_bits varbit(32) + ) + """) + yield BITSTRING_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {BITSTRING_TABLE}") + + def test_insert_and_select_fixed_bit_string(self, postgres_backend, bitstring_test_table): + """Insert a short fixed bit string and verify PostgreSQL stores the padded value.""" + literal = _bit_literal(PostgresBitString("1010", length=8)) + + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (fixed_bits) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT fixed_bits::text AS fixed_bits FROM {bitstring_test_table} WHERE id = 1" + ) + + assert result["fixed_bits"] == "10100000" + + def test_insert_and_select_varbit_string(self, postgres_backend, bitstring_test_table): + """Insert a varbit value and verify it is returned without fixed-length padding.""" + literal = _bit_literal(PostgresBitString("10101")) + + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (variable_bits) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f""" + SELECT variable_bits::text AS variable_bits, + length(variable_bits) AS bit_length + FROM {bitstring_test_table} + WHERE id = 1 + """ + ) + + assert result["variable_bits"] == "10101" + assert result["bit_length"] == 5 + + def test_insert_bit_string_from_plain_str(self, postgres_backend, bitstring_test_table): + """Pass a plain 0/1 string through the adapter and verify the stored varbit text.""" + literal = _bit_literal("11011") + + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (variable_bits) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT variable_bits::text AS variable_bits FROM {bitstring_test_table}" + ) + + assert result["variable_bits"] == "11011" + + def test_insert_bit_string_from_int(self, postgres_backend, bitstring_test_table): + """Pass an integer through the adapter and verify it becomes the expected bit(8).""" + literal = _bit_literal(5, length=8) + + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (fixed_bits) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT fixed_bits::text AS fixed_bits FROM {bitstring_test_table}" + ) + + assert result["fixed_bits"] == "00000101" + + def test_null_bit_string_round_trip(self, postgres_backend, bitstring_test_table): + """Insert NULL bit strings and verify both bit columns are returned as None.""" + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (fixed_bits, variable_bits) VALUES (NULL, NULL)" + ) + result = postgres_backend.fetch_one( + f"SELECT fixed_bits, variable_bits FROM {bitstring_test_table}" + ) + + assert result["fixed_bits"] is None + assert result["variable_bits"] is None + + def test_where_filter_by_bit_string_value(self, postgres_backend, bitstring_test_table): + """Insert two varbit rows and verify equality filtering matches only one row.""" + expected = _bit_literal("10101") + other = _bit_literal("11100") + + postgres_backend.execute( + f"INSERT INTO {bitstring_test_table} (variable_bits) VALUES ({expected}), ({other})" + ) + result = postgres_backend.fetch_one( + f""" + SELECT COUNT(*) AS match_count + FROM {bitstring_test_table} + WHERE variable_bits = {expected} + """ + ) + + assert result["match_count"] == 1 + + def test_invalid_bit_string_rejected_before_insert(self): + """Pass a non-binary string and verify the adapter rejects it before SQL execution.""" + with pytest.raises(ValueError): + _bit_literal("10102") + + +class TestAsyncBitStringIntegration: + """Asynchronous integration tests for bit string types.""" + + @pytest_asyncio.fixture + async def async_bitstring_test_table(self, async_postgres_backend): + """Create async test table with bit string columns.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_BITSTRING_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_BITSTRING_TABLE} ( + id SERIAL PRIMARY KEY, + fixed_bits bit(8), + variable_bits varbit(32) + ) + """) + yield ASYNC_BITSTRING_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_BITSTRING_TABLE}") + + @pytest.mark.asyncio + async def test_async_fixed_bit_string_round_trip( + self, async_postgres_backend, async_bitstring_test_table + ): + """Insert a short bit(n) value asynchronously and verify the padded text result.""" + literal = _bit_literal(PostgresBitString("1010", length=8)) + + await async_postgres_backend.execute( + f"INSERT INTO {async_bitstring_test_table} (fixed_bits) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT fixed_bits::text AS fixed_bits FROM {async_bitstring_test_table}" + ) + + assert result["fixed_bits"] == "10100000" + + @pytest.mark.asyncio + async def test_async_varbit_round_trip( + self, async_postgres_backend, async_bitstring_test_table + ): + """Insert a varbit value asynchronously and verify the same bit text is returned.""" + literal = _bit_literal("10101") + + await async_postgres_backend.execute( + f"INSERT INTO {async_bitstring_test_table} (variable_bits) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT variable_bits::text AS variable_bits FROM {async_bitstring_test_table}" + ) + + assert result["variable_bits"] == "10101" + + @pytest.mark.asyncio + async def test_async_null_bit_string_round_trip( + self, async_postgres_backend, async_bitstring_test_table + ): + """Insert NULL bit strings asynchronously and verify fetched values are None.""" + await async_postgres_backend.execute( + f""" + INSERT INTO {async_bitstring_test_table} (fixed_bits, variable_bits) + VALUES (NULL, NULL) + """ + ) + result = await async_postgres_backend.fetch_one( + f"SELECT fixed_bits, variable_bits FROM {async_bitstring_test_table}" + ) + + assert result["fixed_bits"] is None + assert result["variable_bits"] is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index 4c080ec3..28c02950 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -4,21 +4,36 @@ This module tests the expression-based format methods for DDL operations, including materialized view refresh, comment, and partition expressions. """ -import pytest +from datetime import date, datetime +from decimal import Decimal import pytest + +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + PartitionClause, + PartitionStrategy, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PartitionValue, PostgresRefreshMaterializedViewExpression, PostgresCommentExpression, PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, - PostgresVacuumExpression, - PostgresAnalyzeExpression, + PostgresPartitionMetadataExpression, + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanDeleteConfigExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, + PostgresVacuumExpression, # noqa: F401 + PostgresAnalyzeExpression, # noqa: F401 ) from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( - PostgresExtendedStatisticsMixin, + PostgresExtendedStatisticsMixin, # noqa: F401 ) @@ -214,6 +229,176 @@ def test_comment_with_schema(self, dialect): assert params == ("Public users table",) +class TestPostgresPartitionedTableCreation: + """Test PostgreSQL CREATE TABLE ... PARTITION BY support.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_range_partitioned_parent_table(self, dialect): + """Test creating a RANGE-partitioned parent table.""" + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ + ColumnDefinition("id", "BIGINT"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + sql, params = expr.to_sql() + + assert sql.startswith('CREATE TABLE "events"') + assert 'PARTITION BY RANGE ("created_at")' in sql + assert params == () + + def test_create_list_partitioned_parent_table(self, dialect): + """Test creating a LIST-partitioned parent table.""" + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ + ColumnDefinition("id", "BIGINT"), + ColumnDefinition("status", "TEXT NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "status")], + ), + ) + sql, params = expr.to_sql() + + assert 'PARTITION BY LIST ("status")' in sql + assert params == () + + def test_create_hash_partitioned_parent_table_pg10(self): + """HASH parent table partitioning requires PostgreSQL 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.HASH, + keys=[Column(dialect, "tenant_id")], + ), + ) + + with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): + expr.to_sql() + + def test_create_hash_partitioned_parent_table_pg11(self): + """Test HASH parent table partitioning on PostgreSQL 11+.""" + dialect = PostgresDialect(version=(11, 0, 0)) + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.HASH, + keys=[Column(dialect, "tenant_id")], + ), + ) + sql, params = expr.to_sql() + + assert 'PARTITION BY HASH ("tenant_id")' in sql + assert params == () + + def test_create_partitioned_parent_table_pg9(self): + """Declarative parent table partitioning requires PostgreSQL 10+.""" + dialect = PostgresDialect(version=(9, 6, 0)) + expr = CreateTableExpression( + dialect=dialect, + table="events", + columns=[ColumnDefinition("created_at", "TIMESTAMP NOT NULL")], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + + with pytest.raises(Exception, match="Declarative table partitioning requires PostgreSQL 10"): + expr.to_sql() + + def test_key_partitioning_is_rejected(self, dialect): + """Generic PartitionClause rejects non-core KEY partitioning.""" + with pytest.raises(TypeError, match="PartitionStrategy"): + PartitionClause( + dialect=dialect, + method="KEY", + keys=[Column(dialect, "id")], + ) + + def test_multi_column_range_partitioned_parent_table(self, dialect): + """Test creating a RANGE-partitioned parent table with multiple partition keys.""" + expr = CreateTableExpression( + dialect=dialect, + table="tenanted_events", + columns=[ + ColumnDefinition("tenant_id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("payload", "TEXT"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "tenant_id"), Column(dialect, "created_at")], + ), + ) + sql, params = expr.to_sql() + + assert sql.startswith('CREATE TABLE "tenanted_events"') + assert 'PARTITION BY RANGE ("tenant_id", "created_at")' in sql + assert params == () + + +class TestPostgresPartitionValue: + """Test safe PostgreSQL partition bound value formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + @pytest.mark.parametrize( + "value,expected", + [ + (None, "NULL"), + ("MAXVALUE", "MAXVALUE"), + ("minvalue", "MINVALUE"), + ("default", "DEFAULT"), + ("2024-01-01", "'2024-01-01'"), + ("O'Reilly", "'O''Reilly'"), + (42, "42"), + (3.5, "3.5"), + (Decimal("12.30"), "12.30"), + (date(2024, 1, 1), "'2024-01-01'"), + (datetime(2024, 1, 1, 12, 30, 45), "'2024-01-01 12:30:45'"), + ], + ) + def test_partition_value_formats_whitelisted_values(self, dialect, value, expected): + expr = PartitionValue(dialect=dialect, value=value) + assert expr.to_sql() == (expected, ()) + + @pytest.mark.parametrize("value", [True, object(), ["x"]]) + def test_partition_value_rejects_invalid_types(self, dialect, value): + with pytest.raises(TypeError): + PartitionValue(dialect=dialect, value=value) + + @pytest.mark.parametrize("value", [float("inf"), float("nan"), Decimal("Infinity"), Decimal("NaN")]) + def test_partition_value_rejects_nonfinite_numbers(self, dialect, value): + with pytest.raises(ValueError): + PartitionValue(dialect=dialect, value=value) + + class TestPostgresCreatePartitionExpression: """Test PostgresCreatePartitionExpression.""" @@ -252,6 +437,22 @@ def test_create_list_partition(self, dialect): assert "IN" in sql assert params == () + @pytest.mark.parametrize("partition_type", ["RANGE", "LIST"]) + def test_create_default_partition(self, dialect, partition_type): + """Test CREATE TABLE ... PARTITION OF for DEFAULT catch-all partitions.""" + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name="orders_default", + parent_table="orders", + partition_type=partition_type, + partition_values={"default": True}, + ) + sql, params = expr.to_sql() + assert "PARTITION OF" in sql + assert sql.endswith(" DEFAULT") + assert "FOR VALUES" not in sql + assert params == () + def test_create_hash_partition_pg10(self): """Test HASH partitioning requires PG 11+.""" dialect_pg10 = PostgresDialect(version=(10, 0, 0)) @@ -262,7 +463,7 @@ def test_create_hash_partition_pg10(self): partition_type="HASH", partition_values={"modulus": 4, "remainder": 0}, ) - with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + with pytest.raises(Exception, match="HASH partitioning requires PostgreSQL 11"): expr.to_sql() def test_create_hash_partition_pg11(self, dialect): @@ -319,6 +520,34 @@ def test_create_partition_with_tablespace(self, dialect): assert "TABLESPACE" in sql assert "faststorage" in sql + @pytest.mark.parametrize( + "partition_type,partition_values,expected_clause", + [ + ("RANGE", {"from": date(2024, 1, 1), "to": date(2025, 1, 1)}, + "FROM ('2024-01-01') TO ('2025-01-01')"), + ("RANGE", {"from": datetime(2024, 1, 1, 12, 30), "to": datetime(2025, 1, 1, 0, 0)}, + "FROM ('2024-01-01 12:30:00') TO ('2025-01-01 00:00:00')"), + ("RANGE", {"from": Decimal("1.5"), "to": Decimal("10.5")}, + "FROM (1.5) TO (10.5)"), + ("LIST", {"values": ["active", "pending"]}, + "IN ('active', 'pending')"), + ("HASH", {"modulus": 6, "remainder": 3}, + "WITH (MODULUS 6, REMAINDER 3)"), + ], + ) + def test_create_partition_value_types(self, dialect, partition_type, partition_values, expected_clause): + """Test PARTITION OF with various value types and expressions (PG 12+).""" + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name="test_partition", + parent_table="test_parent", + partition_type=partition_type, + partition_values=partition_values, + ) + sql, params = expr.to_sql() + assert expected_clause in sql + assert params == () + class TestPostgresDetachPartitionExpression: """Test PostgresDetachPartitionExpression.""" @@ -361,7 +590,8 @@ def test_detach_concurrently_pg14(self, dialect): concurrently=True, ) sql, params = expr.to_sql() - assert "DETACH CONCURRENTLY" in sql + assert "DETACH PARTITION" in sql + assert "CONCURRENTLY" in sql def test_detach_finalize_requires_concurrently(self, dialect): """Test FINALIZE requires CONCURRENTLY.""" @@ -374,6 +604,21 @@ def test_detach_finalize_requires_concurrently(self, dialect): with pytest.raises(ValueError, match="FINALIZE only valid with CONCURRENTLY"): expr.to_sql() + def test_detach_concurrently_finalize_pg14(self, dialect): + """Test DETACH CONCURRENTLY FINALIZE on PG 14+.""" + expr = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name="orders_2023", + parent_table="orders", + concurrently=True, + finalize=True, + ) + sql, params = expr.to_sql() + assert "DETACH PARTITION" in sql + assert "CONCURRENTLY" in sql + assert "FINALIZE" in sql + assert sql.index("CONCURRENTLY") < sql.index("FINALIZE") + def test_detach_with_schema(self, dialect): """Test partition detach with schema.""" expr = PostgresDetachPartitionExpression( @@ -434,4 +679,269 @@ def test_attach_hash_partition(self, dialect): ) sql, params = expr.to_sql() assert "FOR VALUES" in sql - assert "MODULUS 4" in sql \ No newline at end of file + assert "MODULUS 4" in sql + + def test_attach_range_partition_requires_bounds(self, dialect): + """RANGE attach requires explicit from/to bounds.""" + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01"}, + ) + with pytest.raises(ValueError, match="RANGE partition requires"): + expr.to_sql() + + def test_attach_list_partition_requires_values(self, dialect): + """LIST attach requires non-empty values.""" + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_empty", + parent_table="orders", + partition_type="LIST", + partition_values={"values": []}, + ) + with pytest.raises(ValueError, match="LIST partition requires"): + expr.to_sql() + + def test_attach_hash_partition_requires_pg11(self): + """HASH attach requires PostgreSQL 11+.""" + dialect = PostgresDialect(version=(10, 0, 0)) + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_shard0", + parent_table="orders", + partition_type="HASH", + partition_values={"modulus": 4, "remainder": 0}, + ) + with pytest.raises(ValueError, match="HASH partitioning requires PostgreSQL 11"): + expr.to_sql() + + def test_attach_concurrently_pg13(self): + """ATTACH CONCURRENTLY requires PG 14+.""" + dialect_pg13 = PostgresDialect(version=(13, 0, 0)) + expr = PostgresAttachPartitionExpression( + dialect=dialect_pg13, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + concurrently=True, + ) + with pytest.raises(ValueError, match="ATTACH CONCURRENTLY requires PostgreSQL 14"): + expr.to_sql() + + def test_attach_concurrently_pg14(self, dialect): + """ATTACH CONCURRENTLY with PG 14+ includes CONCURRENTLY keyword.""" + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_2024_q1", + parent_table="orders", + partition_type="RANGE", + partition_values={"from": "2024-01-01", "to": "2024-04-01"}, + concurrently=True, + ) + sql, params = expr.to_sql() + assert "CONCURRENTLY" in sql + assert "FOR VALUES" in sql + assert params == () + + def test_attach_default_range_partition(self, dialect): + """ATTACH DEFAULT partition for RANGE should emit DEFAULT keyword.""" + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_default", + parent_table="orders", + partition_type="RANGE", + partition_values={"default": True}, + ) + sql, params = expr.to_sql() + assert "DEFAULT" in sql + assert "FOR VALUES" not in sql + assert params == () + + def test_attach_default_list_partition(self, dialect): + """ATTACH DEFAULT partition for LIST should emit DEFAULT keyword.""" + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name="orders_default", + parent_table="orders", + partition_type="LIST", + partition_values={"default": True}, + ) + sql, params = expr.to_sql() + assert "DEFAULT" in sql + assert "FOR VALUES" not in sql + assert params == () + + +class TestPostgresPartitionMetadataExpression: + """Test PostgreSQL partition metadata query expression.""" + + def test_metadata_query_for_parent(self): + """Metadata query uses pg_catalog and parameter binding.""" + dialect = PostgresDialect(version=(14, 0, 0)) + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table="orders", + ) + sql, params = expr.to_sql() + assert "pg_get_partkeydef" in sql + assert "pg_inherits" in sql + assert params == ("orders",) + + def test_metadata_query_with_schema(self): + """Metadata query includes schema filter when schema is specified.""" + dialect = PostgresDialect(version=(14, 0, 0)) + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table="orders", + schema="public", + ) + sql, params = expr.to_sql() + assert "pg_get_partkeydef" in sql + assert "parent_ns.nspname = %s" in sql + assert params == ("orders", "public") + + def test_metadata_query_without_partitions(self): + """Metadata query omits partition details when include_partitions=False.""" + dialect = PostgresDialect(version=(14, 0, 0)) + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table="orders", + include_partitions=False, + ) + sql, params = expr.to_sql() + assert "pg_inherits" not in sql + assert "NULL::text AS name" in sql + assert params == ("orders",) + + def test_metadata_query_requires_pg10(self): + """Metadata introspection follows PostgreSQL declarative partition support.""" + dialect = PostgresDialect(version=(9, 6, 0)) + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table="orders", + ) + with pytest.raises(Exception, match="partition metadata introspection"): + expr.to_sql() + + +class TestPostgresPgPartmanExpressions: + """Test pg_partman maintenance expression SQL generation.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_parent_expression(self, dialect): + """pg_partman create_parent uses named arguments and bound parameters.""" + expr = PostgresPgPartmanCreateParentExpression( + dialect=dialect, + parent_table="public.events", + control="created_at", + interval="1 month", + partition_type="range", + premake=2, + schema="partman", + ) + sql, params = expr.to_sql() + assert '"partman"."create_parent"' in sql + assert "p_parent_table" in sql + assert "p_premake" in sql + assert params == ("public.events", "created_at", "1 month", "range", 2) + + def test_create_parent_with_optional_params(self, dialect): + """pg_partman create_parent with all optional parameters.""" + expr = PostgresPgPartmanCreateParentExpression( + dialect=dialect, + parent_table="public.events", + control="created_at", + interval="1 month", + partition_type="native", + premake=6, + start_partition="2026-01-01", + primary_key="id", + default_table=True, + constraint_cols=["tenant_id"], + template_table="public.events_template", + epoch="seconds", + jobmon=False, + schema="partman", + ) + sql, params = expr.to_sql() + assert "p_premake" in sql + assert "p_start_partition" in sql + assert "p_primary_key" in sql + assert "p_default_table" in sql + assert "p_constraint_cols" in sql + assert "p_template_table" in sql + assert "p_epoch" in sql + assert "p_jobmon" in sql + assert params == ( + "public.events", "created_at", "1 month", "native", 6, + "2026-01-01", "id", True, ["tenant_id"], "public.events_template", + "seconds", False, + ) + + def test_update_config_expression(self, dialect): + """pg_partman update_config updates only requested options.""" + expr = PostgresPgPartmanUpdateConfigExpression( + dialect=dialect, + parent_table="public.events", + automatic_maintenance="on", + infinite_time_partitions=True, + retention="3 months", + retention_keep_table=False, + retention_keep_index=True, + schema="partman", + ) + sql, params = expr.to_sql() + assert 'UPDATE "partman"."part_config"' in sql + assert "automatic_maintenance" in sql + assert "infinite_time_partitions" in sql + assert "retention_keep_index" in sql + assert params == ("on", True, "3 months", False, True, "public.events") + + def test_update_config_requires_at_least_one_option(self, dialect): + """pg_partman update_config rejects empty updates.""" + expr = PostgresPgPartmanUpdateConfigExpression( + dialect=dialect, + parent_table="public.events", + schema="partman", + ) + with pytest.raises(ValueError, match="At least one pg_partman config option"): + expr.to_sql() + + def test_delete_config_expression(self, dialect): + """pg_partman delete_config targets one parent table.""" + expr = PostgresPgPartmanDeleteConfigExpression( + dialect=dialect, + parent_table="public.events", + schema="partman", + ) + sql, params = expr.to_sql() + assert sql == 'DELETE FROM "partman"."part_config" WHERE parent_table = %s' + assert params == ("public.events",) + + def test_run_maintenance_scoped_expression(self, dialect): + """pg_partman scoped maintenance targets one parent table.""" + expr = PostgresPgPartmanRunMaintenanceExpression( + dialect=dialect, + parent_table="public.events", + schema="partman", + ) + sql, params = expr.to_sql() + assert sql == 'SELECT "partman"."run_maintenance"(%s::text)' + assert params == ("public.events",) + + def test_run_maintenance_global_expression(self, dialect): + """pg_partman global maintenance omits the parent table argument.""" + expr = PostgresPgPartmanRunMaintenanceExpression( + dialect=dialect, + schema="partman", + ) + sql, params = expr.to_sql() + assert sql == 'SELECT "partman"."run_maintenance"()' + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 95246a55..6be8da1d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -7,23 +7,84 @@ - PostgresIndexSupport - PostgresMaterializedViewSupport """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.partition import PostgresPartitionMixin class TestPostgresPartitionSupportFeatureDetection: """Test PostgresPartitionSupport feature detection methods.""" - def test_supports_hash_partitioning_pg10(self): + def test_supports_table_partitioning_pg9(self): + """Declarative table partitioning requires PG 10+.""" + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_table_partitioning() is False + + def test_supports_table_partitioning_pg10(self): + """PostgreSQL 10 supports declarative table partitioning.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_table_partitioning() is True + + def test_supports_hash_table_partitioning_pg10(self): """HASH partitioning requires PG 11+.""" dialect = PostgresDialect(version=(10, 0, 0)) - assert dialect.supports_hash_partitioning() is False + assert dialect.supports_hash_table_partitioning() is False - def test_supports_hash_partitioning_pg11(self): + def test_supports_hash_table_partitioning_pg11(self): """PostgreSQL 11 supports HASH partitioning.""" dialect = PostgresDialect(version=(11, 0, 0)) - assert dialect.supports_hash_partitioning() is True + assert dialect.supports_hash_table_partitioning() is True + + def test_supports_partitioned_table_creation_pg9(self): + """Declarative partitioned table creation requires PG 10+.""" + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_partitioned_table_creation() is False + + def test_supports_partitioned_table_creation_pg10(self): + """PostgreSQL 10 supports partitioned parent table creation.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_partitioned_table_creation() is True + + def test_supports_range_and_list_partitioning_pg10(self): + """PostgreSQL 10 supports RANGE and LIST partitioning.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_range_table_partitioning() is True + assert dialect.supports_list_table_partitioning() is True + + def test_subpartitioning_not_exposed_yet(self): + """Nested partitioning is not exposed by this API yet.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_subpartitioning() is False + + def test_partition_introspection_pg9(self): + """Partition metadata introspection follows declarative partitioning support.""" + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_partition_metadata_introspection() is False + + def test_partition_introspection_pg10(self): + """PostgreSQL 10 supports partition metadata through pg_catalog.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_partition_metadata_introspection() is True + + def test_partition_maintenance_capabilities_pg9(self): + """Generic partition maintenance capabilities require PG 10+.""" + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_add_partition() is False + assert dialect.supports_drop_partition() is False + assert dialect.supports_truncate_partition() is False + assert dialect.supports_attach_partition() is False + assert dialect.supports_detach_partition() is False + + def test_partition_maintenance_capabilities_pg10(self): + """PostgreSQL maps generic maintenance capabilities to PG operations.""" + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_add_partition() is True + assert dialect.supports_drop_partition() is True + assert dialect.supports_truncate_partition() is True + assert dialect.supports_attach_partition() is True + assert dialect.supports_detach_partition() is True + assert dialect.supports_reorganize_partition() is False def test_supports_default_partition_pg10(self): """DEFAULT partition requires PG 11+.""" @@ -55,6 +116,16 @@ def test_supports_concurrent_detach_pg14(self): dialect = PostgresDialect(version=(14, 0, 0)) assert dialect.supports_concurrent_detach() is True + def test_supports_concurrent_attach_pg13(self): + """CONCURRENTLY ATTACH requires PG 14+.""" + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_concurrent_attach() is False + + def test_supports_concurrent_attach_pg14(self): + """PostgreSQL 14 supports CONCURRENTLY ATTACH.""" + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_concurrent_attach() is True + def test_supports_partition_bounds_expression_pg11(self): """Partition bounds expression requires PG 12+.""" dialect = PostgresDialect(version=(11, 0, 0)) @@ -86,6 +157,76 @@ def test_supports_partitionwise_aggregate_pg11(self): assert dialect.supports_partitionwise_aggregate() is True +class TestPostgresPartitionMixinDirect: + """Test PostgresPartitionMixin partition capability methods directly.""" + + class _Host: + version = (15, 0, 0) + + class _Pg10Host: + version = (10, 0, 0) + + class _LowHost: + version = (9, 6, 0) + + class _PartitionMixin(_Host, PostgresPartitionMixin): + pass + + class _PartitionMixinPg10(_Pg10Host, PostgresPartitionMixin): + pass + + class _PartitionMixinLow(_LowHost, PostgresPartitionMixin): + pass + + def test_supports_table_partitioning_direct(self): + """PostgreSQL 15 supports declarative table partitioning.""" + assert self._PartitionMixin().supports_table_partitioning() is True + + def test_supports_table_partitioning_low(self): + """PostgreSQL 9.6 does not support declarative table partitioning.""" + assert self._PartitionMixinLow().supports_table_partitioning() is False + + def test_supports_partitioned_table_creation_direct(self): + """Partitioned parent table creation follows table partitioning.""" + assert self._PartitionMixin().supports_partitioned_table_creation() is True + assert self._PartitionMixinLow().supports_partitioned_table_creation() is False + + def test_supports_range_and_list_partitioning_direct(self): + """PostgreSQL 10 supports RANGE and LIST partitioning.""" + mixin = self._PartitionMixinPg10() + assert mixin.supports_range_table_partitioning() is True + assert mixin.supports_list_table_partitioning() is True + + def test_supports_hash_table_partitioning_direct(self): + """HASH table partitioning follows PostgreSQL 11+ support.""" + assert self._PartitionMixin().supports_hash_table_partitioning() is True + assert self._PartitionMixinPg10().supports_hash_table_partitioning() is False + + def test_subpartitioning_not_exposed_direct(self): + """Nested partitioning is not exposed by this API yet.""" + assert self._PartitionMixin().supports_subpartitioning() is False + + def test_partition_introspection_direct(self): + """Partition metadata introspection follows table partitioning support.""" + assert self._PartitionMixin().supports_partition_metadata_introspection() is True + assert self._PartitionMixinLow().supports_partition_metadata_introspection() is False + + def test_partition_maintenance_capabilities_direct(self): + """Generic partition maintenance capabilities map to PostgreSQL operations.""" + mixin = self._PartitionMixin() + assert mixin.supports_add_partition() is True + assert mixin.supports_drop_partition() is True + assert mixin.supports_truncate_partition() is True + assert mixin.supports_attach_partition() is True + assert mixin.supports_detach_partition() is True + assert mixin.supports_reorganize_partition() is False + + def test_supports_concurrent_attach_direct(self): + """CONCURRENTLY ATTACH follows PG version.""" + assert self._PartitionMixin().supports_concurrent_attach() is True + assert self._PartitionMixinPg10().supports_concurrent_attach() is False + + class TestPostgresIndexSupportFeatureDetection: """Test PostgresIndexSupport feature detection methods.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py index c94bf719..6f03f418 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py @@ -25,7 +25,7 @@ def test_table_with_special_chars(self, postgres_backend): yield "test_security_chars" postgres_backend.execute("DROP TABLE IF EXISTS test_security_chars") - def test_default_string_with_single_quote_insert_and_retrieve(self, postgres_backend, test_table_with_special_chars): + def test_default_string_with_single_quote_insert_and_retrieve(self, postgres_backend, test_table_with_special_chars): # noqa: E501 """Test that single quotes in DEFAULT are properly escaped and retrieved correctly.""" # Insert a row with special characters postgres_backend.execute( @@ -59,7 +59,7 @@ def test_valid_data_type_works(self, postgres_backend, test_table_for_data_type) result = postgres_backend.fetch_one(f""" SELECT column_name FROM information_schema.columns WHERE table_name = 'test_data_type_security' - """) + """) # noqa: F541 assert result is not None def test_malicious_data_type_rejected_at_dialect_level(self, postgres_backend): @@ -116,7 +116,7 @@ def test_exclude_constraint_overlap_rejected(self, postgres_backend, exclude_tab ) # Try to insert overlapping range - should fail - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 postgres_backend.execute( f"INSERT INTO {exclude_table} (range) VALUES ('[5,15)')" ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_integration.py new file mode 100644 index 00000000..52842575 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_integration.py @@ -0,0 +1,205 @@ +# tests/.../feature/backend/postgres/test_enum_integration.py +""" +Integration tests for PostgreSQL ENUM types with real database. + +These tests require a live PostgreSQL connection and test: +- CREATE TYPE / DROP TYPE lifecycle +- enum column storage and retrieval +- EnumTypeManager operations against PostgreSQL +- Sync/async round-trip behavior +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresEnumAdapter +from rhosocial.activerecord.backend.impl.postgres.types.enum import ( + EnumTypeManager, + PostgresEnumType, +) + + +ENUM_TYPE = "test_status_enum" +ENUM_TABLE = "test_enum_types" +ASYNC_ENUM_TYPE = "test_status_enum_async" +ASYNC_ENUM_TABLE = "test_enum_types_async" + + +def _enum_literal(value, enum_type): + adapter = PostgresEnumAdapter() + database_value = adapter.to_database(value, str, options={"enum_type": enum_type}) + if database_value is None: + return "NULL" + escaped = database_value.replace("'", "''") + type_sql, _ = enum_type.to_sql() + return f"'{escaped}'::{type_sql}" + + +class TestSyncEnumIntegration: + """Synchronous integration tests for PostgreSQL enum types.""" + + @pytest.fixture + def enum_type(self, postgres_backend): + """Create and drop a PostgreSQL enum type for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {ENUM_TABLE}") + postgres_backend.execute(f"DROP TYPE IF EXISTS {ENUM_TYPE}") + enum_type = PostgresEnumType( + dialect=postgres_backend.dialect, + name=ENUM_TYPE, + values=["pending", "processing", "ready", "failed"], + ) + manager = EnumTypeManager(postgres_backend) + manager.create_type(enum_type) + yield enum_type + postgres_backend.execute(f"DROP TABLE IF EXISTS {ENUM_TABLE}") + manager.drop_type(enum_type, if_exists=True) + + @pytest.fixture + def enum_test_table(self, postgres_backend, enum_type): + """Create a table containing an enum column for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {ENUM_TABLE}") + type_sql, _ = enum_type.to_sql() + postgres_backend.execute(f""" + CREATE TABLE {ENUM_TABLE} ( + id SERIAL PRIMARY KEY, + status {type_sql} + ) + """) + yield ENUM_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {ENUM_TABLE}") + + def test_enum_type_manager_creates_type(self, postgres_backend, enum_type): + """Create an enum type and verify PostgreSQL reports it exists.""" + manager = EnumTypeManager(postgres_backend) + + assert manager.type_exists(ENUM_TYPE) is True + assert manager.get_type_values(ENUM_TYPE) == [ + "pending", + "processing", + "ready", + "failed", + ] + assert enum_type.validate_value("ready") is True + + def test_insert_and_select_enum_value( + self, postgres_backend, enum_test_table, enum_type + ): + """Insert an enum value and verify PostgreSQL returns the same label.""" + literal = _enum_literal("ready", enum_type) + + postgres_backend.execute( + f"INSERT INTO {enum_test_table} (status) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT status::text AS status FROM {enum_test_table} WHERE id = 1" + ) + + assert result["status"] == "ready" + + def test_insert_multiple_enum_values_and_filter( + self, postgres_backend, enum_test_table, enum_type + ): + """Insert two enum values and verify equality filtering matches one row.""" + pending = _enum_literal("pending", enum_type) + failed = _enum_literal("failed", enum_type) + + postgres_backend.execute( + f"INSERT INTO {enum_test_table} (status) VALUES ({pending}), ({failed})" + ) + result = postgres_backend.fetch_one(f""" + SELECT COUNT(*) AS match_count + FROM {enum_test_table} + WHERE status = {pending} + """) + + assert result["match_count"] == 1 + + def test_null_enum_round_trip(self, postgres_backend, enum_test_table): + """Insert a NULL enum value and verify the fetched value is None.""" + postgres_backend.execute( + f"INSERT INTO {enum_test_table} (status) VALUES (NULL)" + ) + result = postgres_backend.fetch_one( + f"SELECT status FROM {enum_test_table} WHERE id = 1" + ) + + assert result["status"] is None + + def test_invalid_enum_rejected_before_insert(self, enum_type): + """Pass an invalid enum label and verify the adapter rejects it before SQL.""" + with pytest.raises(ValueError): + _enum_literal("unknown", enum_type) + + def test_add_enum_value_with_manager(self, postgres_backend, enum_type): + """Add a new enum label and verify PostgreSQL exposes the updated value list.""" + manager = EnumTypeManager(postgres_backend) + + manager.add_value(enum_type, "archived", after="failed") + + assert manager.get_type_values(ENUM_TYPE) == [ + "pending", + "processing", + "ready", + "failed", + "archived", + ] + assert enum_type.validate_value("archived") is True + + +class TestAsyncEnumIntegration: + """Asynchronous integration tests for PostgreSQL enum types.""" + + @pytest_asyncio.fixture + async def async_enum_test_table(self, async_postgres_backend): + """Create an enum type and table for async tests.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_ENUM_TABLE}") + await async_postgres_backend.execute(f"DROP TYPE IF EXISTS {ASYNC_ENUM_TYPE}") + await async_postgres_backend.execute( + f"CREATE TYPE {ASYNC_ENUM_TYPE} AS ENUM ('pending', 'ready', 'failed')" + ) + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_ENUM_TABLE} ( + id SERIAL PRIMARY KEY, + status {ASYNC_ENUM_TYPE} + ) + """) + enum_type = PostgresEnumType( + dialect=async_postgres_backend.dialect, + name=ASYNC_ENUM_TYPE, + values=["pending", "ready", "failed"], + ) + yield ASYNC_ENUM_TABLE, enum_type + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_ENUM_TABLE}") + await async_postgres_backend.execute(f"DROP TYPE IF EXISTS {ASYNC_ENUM_TYPE}") + + @pytest.mark.asyncio + async def test_async_enum_round_trip( + self, async_postgres_backend, async_enum_test_table + ): + """Insert an enum value asynchronously and verify the same label is returned.""" + table_name, enum_type = async_enum_test_table + literal = _enum_literal("ready", enum_type) + + await async_postgres_backend.execute( + f"INSERT INTO {table_name} (status) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT status::text AS status FROM {table_name}" + ) + + assert result["status"] == "ready" + + @pytest.mark.asyncio + async def test_async_null_enum_round_trip( + self, async_postgres_backend, async_enum_test_table + ): + """Insert a NULL enum value asynchronously and verify fetched value is None.""" + table_name, _ = async_enum_test_table + + await async_postgres_backend.execute( + f"INSERT INTO {table_name} (status) VALUES (NULL)" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT status FROM {table_name}" + ) + + assert result["status"] is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py index e62e830e..d289917c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py @@ -8,12 +8,12 @@ """ import pytest from enum import Enum -from typing import Tuple +from typing import Tuple # noqa: F401 from rhosocial.activerecord.backend.impl.postgres import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.types import PostgresEnumType from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresEnumAdapter -from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport +from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, @@ -375,7 +375,7 @@ def test_dialect_implements_postgres_enum_type_support(self): """Test that PostgresDialect implements PostgresEnumTypeSupport.""" dialect = PostgresDialect() # Check via MRO - from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport + from rhosocial.activerecord.backend.impl.postgres.protocols import PostgresEnumTypeSupport # noqa: F811 assert PostgresEnumTypeSupport in dialect.__class__.__mro__ def test_create_enum_type_method(self): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py index e6076870..dfa112a3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_expressions.py @@ -13,9 +13,9 @@ PostgresCreatePartitionExpression, PostgresDetachPartitionExpression, PostgresAttachPartitionExpression, - PostgresReindexExpression, - PostgresCreateStatisticsExpression, - PostgresDropStatisticsExpression, + PostgresReindexExpression, # noqa: F401 + PostgresCreateStatisticsExpression, # noqa: F401 + PostgresDropStatisticsExpression, # noqa: F401 ) @@ -285,7 +285,7 @@ def test_detach_concurrently_pg14(self, dialect): concurrently=True ) sql, _ = expr.to_sql() - assert "DETACH CONCURRENTLY" in sql + assert "CONCURRENTLY" in sql def test_finalize_requires_concurrently(self, dialect): """Test FINALIZE only valid with CONCURRENTLY.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py index 59252820..cad58216 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py @@ -10,8 +10,8 @@ - pgvector extension functions - PostGIS extension functions """ -import pytest -from unittest.mock import MagicMock +import pytest # noqa: F401 +from unittest.mock import MagicMock # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.expression import core, operators @@ -19,7 +19,7 @@ from rhosocial.activerecord.backend.impl.postgres.functions.ltree import ( ltree_literal, lquery_literal, - ltxtquery_literal, + ltxtquery_literal, # noqa: F401 ltree_ancestor, ltree_descendant, ltree_matches, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_geometry_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_geometry_types.py index 7a384ed5..012da4ce 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_geometry_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_geometry_types.py @@ -6,7 +6,7 @@ - Point, Line, LineSegment, Box, Path, Polygon, Circle data classes - PostgresGeometryAdapter conversion """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.types.geometric import ( Point, @@ -67,19 +67,19 @@ class TestLine: def test_create_line(self): """Test creating a line.""" - l = Line(1.0, -1.0, 0.0) + l = Line(1.0, -1.0, 0.0) # noqa: E741 assert l.A == 1.0 assert l.B == -1.0 assert l.C == 0.0 def test_line_to_postgres_string(self): """Test line to PostgreSQL string conversion.""" - l = Line(1.0, -1.0, 0.0) + l = Line(1.0, -1.0, 0.0) # noqa: E741 assert l.to_postgres_string() == '{1.0,-1.0,0.0}' def test_line_from_postgres_string(self): """Test parsing PostgreSQL line string.""" - l = Line.from_postgres_string('{1.0,-1.0,0.0}') + l = Line.from_postgres_string('{1.0,-1.0,0.0}') # noqa: E741 assert l.A == 1.0 assert l.B == -1.0 assert l.C == 0.0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py index 0e16ece0..d5ccf14f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py """Tests for ILIKE protocol support in PostgreSQL dialect.""" -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.dialect.protocols import ILIKESupport diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 3d236f1a..19e40de9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -5,11 +5,31 @@ - PostgresIndexMixin feature detection - Format REINDEX statement - Format CREATE INDEX with PostgreSQL-specific options +- Format DROP INDEX with CONCURRENTLY +- Format ALTER INDEX +- PostgreSQL-specific IndexSupport protocol methods +- CreateIndexExpression / DropIndexExpression / PostgresAlterIndexExpression """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddIndex, + DropIndex as DropIndexAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition + +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, + DropIndexExpression, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PostgresReindexExpression +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.index import PostgresIndexMixin +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresAlterIndexExpression, + PostgresAlterIndexActionType, + PostgresReindexExpression, +) class TestIndexFeatureDetection: @@ -396,3 +416,536 @@ def test_create_index_multiple_columns(self): columns=["email", "name"] ) assert '("email", "name")' in sql + + +class TestIndexSupportProtocol: + """Test IndexSupport protocol methods.""" + + def test_supports_create_index(self): + assert PostgresDialect().supports_create_index() is True + + def test_supports_drop_index(self): + assert PostgresDialect().supports_drop_index() is True + + def test_supports_unique_index(self): + assert PostgresDialect().supports_unique_index() is True + + def test_supports_index_if_not_exists(self): + assert PostgresDialect((9, 5, 0)).supports_index_if_not_exists() is True + + def test_supports_index_if_not_exists_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_index_if_not_exists() is False + + def test_supports_index_if_exists(self): + assert PostgresDialect().supports_index_if_exists() is True + + def test_supports_index_type(self): + d = PostgresDialect() + assert d.supports_index_type() is True + + def test_supports_partial_index(self): + d = PostgresDialect() + assert d.supports_partial_index() is True + + def test_supports_functional_index(self): + d = PostgresDialect() + assert d.supports_functional_index() is True + + def test_supports_index_tablespace(self): + d = PostgresDialect() + assert d.supports_index_tablespace() is True + + def test_supports_index_include_pg10(self): + assert PostgresDialect((10, 0, 0)).supports_index_include() is False + + def test_supports_index_include_pg11(self): + assert PostgresDialect((11, 0, 0)).supports_index_include() is True + + def test_supports_concurrent_index_pg10(self): + assert PostgresDialect((10, 0, 0)).supports_concurrent_index() is False + + def test_supports_concurrent_index_pg11(self): + assert PostgresDialect((11, 0, 0)).supports_concurrent_index() is True + + def test_get_supported_index_types(self): + types = PostgresDialect().get_supported_index_types() + assert types == ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] + + +class TestFulltextNotSupported: + """PostgreSQL does not support MySQL-style FULLTEXT indexes.""" + + def test_supports_fulltext_index(self): + assert PostgresDialect().supports_fulltext_index() is False + + def test_supports_fulltext_parser(self): + assert PostgresDialect().supports_fulltext_parser() is False + + def test_supports_fulltext_boolean_mode(self): + assert PostgresDialect().supports_fulltext_boolean_mode() is False + + def test_supports_fulltext_query_expansion(self): + assert PostgresDialect().supports_fulltext_query_expansion() is False + + def test_format_fulltext_match_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_fulltext_match(["col"], "search") + + def test_format_create_fulltext_index_statement_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_create_fulltext_index_statement(None) + + def test_format_drop_fulltext_index_statement_raises(self): + with pytest.raises(UnsupportedFeatureError): + PostgresDialect().format_drop_fulltext_index_statement(None) + + +class TestCreateIndexExpression: + """Test CreateIndexExpression with NULLS NOT DISTINCT.""" + + def test_basic_create(self): + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression(d, "idx_test", "t", ["a"]) + sql, _ = expr.to_sql() + assert sql == 'CREATE INDEX "idx_test" ON "t" ("a")' + + def test_unique(self): + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression(d, "idx_u", "t", ["a"], unique=True) + sql, _ = expr.to_sql() + assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a")' + + def test_nulls_not_distinct(self): + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + ) + sql, _ = expr.to_sql() + assert sql == 'CREATE UNIQUE INDEX "idx_u" ON "t" ("a") NULLS NOT DISTINCT' + + def test_nulls_not_distinct_pg14_raises(self): + d = PostgresDialect((14, 0, 0)) + expr = CreateIndexExpression( + d, "idx_u", "t", ["a"], unique=True, dialect_options={"nulls_not_distinct": True} + ) + with pytest.raises(ValueError, match="NULLS NOT DISTINCT requires PostgreSQL 15"): + expr.to_sql() + + def test_nulls_not_distinct_non_unique_raises(self): + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression( + d, "idx_u", "t", ["a"], unique=False, dialect_options={"nulls_not_distinct": True} + ) + with pytest.raises(ValueError, match="NULLS NOT DISTINCT is only valid for UNIQUE"): + expr.to_sql() + + def test_concurrent_and_nulls_not_distinct_pg15_raises(self): + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression( + d, "idx_u", "t", ["a"], + unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + ) + with pytest.raises(ValueError, match="CONCURRENTLY.*NULLS NOT DISTINCT.*PostgreSQL 16"): + expr.to_sql() + + def test_concurrent_and_nulls_not_distinct_pg16(self): + d = PostgresDialect((16, 0, 0)) + expr = CreateIndexExpression( + d, "idx_u", "t", ["a"], + unique=True, concurrent=True, dialect_options={"nulls_not_distinct": True}, + ) + sql, _ = expr.to_sql() + assert "NULLS NOT DISTINCT" in sql + assert "CONCURRENTLY" in sql + + +class TestDropIndexExpression: + """Test DropIndexExpression with CONCURRENTLY.""" + + def test_basic_drop(self): + d = PostgresDialect((18, 0, 0)) + expr = DropIndexExpression(d, "idx_test") + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX "idx_test"' + + def test_drop_if_exists(self): + d = PostgresDialect((18, 0, 0)) + expr = DropIndexExpression(d, "idx_test", if_exists=True) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX IF EXISTS "idx_test"' + + def test_drop_concurrently(self): + d = PostgresDialect((18, 0, 0)) + expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX CONCURRENTLY "idx_test"' + + def test_drop_concurrently_pg17_raises(self): + d = PostgresDialect((17, 0, 0)) + expr = DropIndexExpression(d, "idx_test", dialect_options={"concurrent": True}) + with pytest.raises(ValueError, match="DROP INDEX CONCURRENTLY requires PostgreSQL 18"): + expr.to_sql() + + def test_drop_concurrently_if_exists(self): + d = PostgresDialect((18, 0, 0)) + expr = DropIndexExpression(d, "idx_test", if_exists=True, dialect_options={"concurrent": True}) + sql, _ = expr.to_sql() + assert sql == 'DROP INDEX CONCURRENTLY IF EXISTS "idx_test"' + + +class TestPostgresAlterIndexExpression: + """Test PostgresAlterIndexExpression for all action types.""" + + def test_rename_to(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO, new_name="idx_new" + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" RENAME TO "idx_new"' + + def test_rename_to_if_exists(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO, + new_name="idx_new", if_exists=True, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX IF EXISTS "idx_old" RENAME TO "idx_new"' + + def test_rename_to_missing_name_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RENAME_TO + ) + with pytest.raises(ValueError, match="new_name is required"): + expr.to_sql() + + def test_set_tablespace(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_TABLESPACE, + tablespace="fast_ts", + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" SET TABLESPACE "fast_ts"' + + def test_set_storage_parameters(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70}, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" SET (fillfactor = 70)' + + def test_set_storage_parameters_multiple(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70, "deduplicate_items": "off"}, + ) + sql, _ = expr.to_sql() + assert "fillfactor = 70" in sql + assert "deduplicate_items = off" in sql + + def test_reset_storage_parameters(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS, + storage_parameters={"fillfactor": 70}, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" RESET (fillfactor)' + + def test_alter_column_statistics(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx_old", PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS, + column_number=1, statistics_target=100, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX "idx_old" ALTER COLUMN 1 SET STATISTICS 100' + + def test_all_in_tablespace(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", target_tablespace="new_ts", + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX ALL IN TABLESPACE "old_ts" SET TABLESPACE "new_ts"' + + def test_all_in_tablespace_nowait(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", target_tablespace="new_ts", nowait=True, + ) + sql, _ = expr.to_sql() + assert sql == 'ALTER INDEX ALL IN TABLESPACE "old_ts" SET TABLESPACE "new_ts" NOWAIT' + + +class TestFormatAddDropIndexAction: + """Test format_add/drop_index_action raise UnsupportedFeatureError for PostgreSQL.""" + + def test_format_add_index_action_raises(self): + d = PostgresDialect() + add = AddIndex(d, index=IndexDefinition(name="idx_test", columns=["a"])) + with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE ADD INDEX"): + d.format_add_index_action(add) + + def test_format_drop_index_action_raises(self): + d = PostgresDialect() + drop = DropIndexAction(d, index_name="idx_test") + with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE DROP INDEX"): + d.format_drop_index_action(drop) + + +class TestDialectIndexSupport: + """Test the 5 PostgresDialect-level index support methods (B1–B5).""" + + def test_supports_create_index(self): + assert PostgresDialect().supports_create_index() is True + + def test_supports_drop_index(self): + assert PostgresDialect().supports_drop_index() is True + + def test_supports_unique_index(self): + assert PostgresDialect().supports_unique_index() is True + + def test_supports_index_if_not_exists(self): + assert PostgresDialect((15, 0, 0)).supports_index_if_not_exists() is True + + def test_supports_index_if_exists(self): + assert PostgresDialect().supports_index_if_exists() is True + + +class TestCreateIndexExpressionAllOptions: + """Expand CREATE INDEX expression coverage — all remaining options.""" + + def test_concurrent(self): + d = PostgresDialect((11, 0, 0)) + expr = CreateIndexExpression(d, "idx_c", "t", ["a"], concurrent=True) + sql, _ = expr.to_sql() + assert "CONCURRENTLY" in sql + + def test_concurrent_pg10_raises(self): + d = PostgresDialect((10, 0, 0)) + expr = CreateIndexExpression(d, "idx_c", "t", ["a"], concurrent=True) + with pytest.raises(ValueError, match="CONCURRENTLY requires PostgreSQL 11"): + expr.to_sql() + + def test_if_not_exists(self): + d = PostgresDialect() + expr = CreateIndexExpression(d, "idx_t", "t", ["a"], if_not_exists=True) + sql, _ = expr.to_sql() + assert "IF NOT EXISTS" in sql + + def test_index_type(self): + d = PostgresDialect() + expr = CreateIndexExpression(d, "idx_h", "t", ["a"], index_type="hash") + sql, _ = expr.to_sql() + assert "USING hash" in sql + + def test_include(self): + d = PostgresDialect((12, 0, 0)) + expr = CreateIndexExpression( + d, "idx_i", "t", ["a"], include=["b", "c"] + ) + sql, _ = expr.to_sql() + assert 'INCLUDE ("b", "c")' in sql + + def test_tablespace(self): + d = PostgresDialect() + expr = CreateIndexExpression( + d, "idx_t", "t", ["a"], tablespace="fast_ts" + ) + sql, _ = expr.to_sql() + assert 'TABLESPACE "fast_ts"' in sql + + def test_where(self): + d = PostgresDialect() + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression import Column + expr = CreateIndexExpression( + d, "idx_w", "t", ["a"], + where=Column(d, "status") == Literal(d, 1), + ) + sql, params = expr.to_sql() + assert "WHERE" in sql + assert "%s" in sql + assert params == (1,) + + def test_with_options(self): + d = PostgresDialect() + expr = CreateIndexExpression( + d, "idx_w", "t", ["a"], + dialect_options={"with": {"fillfactor": 70}}, + ) + sql, _ = expr.to_sql() + assert "WITH (fillfactor = 70)" in sql + + def test_opclasses(self): + d = PostgresDialect() + expr = CreateIndexExpression( + d, "idx_o", "t", ["a", "b"], + dialect_options={"opclasses": {"a": "text_pattern_ops"}}, + ) + sql, _ = expr.to_sql() + assert '"a" text_pattern_ops' in sql + assert '"b"' in sql + + +class TestFormatCreateIndexPgStatementWhereExpression: + """Test format_create_index_pg_statement where_clause as ToSQLProtocol.""" + + def test_where_expression(self): + d = PostgresDialect() + from rhosocial.activerecord.backend.expression import Column, Literal + sql, params = d.format_create_index_pg_statement( + index_name="idx_active", + table_name="users", + columns=["email"], + where_clause=Column(d, "status") == Literal(d, 1), + ) + assert "WHERE" in sql + assert params == (1,) + + +class TestPostgresAlterIndexExpressionErrorPaths: + """Test error paths in ALTER INDEX operations.""" + + def test_all_in_tablespace_missing_source_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + target_tablespace="new_ts", + ) + with pytest.raises(ValueError, match="source_tablespace"): + expr.to_sql() + + def test_all_in_tablespace_missing_target_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "", PostgresAlterIndexActionType.ALL_IN_TABLESPACE, + source_tablespace="old_ts", + ) + with pytest.raises(ValueError, match="target_tablespace"): + expr.to_sql() + + def test_set_tablespace_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.SET_TABLESPACE, + ) + with pytest.raises(ValueError, match="tablespace is required"): + expr.to_sql() + + def test_set_storage_params_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.SET_STORAGE_PARAMETERS, + ) + with pytest.raises(ValueError, match="storage_parameters"): + expr.to_sql() + + def test_reset_storage_params_missing_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.RESET_STORAGE_PARAMETERS, + ) + with pytest.raises(ValueError, match="storage_parameters"): + expr.to_sql() + + def test_alter_statistics_missing_params_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression( + d, "idx", PostgresAlterIndexActionType.ALTER_COLUMN_STATISTICS, + ) + with pytest.raises(ValueError, match="column_number"): + expr.to_sql() + + +class TestPostgresIndexMixinDirect: + """Test PostgresIndexMixin directly (not through PostgresDialect).""" + + class _Host: + version = (9, 0, 0) + name = "PostgreSQL" + def format_identifier(self, s): return f'"{s}"' + + class _IndexMixinLow(_Host, PostgresIndexMixin): + pass + + class _HostHigh: + version = (15, 0, 0) + name = "PostgreSQL" + def format_identifier(self, s): return f'"{s}"' + + class _IndexMixinHigh(_HostHigh, PostgresIndexMixin): + pass + + def test_supports_create_statistics_low(self): + assert not self._IndexMixinLow().supports_create_statistics() + + def test_supports_statistics_mcv_low(self): + assert not self._IndexMixinLow().supports_statistics_mcv() + + def test_supports_statistics_dependencies_low(self): + assert not self._IndexMixinLow().supports_statistics_dependencies() + + def test_supports_statistics_ndistinct_low(self): + assert not self._IndexMixinLow().supports_statistics_ndistinct() + + def test_supports_create_index(self): + assert self._IndexMixinHigh().supports_create_index() is True + + def test_supports_drop_index(self): + assert self._IndexMixinHigh().supports_drop_index() is True + + def test_supports_unique_index(self): + assert self._IndexMixinHigh().supports_unique_index() is True + + def test_supports_index_if_not_exists_low(self): + assert not self._IndexMixinLow().supports_index_if_not_exists() + + def test_supports_index_if_exists(self): + assert self._IndexMixinHigh().supports_index_if_exists() is True + + def test_format_create_index_expression_column(self): + from rhosocial.activerecord.backend.expression import Column, Literal # noqa: F401 + expr = CreateIndexExpression( + PostgresDialect((15, 0, 0)), "idx_e", "t", [Column(PostgresDialect((15, 0, 0)), "a")], + ) + sql, _ = expr.to_sql() + assert '"a"' in sql + + def test_include_gist_low_raises(self): + d = PostgresDialect((11, 0, 0)) + expr = CreateIndexExpression( + d, "idx_g", "t", ["a"], include=["b"], index_type="gist", + ) + with pytest.raises(ValueError, match="INCLUDE for GiST"): + expr.to_sql() + + def test_include_spgist_low_raises(self): + d = PostgresDialect((12, 0, 0)) + expr = CreateIndexExpression( + d, "idx_s", "t", ["a"], include=["b"], index_type="spgist", + ) + with pytest.raises(ValueError, match="INCLUDE for SP-GiST"): + expr.to_sql() + + def test_unsupported_alter_index_action_raises(self): + d = PostgresDialect() + expr = PostgresAlterIndexExpression(d, "idx", "INVALID") + with pytest.raises(ValueError, match="Unsupported ALTER INDEX action"): + expr.to_sql() + + def test_reindex_expression_to_sql(self): + d = PostgresDialect((15, 0, 0)) + expr = PostgresReindexExpression(d, "INDEX", "idx_test") + sql, _ = expr.to_sql() + assert "REINDEX INDEX" in sql + assert "idx_test" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py new file mode 100644 index 00000000..f01a46d8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_lsn_integration.py @@ -0,0 +1,196 @@ +# tests/.../feature/backend/postgres/test_lsn_integration.py +""" +Integration tests for PostgreSQL pg_lsn type with real database. + +These tests require a live PostgreSQL connection and test: +- pg_lsn storage and retrieval +- PostgresLsnAdapter output accepted by PostgreSQL +- pg_lsn comparison and arithmetic +- Sync/async round-trip behavior +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters.pg_lsn import PostgresLsnAdapter +from rhosocial.activerecord.backend.impl.postgres.types.pg_lsn import PostgresLsn + + +LSN_TABLE = "test_lsn_types" +ASYNC_LSN_TABLE = "test_lsn_types_async" + + +def _requires_pg_lsn(backend): + if backend.get_server_version() < (9, 4, 0): + pytest.skip("pg_lsn requires PostgreSQL 9.4+") + + +async def _requires_pg_lsn_async(backend): + if await backend.get_server_version() < (9, 4, 0): + pytest.skip("pg_lsn requires PostgreSQL 9.4+") + + +def _lsn_literal(value): + adapter = PostgresLsnAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::pg_lsn" + + +class TestSyncLsnIntegration: + """Synchronous integration tests for PostgreSQL pg_lsn type.""" + + @pytest.fixture + def lsn_test_table(self, postgres_backend): + """Create a table containing a pg_lsn column for sync tests.""" + _requires_pg_lsn(postgres_backend) + postgres_backend.execute(f"DROP TABLE IF EXISTS {LSN_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {LSN_TABLE} ( + id SERIAL PRIMARY KEY, + lsn_value pg_lsn + ) + """) + yield LSN_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {LSN_TABLE}") + + def test_insert_and_select_lsn_value(self, postgres_backend, lsn_test_table): + """Insert a PostgresLsn value and verify PostgreSQL returns canonical text.""" + literal = _lsn_literal(PostgresLsn.from_string("16/B374D848")) + + postgres_backend.execute( + f"INSERT INTO {lsn_test_table} (lsn_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT lsn_value::text AS lsn_value FROM {lsn_test_table} WHERE id = 1" + ) + + assert result["lsn_value"] == "16/B374D848" + + def test_insert_lsn_from_int(self, postgres_backend, lsn_test_table): + """Pass an integer through the adapter and verify the stored pg_lsn text.""" + literal = _lsn_literal(0x0000001600000001) + + postgres_backend.execute( + f"INSERT INTO {lsn_test_table} (lsn_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT lsn_value::text AS lsn_value FROM {lsn_test_table}" + ) + + # PG19+ standardizes LSN output to %X/%08X format (zero-padded) + # PG18 and earlier omit leading zeros (e.g. "16/1") + version = postgres_backend.get_server_version() + if version >= (19, 0, 0): + assert result["lsn_value"] == "16/00000001" + else: + assert result["lsn_value"] == "16/1" + + def test_lsn_null_round_trip(self, postgres_backend, lsn_test_table): + """Insert a NULL pg_lsn value and verify the fetched value is None.""" + postgres_backend.execute( + f"INSERT INTO {lsn_test_table} (lsn_value) VALUES (NULL)" + ) + result = postgres_backend.fetch_one( + f"SELECT lsn_value FROM {lsn_test_table} WHERE id = 1" + ) + + assert result["lsn_value"] is None + + def test_lsn_comparison_filter(self, postgres_backend, lsn_test_table): + """Insert two pg_lsn rows and verify greater-than filtering matches one row.""" + lower = _lsn_literal("16/B374D848") + higher = _lsn_literal("16/B374D849") + + postgres_backend.execute( + f"INSERT INTO {lsn_test_table} (lsn_value) VALUES ({lower}), ({higher})" + ) + result = postgres_backend.fetch_one(f""" + SELECT COUNT(*) AS match_count + FROM {lsn_test_table} + WHERE lsn_value > {lower} + """) + + assert result["match_count"] == 1 + + def test_lsn_subtraction_executes(self, postgres_backend): + """Subtract two pg_lsn values and verify PostgreSQL returns byte distance.""" + _requires_pg_lsn(postgres_backend) + result = postgres_backend.fetch_one(""" + SELECT '16/B374D849'::pg_lsn - '16/B374D848'::pg_lsn AS distance + """) + + assert result["distance"] == 1 + + def test_lsn_addition_executes(self, postgres_backend): + """Add bytes to a pg_lsn value and verify PostgreSQL returns the new LSN.""" + if postgres_backend.get_server_version() < (14, 0, 0): + pytest.skip("pg_lsn addition requires PostgreSQL 14+") + result = postgres_backend.fetch_one(""" + SELECT ('16/B374D848'::pg_lsn + 1)::text AS lsn_value + """) + + assert result["lsn_value"] == "16/B374D849" + + def test_invalid_lsn_rejected_before_insert(self): + """Pass an invalid LSN string and verify the adapter rejects it before SQL.""" + with pytest.raises(ValueError): + _lsn_literal("not_an_lsn") + + +class TestAsyncLsnIntegration: + """Asynchronous integration tests for PostgreSQL pg_lsn type.""" + + @pytest_asyncio.fixture + async def async_lsn_test_table(self, async_postgres_backend): + """Create a table containing a pg_lsn column for async tests.""" + await _requires_pg_lsn_async(async_postgres_backend) + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_LSN_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_LSN_TABLE} ( + id SERIAL PRIMARY KEY, + lsn_value pg_lsn + ) + """) + yield ASYNC_LSN_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_LSN_TABLE}") + + @pytest.mark.asyncio + async def test_async_lsn_round_trip( + self, async_postgres_backend, async_lsn_test_table + ): + """Insert a pg_lsn value asynchronously and verify PostgreSQL returns it.""" + literal = _lsn_literal(PostgresLsn.from_string("16/B374D848")) + + await async_postgres_backend.execute( + f"INSERT INTO {async_lsn_test_table} (lsn_value) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT lsn_value::text AS lsn_value FROM {async_lsn_test_table}" + ) + + assert result["lsn_value"] == "16/B374D848" + + @pytest.mark.asyncio + async def test_async_lsn_null_round_trip( + self, async_postgres_backend, async_lsn_test_table + ): + """Insert a NULL pg_lsn value asynchronously and verify fetched value is None.""" + await async_postgres_backend.execute( + f"INSERT INTO {async_lsn_test_table} (lsn_value) VALUES (NULL)" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT lsn_value FROM {async_lsn_test_table}" + ) + + assert result["lsn_value"] is None + + @pytest.mark.asyncio + async def test_async_lsn_subtraction_executes(self, async_postgres_backend): + """Subtract pg_lsn values asynchronously and verify PostgreSQL byte distance.""" + await _requires_pg_lsn_async(async_postgres_backend) + result = await async_postgres_backend.fetch_one(""" + SELECT '16/B374D849'::pg_lsn - '16/B374D848'::pg_lsn AS distance + """) + + assert result["distance"] == 1 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_macaddr8_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_macaddr8_types.py index a2221b2e..222539c7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_macaddr8_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_macaddr8_types.py @@ -104,7 +104,7 @@ def test_to_bytes(self): """Test bytes conversion.""" mac = PostgresMacaddr8('08:00:2b:01:02:03:04:05') result = bytes(mac) - expected = b'\\x08\\x00\\x2b\\x01\\x02\\x03\\x04\\x05' + expected = b'\\x08\\x00\\x2b\\x01\\x02\\x03\\x04\\x05' # noqa: F841 assert len(result) == 8 assert result[0] == 0x08 assert result[1] == 0x00 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_money_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_money_integration.py new file mode 100644 index 00000000..392e4a2c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_money_integration.py @@ -0,0 +1,165 @@ +# tests/.../feature/backend/postgres/test_money_integration.py +""" +Integration tests for PostgreSQL MONEY type with real database. + +These tests require a live PostgreSQL connection and test: +- money storage and retrieval through stable numeric casts +- PostgresMoneyAdapter output accepted by PostgreSQL +- Sync/async round-trip behavior +""" +from decimal import Decimal + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters.monetary import ( + PostgresMoneyAdapter, +) +from rhosocial.activerecord.backend.impl.postgres.types.monetary import PostgresMoney + + +MONEY_TABLE = "test_money_types" +ASYNC_MONEY_TABLE = "test_money_types_async" + + +def _money_literal(value): + adapter = PostgresMoneyAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::money" + + +class TestSyncMoneyIntegration: + """Synchronous integration tests for PostgreSQL money type.""" + + @pytest.fixture + def money_test_table(self, postgres_backend): + """Create a table containing a money column for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {MONEY_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {MONEY_TABLE} ( + id SERIAL PRIMARY KEY, + amount money + ) + """) + yield MONEY_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {MONEY_TABLE}") + + def test_insert_and_select_money_value(self, postgres_backend, money_test_table): + """Insert a PostgresMoney value and verify PostgreSQL stores the numeric amount.""" + literal = _money_literal(PostgresMoney(Decimal("1234.56"))) + + postgres_backend.execute( + f"INSERT INTO {money_test_table} (amount) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT amount::numeric AS amount FROM {money_test_table} WHERE id = 1" + ) + + assert result["amount"] == Decimal("1234.56") + + def test_insert_negative_money_value(self, postgres_backend, money_test_table): + """Insert a negative money value and verify the sign survives round-trip.""" + literal = _money_literal(PostgresMoney(Decimal("-42.75"))) + + postgres_backend.execute( + f"INSERT INTO {money_test_table} (amount) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT amount::numeric AS amount FROM {money_test_table} WHERE id = 1" + ) + + assert result["amount"] == Decimal("-42.75") + + def test_insert_money_from_plain_decimal(self, postgres_backend, money_test_table): + """Pass a Decimal through the adapter and verify the stored money amount.""" + literal = _money_literal(Decimal("19.99")) + + postgres_backend.execute( + f"INSERT INTO {money_test_table} (amount) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT amount::numeric AS amount FROM {money_test_table}" + ) + + assert result["amount"] == Decimal("19.99") + + def test_null_money_round_trip(self, postgres_backend, money_test_table): + """Insert a NULL money value and verify the fetched value is None.""" + postgres_backend.execute( + f"INSERT INTO {money_test_table} (amount) VALUES (NULL)" + ) + result = postgres_backend.fetch_one( + f"SELECT amount FROM {money_test_table} WHERE id = 1" + ) + + assert result["amount"] is None + + def test_money_filter_by_numeric_value(self, postgres_backend, money_test_table): + """Insert two money rows and verify numeric equality matches only one row.""" + expected = _money_literal(PostgresMoney(Decimal("25.50"))) + other = _money_literal(PostgresMoney(Decimal("30.00"))) + + postgres_backend.execute( + f"INSERT INTO {money_test_table} (amount) VALUES ({expected}), ({other})" + ) + result = postgres_backend.fetch_one(f""" + SELECT COUNT(*) AS match_count + FROM {money_test_table} + WHERE amount = {expected} + """) + + assert result["match_count"] == 1 + + def test_invalid_money_rejected_before_insert(self): + """Pass an invalid money string and verify the adapter rejects it before SQL.""" + with pytest.raises(ValueError): + _money_literal("not_a_number") + + +class TestAsyncMoneyIntegration: + """Asynchronous integration tests for PostgreSQL money type.""" + + @pytest_asyncio.fixture + async def async_money_test_table(self, async_postgres_backend): + """Create a table containing a money column for async tests.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_MONEY_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_MONEY_TABLE} ( + id SERIAL PRIMARY KEY, + amount money + ) + """) + yield ASYNC_MONEY_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_MONEY_TABLE}") + + @pytest.mark.asyncio + async def test_async_money_round_trip( + self, async_postgres_backend, async_money_test_table + ): + """Insert a PostgresMoney value asynchronously and verify the numeric amount.""" + literal = _money_literal(PostgresMoney(Decimal("88.25"))) + + await async_postgres_backend.execute( + f"INSERT INTO {async_money_test_table} (amount) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT amount::numeric AS amount FROM {async_money_test_table}" + ) + + assert result["amount"] == Decimal("88.25") + + @pytest.mark.asyncio + async def test_async_null_money_round_trip( + self, async_postgres_backend, async_money_test_table + ): + """Insert a NULL money value asynchronously and verify fetched value is None.""" + await async_postgres_backend.execute( + f"INSERT INTO {async_money_test_table} (amount) VALUES (NULL)" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT amount FROM {async_money_test_table}" + ) + + assert result["amount"] is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_network_address_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_network_address_integration.py new file mode 100644 index 00000000..e76a9555 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_network_address_integration.py @@ -0,0 +1,236 @@ +# tests/.../feature/backend/postgres/test_network_address_integration.py +""" +Integration tests for PostgreSQL network address types with real database. + +These tests require a live PostgreSQL connection and test: +- inet and cidr storage and retrieval +- macaddr and macaddr8 storage and retrieval +- Network address adapters output accepted by PostgreSQL +- Sync/async round-trip behavior +""" +import ipaddress + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters.network_address import ( + PostgresMacaddr8Adapter, + PostgresMacaddrAdapter, + PostgresNetworkAddressAdapter, +) +from rhosocial.activerecord.backend.impl.postgres.types.network_address import ( + PostgresMacaddr, + PostgresMacaddr8, +) + + +NETWORK_TABLE = "test_network_address_types" +ASYNC_NETWORK_TABLE = "test_network_address_types_async" + + +def _inet_literal(value): + adapter = PostgresNetworkAddressAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::inet" + + +def _cidr_literal(value): + adapter = PostgresNetworkAddressAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::cidr" + + +def _macaddr_literal(value): + adapter = PostgresMacaddrAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::macaddr" + + +def _macaddr8_literal(value): + adapter = PostgresMacaddr8Adapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::macaddr8" + + +class TestSyncNetworkAddressIntegration: + """Synchronous integration tests for PostgreSQL network address types.""" + + @pytest.fixture + def network_test_table(self, postgres_backend): + """Create a table containing network address columns for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {NETWORK_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {NETWORK_TABLE} ( + id SERIAL PRIMARY KEY, + inet_value inet, + cidr_value cidr, + mac_value macaddr + ) + """) + yield NETWORK_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {NETWORK_TABLE}") + + def test_insert_and_select_inet_value(self, postgres_backend, network_test_table): + """Insert an IPv4 address and verify PostgreSQL returns the inet text.""" + literal = _inet_literal(ipaddress.ip_address("192.168.1.10")) + + postgres_backend.execute( + f"INSERT INTO {network_test_table} (inet_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT host(inet_value) AS inet_value FROM {network_test_table} WHERE id = 1" + ) + + assert result["inet_value"] == "192.168.1.10" + + def test_insert_and_select_inet_with_mask(self, postgres_backend, network_test_table): + """Insert an inet host with mask and verify the mask survives round-trip.""" + literal = _inet_literal("192.168.1.10/24") + + postgres_backend.execute( + f"INSERT INTO {network_test_table} (inet_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT inet_value::text AS inet_value FROM {network_test_table} WHERE id = 1" + ) + + assert result["inet_value"] == "192.168.1.10/24" + + def test_insert_and_select_cidr_value(self, postgres_backend, network_test_table): + """Insert a CIDR network and verify PostgreSQL returns canonical CIDR text.""" + literal = _cidr_literal(ipaddress.ip_network("192.168.1.0/24")) + + postgres_backend.execute( + f"INSERT INTO {network_test_table} (cidr_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT cidr_value::text AS cidr_value FROM {network_test_table} WHERE id = 1" + ) + + assert result["cidr_value"] == "192.168.1.0/24" + + def test_insert_and_select_macaddr_value(self, postgres_backend, network_test_table): + """Insert a MAC address and verify PostgreSQL returns canonical macaddr text.""" + literal = _macaddr_literal(PostgresMacaddr("08-00-2b-01-02-03")) + + postgres_backend.execute( + f"INSERT INTO {network_test_table} (mac_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT mac_value::text AS mac_value FROM {network_test_table} WHERE id = 1" + ) + + assert result["mac_value"] == "08:00:2b:01:02:03" + + def test_insert_and_select_macaddr8_value(self, postgres_backend, network_test_table): + """Insert a MACADDR8 value and verify PostgreSQL returns canonical text.""" + if postgres_backend.get_server_version() < (10, 0, 0): + pytest.skip("macaddr8 requires PostgreSQL 10+") + + literal = _macaddr8_literal(PostgresMacaddr8("08:00:2b:01:02:03:04:05")) + + result = postgres_backend.fetch_one( + f"SELECT {literal}::text AS mac8_value" + ) + + assert result["mac8_value"] == "08:00:2b:01:02:03:04:05" + + def test_network_null_round_trip(self, postgres_backend, network_test_table): + """Insert NULL network address values and verify fetched values are None.""" + postgres_backend.execute(f""" + INSERT INTO {network_test_table} + (inet_value, cidr_value, mac_value) + VALUES (NULL, NULL, NULL) + """) + result = postgres_backend.fetch_one(f""" + SELECT inet_value, cidr_value, mac_value + FROM {network_test_table} + WHERE id = 1 + """) + + assert result["inet_value"] is None + assert result["cidr_value"] is None + assert result["mac_value"] is None + + def test_invalid_macaddr_rejected_before_insert(self): + """Pass an invalid MAC address and verify the adapter rejects it before SQL.""" + with pytest.raises(ValueError): + _macaddr_literal("08:00:2b") + + +class TestAsyncNetworkAddressIntegration: + """Asynchronous integration tests for PostgreSQL network address types.""" + + @pytest_asyncio.fixture + async def async_network_test_table(self, async_postgres_backend): + """Create a table containing network address columns for async tests.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_NETWORK_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_NETWORK_TABLE} ( + id SERIAL PRIMARY KEY, + inet_value inet, + cidr_value cidr, + mac_value macaddr + ) + """) + yield ASYNC_NETWORK_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_NETWORK_TABLE}") + + @pytest.mark.asyncio + async def test_async_inet_round_trip( + self, async_postgres_backend, async_network_test_table + ): + """Insert an inet value asynchronously and verify PostgreSQL returns it.""" + literal = _inet_literal(ipaddress.ip_address("2001:db8::1")) + + await async_postgres_backend.execute( + f"INSERT INTO {async_network_test_table} (inet_value) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT host(inet_value) AS inet_value FROM {async_network_test_table}" + ) + + assert result["inet_value"] == "2001:db8::1" + + @pytest.mark.asyncio + async def test_async_macaddr_round_trip( + self, async_postgres_backend, async_network_test_table + ): + """Insert a macaddr value asynchronously and verify PostgreSQL returns it.""" + literal = _macaddr_literal(PostgresMacaddr("08:00:2b:01:02:03")) + + await async_postgres_backend.execute( + f"INSERT INTO {async_network_test_table} (mac_value) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT mac_value::text AS mac_value FROM {async_network_test_table}" + ) + + assert result["mac_value"] == "08:00:2b:01:02:03" + + @pytest.mark.asyncio + async def test_async_network_null_round_trip( + self, async_postgres_backend, async_network_test_table + ): + """Insert NULL network values asynchronously and verify fetched values are None.""" + await async_postgres_backend.execute(f""" + INSERT INTO {async_network_test_table} + (inet_value, cidr_value, mac_value) + VALUES (NULL, NULL, NULL) + """) + result = await async_postgres_backend.fetch_one(f""" + SELECT inet_value, cidr_value, mac_value + FROM {async_network_test_table} + """) + + assert result["inet_value"] is None + assert result["cidr_value"] is None + assert result["mac_value"] is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_oid_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_oid_integration.py new file mode 100644 index 00000000..6bc58336 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_oid_integration.py @@ -0,0 +1,224 @@ +# tests/.../feature/backend/postgres/test_oid_integration.py +""" +Integration tests for PostgreSQL object identifier types with real database. + +These tests require a live PostgreSQL connection and test: +- oid storage and retrieval +- tid storage and retrieval +- regclass and regtype name resolution +- Object identifier adapters output accepted by PostgreSQL +- Sync/async round-trip behavior +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.impl.postgres.adapters.object_identifier import ( + PostgresOidAdapter, + PostgresTidAdapter, +) +from rhosocial.activerecord.backend.impl.postgres.types.object_identifier import ( + OID, + RegClass, + RegType, + TID, +) + + +OID_TABLE = "test_oid_types" +ASYNC_OID_TABLE = "test_oid_types_async" + + +def _oid_literal(value): + adapter = PostgresOidAdapter() + database_value = adapter.to_database(value, int) + if database_value is None: + return "NULL" + return f"{database_value}::oid" + + +def _tid_literal(value): + adapter = PostgresTidAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + return f"'{database_value}'::tid" + + +def _regclass_literal(value): + adapter = PostgresOidAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + escaped = database_value.replace("'", "''") + return f"'{escaped}'::regclass" + + +def _regtype_literal(value): + adapter = PostgresOidAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + escaped = database_value.replace("'", "''") + return f"'{escaped}'::regtype" + + +class TestSyncObjectIdentifierIntegration: + """Synchronous integration tests for PostgreSQL object identifier types.""" + + @pytest.fixture + def oid_test_table(self, postgres_backend): + """Create a table containing object identifier columns for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {OID_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {OID_TABLE} ( + id SERIAL PRIMARY KEY, + oid_value oid, + tid_value tid + ) + """) + yield OID_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {OID_TABLE}") + + def test_insert_and_select_oid_value(self, postgres_backend, oid_test_table): + """Insert an OID wrapper value and verify PostgreSQL returns the integer OID.""" + literal = _oid_literal(OID(16384)) + + postgres_backend.execute( + f"INSERT INTO {oid_test_table} (oid_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT oid_value::int AS oid_value FROM {oid_test_table} WHERE id = 1" + ) + + assert result["oid_value"] == 16384 + + def test_insert_oid_from_int(self, postgres_backend, oid_test_table): + """Pass an int through the OID adapter and verify the stored OID value.""" + literal = _oid_literal(20000) + + postgres_backend.execute( + f"INSERT INTO {oid_test_table} (oid_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT oid_value::int AS oid_value FROM {oid_test_table}" + ) + + assert result["oid_value"] == 20000 + + def test_insert_and_select_tid_value(self, postgres_backend, oid_test_table): + """Insert a TID wrapper value and verify PostgreSQL returns the same tuple id.""" + literal = _tid_literal(TID(1, 2)) + + postgres_backend.execute( + f"INSERT INTO {oid_test_table} (tid_value) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT tid_value::text AS tid_value FROM {oid_test_table} WHERE id = 1" + ) + + assert result["tid_value"] == "(1,2)" + + def test_object_identifier_null_round_trip(self, postgres_backend, oid_test_table): + """Insert NULL object identifier values and verify fetched values are None.""" + postgres_backend.execute( + f"INSERT INTO {oid_test_table} (oid_value, tid_value) VALUES (NULL, NULL)" + ) + result = postgres_backend.fetch_one(f""" + SELECT oid_value, tid_value + FROM {oid_test_table} + WHERE id = 1 + """) + + assert result["oid_value"] is None + assert result["tid_value"] is None + + def test_regclass_resolves_created_table(self, postgres_backend, oid_test_table): + """Cast a RegClass table name and verify PostgreSQL resolves it to the table.""" + literal = _regclass_literal(RegClass(oid_test_table)) + + result = postgres_backend.fetch_one( + f"SELECT {literal}::text AS regclass_name" + ) + + assert result["regclass_name"] == oid_test_table + + def test_regtype_resolves_builtin_type(self, postgres_backend): + """Cast a RegType builtin name and verify PostgreSQL resolves it to text.""" + literal = _regtype_literal(RegType("integer")) + + result = postgres_backend.fetch_one( + f"SELECT {literal}::text AS regtype_name" + ) + + assert result["regtype_name"] == "integer" + + def test_invalid_oid_rejected_before_insert(self): + """Pass an out-of-range OID and verify the adapter rejects it before SQL.""" + with pytest.raises(ValueError): + _oid_literal(4294967296) + + +class TestAsyncObjectIdentifierIntegration: + """Asynchronous integration tests for PostgreSQL object identifier types.""" + + @pytest_asyncio.fixture + async def async_oid_test_table(self, async_postgres_backend): + """Create a table containing object identifier columns for async tests.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_OID_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_OID_TABLE} ( + id SERIAL PRIMARY KEY, + oid_value oid, + tid_value tid + ) + """) + yield ASYNC_OID_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_OID_TABLE}") + + @pytest.mark.asyncio + async def test_async_oid_round_trip( + self, async_postgres_backend, async_oid_test_table + ): + """Insert an OID asynchronously and verify PostgreSQL returns the integer value.""" + literal = _oid_literal(OID(30000)) + + await async_postgres_backend.execute( + f"INSERT INTO {async_oid_test_table} (oid_value) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT oid_value::int AS oid_value FROM {async_oid_test_table}" + ) + + assert result["oid_value"] == 30000 + + @pytest.mark.asyncio + async def test_async_tid_round_trip( + self, async_postgres_backend, async_oid_test_table + ): + """Insert a TID asynchronously and verify PostgreSQL returns the tuple id.""" + literal = _tid_literal(TID(2, 3)) + + await async_postgres_backend.execute( + f"INSERT INTO {async_oid_test_table} (tid_value) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT tid_value::text AS tid_value FROM {async_oid_test_table}" + ) + + assert result["tid_value"] == "(2,3)" + + @pytest.mark.asyncio + async def test_async_object_identifier_null_round_trip( + self, async_postgres_backend, async_oid_test_table + ): + """Insert NULL object identifier values asynchronously and verify None values.""" + await async_postgres_backend.execute( + f"INSERT INTO {async_oid_test_table} (oid_value, tid_value) VALUES (NULL, NULL)" + ) + result = await async_postgres_backend.fetch_one(f""" + SELECT oid_value, tid_value + FROM {async_oid_test_table} + """) + + assert result["oid_value"] is None + assert result["tid_value"] is None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py index 2758d846..62a802fa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py @@ -26,7 +26,7 @@ array_to_string, array_upper, unnest, - array_agg_distinct, + array_agg_distinct, # noqa: F401 string_to_array, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py new file mode 100644 index 00000000..8acd4887 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py @@ -0,0 +1,214 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/ +# test_postgres_datetime_interval_expressions.py +"""Tests for PostgreSQL datetime interval expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression.functions import ( + date_add, + date_diff, + date_part, + date_sub, + date_trunc, + extract, + interval, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +class TestPostgresDateTimeIntervalExpressions: + @pytest.mark.parametrize( + "field", + ["year", "month", "day", "hour", "minute", "second"], + ) + def test_extract_datetime_fields( + self, postgres_dialect: PostgresDialect, field: str + ): + expr = extract(postgres_dialect, field, Column(postgres_dialect, "created_at")) + + sql, params = expr.to_sql() + + assert sql == f'EXTRACT({field.upper()} FROM "created_at")' + assert params == () + + def test_date_part_uses_extract_mapping(self, postgres_dialect: PostgresDialect): + expr = date_part( + postgres_dialect, "day", Column(postgres_dialect, "created_at") + ) + + sql, params = expr.to_sql() + + assert sql == 'EXTRACT(DAY FROM "created_at")' + assert params == () + + def test_date_trunc_expression(self, postgres_dialect: PostgresDialect): + expr = date_trunc( + postgres_dialect, "month", Column(postgres_dialect, "created_at") + ) + + sql, params = expr.to_sql() + + assert sql == "DATE_TRUNC('month', \"created_at\")" + assert params == () + + def test_interval_expression(self, postgres_dialect: PostgresDialect): + expr = interval(postgres_dialect, 2, "hour") + + sql, params = expr.to_sql() + + assert sql == "INTERVAL '2' HOUR" + assert params == () + + def test_date_add_column_source(self, postgres_dialect: PostgresDialect): + expr = date_add( + postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day" + ) + + sql, params = expr.to_sql() + + assert sql == '"created_at" + INTERVAL \'1\' DAY' + assert params == () + + def test_date_sub_interval_expression(self, postgres_dialect: PostgresDialect): + expr = date_sub( + postgres_dialect, + Column(postgres_dialect, "created_at"), + interval(postgres_dialect, 2, "hour"), + ) + + sql, params = expr.to_sql() + + assert sql == '"created_at" - INTERVAL \'2\' HOUR' + assert params == () + + def test_date_add_literal_source_params_order( + self, postgres_dialect: PostgresDialect + ): + expr = date_add( + postgres_dialect, + Literal(postgres_dialect, "2026-06-04 10:00:00"), + 30, + "minute", + ) + + sql, params = expr.to_sql() + + assert sql == "%s + INTERVAL '30' MINUTE" + assert params == ("2026-06-04 10:00:00",) + + @pytest.mark.parametrize( + "unit,factor", + [ + ("second", "1"), + ("minute", "60"), + ("hour", "3600"), + ("day", "86400"), + ("week", "604800"), + ], + ) + def test_date_diff_epoch_units( + self, postgres_dialect: PostgresDialect, unit: str, factor: str + ): + expr = date_diff( + postgres_dialect, + unit, + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(EPOCH FROM ("ended_at" - "started_at"))' + f" / {factor})" + ) + assert params == () + + def test_date_diff_month(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "month", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '((EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at")) ' + '* 12 + (EXTRACT(MONTH FROM "ended_at") - ' + 'EXTRACT(MONTH FROM "started_at")))' + ) + assert params == () + + def test_date_diff_month_literal_params(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "month", + Literal(postgres_dialect, "2026-01-01"), + Literal(postgres_dialect, "2026-06-01"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + "((EXTRACT(YEAR FROM %s) - EXTRACT(YEAR FROM %s)) " + "* 12 + (EXTRACT(MONTH FROM %s) - EXTRACT(MONTH FROM %s)))" + ) + assert params == ( + "2026-06-01", + "2026-01-01", + "2026-06-01", + "2026-01-01", + ) + + def test_date_diff_year(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "year", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ) + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(YEAR FROM "ended_at") - EXTRACT(YEAR FROM "started_at"))' + ) + assert params == () + + def test_alias_and_cast(self, postgres_dialect: PostgresDialect): + expr = date_diff( + postgres_dialect, + "day", + Column(postgres_dialect, "started_at"), + Column(postgres_dialect, "ended_at"), + ).cast("INTEGER").as_("elapsed_days") + + sql, params = expr.to_sql() + + assert sql == ( + '(EXTRACT(EPOCH FROM ("ended_at" - "started_at")) / 86400)' + '::INTEGER AS "elapsed_days"' + ) + assert params == () + + def test_query_expression_integration(self, postgres_dialect: PostgresDialect): + shifted = date_add( + postgres_dialect, Column(postgres_dialect, "created_at"), 1, "day" + ) + query = QueryExpression( + postgres_dialect, + select=[ + extract(postgres_dialect, "year", Column(postgres_dialect, "created_at")) + ], + from_="events", + where=shifted > Literal(postgres_dialect, "2026-01-01"), + ) + + sql, params = query.to_sql() + + assert 'EXTRACT(YEAR FROM "created_at")' in sql + assert '"created_at" + INTERVAL \'1\' DAY > %s' in sql + assert params == ("2026-01-01",) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py index 2dbf08ac..8d9f9364 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py @@ -18,7 +18,7 @@ hstore_subscript_get, hstore_subscript_set """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.hstore import ( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py index c721cbe9..c406924d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py @@ -6,7 +6,7 @@ vector_cosine_similarity, vector_literal """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.pgvector import ( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py index f1fc5bcf..a890c3e9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py @@ -9,7 +9,7 @@ st_centroid """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.postgis import ( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py new file mode 100644 index 00000000..be4e111c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -0,0 +1,413 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +""" +Real-world PGQ scenario tests for PostgreSQL 19+ using the expression system. + +All SQL is constructed via expression classes. No raw SQL strings are used. +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.dialect.protocols import GraphTableSupport +from rhosocial.activerecord.backend.expression import ( + GraphVertex, GraphEdge, GraphEdgeDirection, MatchClause, + GraphColumn, ColumnsClause, GraphTableExpression, + TablePropertiesClause, VertexTable, EdgeTable, + CreatePropertyGraphExpression, DropPropertyGraphExpression, + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, + InsertExpression, ValuesSource, Literal, + QueryExpression, WildcardExpression, + FunctionCall, TableExpression, + ExplainExpression, ExplainOptions, +) +from rhosocial.activerecord.backend.expression.query_parts import ( + WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, +) +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.query_parts import JoinExpression, JoinType +from rhosocial.activerecord.backend.expression.literals import Identifier + + +GRAPH_NAME = "social_graph" + + +@pytest.fixture +def social_data(postgres_backend): + backend = postgres_backend + dialect = backend.dialect + if not dialect.supports_graph_table(): + pytest.skip("PGQ not supported in this PostgreSQL version") + + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + people_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", "TEXT"), + ColumnDefinition("email", "TEXT"), + ColumnDefinition("city", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) + + follows_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("followed_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("since", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) + + posts_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("author_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("content", "TEXT"), + ColumnDefinition("created_at", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "posts", posts_cols).to_sql()) + + likes_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("post_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("posts", ["id"]))]), + ColumnDefinition("created_at", "TEXT"), + ] + backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) + + people_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice"), Literal(dialect, "alice@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 2), Literal(dialect, "Bob"), Literal(dialect, "bob@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 3), Literal(dialect, "Charlie"), Literal(dialect, "charlie@example.com"), Literal(dialect, "LA")], + [Literal(dialect, 4), Literal(dialect, "Diana"), Literal(dialect, "diana@example.com"), Literal(dialect, "NYC")], + [Literal(dialect, 5), Literal(dialect, "Eve"), Literal(dialect, "eve@example.com"), Literal(dialect, "LA")], + ]) + backend.execute(*InsertExpression(dialect, "people", source=people_data).to_sql()) + + follows_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-01-01")], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, 3), Literal(dialect, "2024-02-01")], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 3), Literal(dialect, "2024-03-01")], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 1), Literal(dialect, "2024-04-01")], + [Literal(dialect, 5), Literal(dialect, 3), Literal(dialect, 5), Literal(dialect, "2024-05-01")], + ]) + backend.execute(*InsertExpression(dialect, "follows", source=follows_data).to_sql()) + + posts_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "Hello world"), Literal(dialect, "2024-06-01")], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, "PGQ is cool"), Literal(dialect, "2024-06-02")], + [Literal(dialect, 3), Literal(dialect, 3), Literal(dialect, "Graph databases"), Literal(dialect, "2024-06-03")], + [Literal(dialect, 4), Literal(dialect, 1), Literal(dialect, "My first post"), Literal(dialect, "2024-06-04")], + ]) + backend.execute(*InsertExpression(dialect, "posts", source=posts_data).to_sql()) + + likes_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, "2024-06-02")], + [Literal(dialect, 2), Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, "2024-06-02")], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-06-03")], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, "2024-06-05")], + [Literal(dialect, 5), Literal(dialect, 5), Literal(dialect, 1), Literal(dialect, "2024-06-06")], + ]) + backend.execute(*InsertExpression(dialect, "likes", source=likes_data).to_sql()) + + vt_people = VertexTable(dialect, "people", + labels=["person"], + properties=TablePropertiesClause(dialect, columns=["id", "name", "city"])) + vt_posts = VertexTable(dialect, "posts", + labels=["post"], + properties=TablePropertiesClause(dialect, columns=["id", "content"])) + et_follows = EdgeTable(dialect, "follows", ["follower_id"], ["followed_id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["follows"]) + et_posts = EdgeTable(dialect, "posts", ["author_id"], ["id"], + references_source=("people", ["id"]), + references_destination=("posts", ["id"]), + labels=["authored"], + alias="authored") + et_likes = EdgeTable(dialect, "likes", ["user_id"], ["post_id"], + references_source=("people", ["id"]), + references_destination=("posts", ["id"]), + labels=["likes"]) + create_expr = CreatePropertyGraphExpression( + dialect, GRAPH_NAME, [vt_people, vt_posts], [et_follows, et_posts, et_likes] + ) + try: + backend.execute(*create_expr.to_sql()) + except Exception as e: + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + raise e + + yield GRAPH_NAME + + backend.execute(*DropPropertyGraphExpression(dialect, GRAPH_NAME, if_exists=True).to_sql()) + for t in ("likes", "posts", "follows", "people"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + +class TestSocialGraph: + """Social Network PGQ scenario using expression system.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_single_hop_followers(self, postgres_backend, social_data): + """Q1: Who does Alice follow?""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "b_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["b_name"] for r in rows] + assert names == ["Bob", "Charlie"] + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_two_hop_recommendation(self, postgres_backend, social_data): + """Q2: Friends of friends (two-hop).""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f1 = GraphEdge(dialect, "f1", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + f2 = GraphEdge(dialect, "f2", "follows", GraphEdgeDirection.RIGHT) + c = GraphVertex(dialect, "c", "person") + match = MatchClause(dialect, a, f1, b, f2, c) + cols = ColumnsClause(dialect, GraphColumn("c", "name", "c_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "c_name")], + from_=gt, + where=WhereClause(dialect, condition=Column(dialect, "c_name") != Literal(dialect, "Alice")), + order_by=OrderByClause(dialect, [Column(dialect, "c_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_likes_on_posts(self, postgres_backend, social_data): + """Q3: Who liked Alice's posts?""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) + post = GraphVertex(dialect, "post", "post") + l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + liker = GraphVertex(dialect, "liker", "person") + match = MatchClause(dialect, a, p, post, l, liker) + cols = ColumnsClause(dialect, GraphColumn("liker", "name", "liker_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "liker_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "liker_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_table_with_group_by(self, postgres_backend, social_data): + """GRAPH_TABLE + GROUP BY.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + p = GraphEdge(dialect, "p", "authored", GraphEdgeDirection.RIGHT) + post = GraphVertex(dialect, "post", "post") + l = GraphEdge(dialect, "l", "likes", GraphEdgeDirection.LEFT) + liker = GraphVertex(dialect, "liker", "person") + match = MatchClause(dialect, a, p, post, l, liker) + cols = ColumnsClause(dialect, + GraphColumn("a", "name", "author"), + GraphColumn("liker", "name", "liker_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[ + Column(dialect, "author"), + FunctionCall(dialect, "COUNT", Column(dialect, "liker_name"), alias="like_count"), + ], + from_=gt, + group_by_having=GroupByHavingClause(dialect, group_by=[Column(dialect, "author")]), + order_by=OrderByClause(dialect, [(Column(dialect, "like_count"), "DESC")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + assert rows[0]["like_count"] >= 1 + + +class TestCommerceGraph: + """Commerce/Supply Chain PGQ scenario.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_data): + """Composite: GRAPH_TABLE + JOIN + ORDER BY.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, + GraphColumn("a", "name", "follower"), + GraphColumn("b", "name", "followed"), + GraphColumn("f", "since", "since")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + join = JoinExpression(dialect, + left_table=gt, + right_table=TableExpression(dialect, "people", alias="p"), + join_type="INNER JOIN", + condition=Column(dialect, "follower", "g") == Column(dialect, "name", "p")) + + query = QueryExpression(dialect, + select=[WildcardExpression(dialect)], + from_=join, + order_by=OrderByClause(dialect, [Column(dialect, "follower", "g")]), + limit_offset=LimitOffsetClause(dialect, limit=10)) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + assert len(rows) >= 1 + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_parameter_binding(self, postgres_backend, social_data): + """Parameter binding via expression system.""" + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt, + order_by=OrderByClause(dialect, [Column(dialect, "b_name")])) + sql, params = query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["b_name"] for r in rows] + assert names == ["Bob", "Charlie"] + + +class TestAsyncSocialGraph: + """Async versions of social graph scenarios using expression system.""" + + @pytest_asyncio.fixture + async def async_social_data(self, async_postgres_backend): + backend = async_postgres_backend + dialect = backend.dialect + if not dialect.supports_graph_table(): + pytest.skip("PGQ not supported") + + for t in ("likes", "posts", "follows", "people"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + people_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", "TEXT"), + ColumnDefinition("email", "TEXT"), + ColumnDefinition("city", "TEXT"), + ] + await backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) + + follows_cols = [ + ColumnDefinition("id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("followed_id", "INTEGER", + constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + foreign_key_reference=("people", ["id"]))]), + ColumnDefinition("since", "TEXT"), + ] + await backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) + + people_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice"), Literal(dialect, "a@x.com"), Literal(dialect, "NYC")], + [Literal(dialect, 2), Literal(dialect, "Bob"), Literal(dialect, "b@x.com"), Literal(dialect, "NYC")], + ]) + await backend.execute(*InsertExpression(dialect, "people", source=people_data).to_sql()) + + follows_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2), Literal(dialect, "2024-01-01")], + ]) + await backend.execute(*InsertExpression(dialect, "follows", source=follows_data).to_sql()) + + vt = VertexTable(dialect, "people", labels=["person"]) + et = EdgeTable(dialect, "follows", ["follower_id"], ["followed_id"], + references_source=("people", ["id"]), + references_destination=("people", ["id"]), + labels=["follows"]) + create_expr = CreatePropertyGraphExpression(dialect, GRAPH_NAME, [vt], [et]) + await backend.execute(*create_expr.to_sql()) + yield GRAPH_NAME + await backend.execute(*DropPropertyGraphExpression(dialect, GRAPH_NAME, if_exists=True).to_sql()) + for t in ("follows", "people"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + @pytest.mark.asyncio + async def test_async_single_hop(self, async_postgres_backend, async_social_data): + dialect = async_postgres_backend.dialect + a = GraphVertex(dialect, "a", "person", + where=WhereClause(dialect, condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"))) + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt) + sql, params = query.to_sql() + rows = await async_postgres_backend.fetch_all(sql, params) + assert len(rows) == 1 + assert rows[0]["b_name"] == "Bob" + + +class TestPGQExplain: + """Verify PG 19 rewrites GRAPH_TABLE into relational joins.""" + + @pytest.mark.requires_protocol((GraphTableSupport, "supports_graph_table")) + def test_explain_graph_table(self, postgres_backend, social_data): + dialect = postgres_backend.dialect + a = GraphVertex(dialect, "a", "person") + f = GraphEdge(dialect, "f", "follows", GraphEdgeDirection.RIGHT) + b = GraphVertex(dialect, "b", "person") + match = MatchClause(dialect, a, f, b) + cols = ColumnsClause(dialect, GraphColumn("b", "name", "b_name")) + gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") + + query = QueryExpression(dialect, + select=[Column(dialect, "b_name")], + from_=gt) + explain = ExplainExpression(dialect, statement=query, + options=ExplainOptions(costs=False)) + sql, params = explain.to_sql() + rows = postgres_backend.fetch_all(sql, params) + explain_text = " ".join(r["QUERY PLAN"] for r in rows) + assert "Nested Loop" in explain_text or "Hash Join" in explain_text diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_functions.py index 0a28f133..af3dc886 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_functions.py @@ -6,7 +6,7 @@ - Range operations (+, *, -) - Range functions (lower, upper, isempty, lower_inc, upper_inc, lower_inf, upper_inf) """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.expression import operators, core diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types.py index 6d5b0188..b013d905 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types.py @@ -9,8 +9,8 @@ - PostgresMultirangeAdapter conversion """ import pytest -from datetime import date, datetime -from decimal import Decimal +from datetime import date, datetime # noqa: F401 +from decimal import Decimal # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.types.range import ( PostgresRange, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types_integration.py index d041bb50..7182072e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_range_types_integration.py @@ -9,14 +9,14 @@ """ import pytest import pytest_asyncio -from datetime import date, datetime -from decimal import Decimal +from datetime import date, datetime # noqa: F401 +from decimal import Decimal # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.types.range import ( - PostgresRange, - PostgresMultirange, + PostgresRange, # noqa: F401 + PostgresMultirange, # noqa: F401 ) -from rhosocial.activerecord.backend.impl.postgres.adapters.range import PostgresRangeAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.range import PostgresRangeAdapter # noqa: F401 class TestSyncRangeTypesIntegration: @@ -86,7 +86,7 @@ def test_empty_range(self, postgres_backend, range_test_table): def test_daterange_insert_and_select(self, postgres_backend, range_test_table): """Test DATERANGE insert and select.""" postgres_backend.execute( - f"INSERT INTO {range_test_table} (date_range) VALUES (daterange('{date(2024, 1, 1)}', '{date(2024, 12, 31)}'))" + f"INSERT INTO {range_test_table} (date_range) VALUES (daterange('{date(2024, 1, 1)}', '{date(2024, 12, 31)}'))" # noqa: E501 ) result = postgres_backend.fetch_one( f"SELECT date_range FROM {range_test_table}" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py index 33f5d41a..4269b5a5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py """Tests for SetOperation and Truncate protocol support in PostgreSQL dialect.""" -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.dialect.protocols import ( SetOperationSupport, TruncateSupport diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py new file mode 100644 index 00000000..46f159bc --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -0,0 +1,58 @@ +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.table import PostgresTableMixin + + +class TestTableSupport: + """Test PostgresTableMixin feature detection.""" + + def test_supports_if_not_exists_table_pg95(self): + assert PostgresDialect((9, 5, 0)).supports_if_not_exists_table() is True + + def test_supports_if_not_exists_table_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_if_not_exists_table() is False + + def test_supports_if_exists_table(self): + assert PostgresDialect().supports_if_exists_table() is True + + def test_supports_temporary_table(self): + assert PostgresDialect().supports_temporary_table() is True + + def test_supports_table_inheritance(self): + assert PostgresDialect().supports_table_inheritance() is True + + def test_supports_table_tablespace(self): + assert PostgresDialect().supports_table_tablespace() is True + + +class TestPostgresTableMixinDirect: + """Test PostgresTableMixin directly (not through PostgresDialect).""" + + class _Host: + version = (15, 0, 0) + + class _LowHost: + version = (9, 4, 0) + + class _TableMixin(_Host, PostgresTableMixin): + pass + + class _TableMixinLow(_LowHost, PostgresTableMixin): + pass + + def test_supports_if_not_exists_table_direct(self): + assert self._TableMixin().supports_if_not_exists_table() is True + + def test_supports_if_not_exists_table_low(self): + assert not self._TableMixinLow().supports_if_not_exists_table() + + def test_supports_if_exists_table_direct(self): + assert self._TableMixin().supports_if_exists_table() is True + + def test_supports_temporary_table_direct(self): + assert self._TableMixin().supports_temporary_table() is True + + def test_supports_table_inheritance_direct(self): + assert self._TableMixin().supports_table_inheritance() is True + + def test_supports_table_tablespace_direct(self): + assert self._TableMixin().supports_table_tablespace() is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py index 5eacdd2b..e936a5ae 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py @@ -6,9 +6,10 @@ - Format CREATE TRIGGER statement - Format DROP TRIGGER statement """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.trigger import PostgresTriggerMixin from rhosocial.activerecord.backend.expression.statements import ( CreateTriggerExpression, DropTriggerExpression, @@ -21,6 +22,21 @@ class TestTriggerFeatureDetection: """Test trigger feature detection methods.""" + def test_supports_trigger(self): + """Triggers are supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_trigger() is True + + def test_supports_create_trigger(self): + """CREATE TRIGGER is supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_create_trigger() is True + + def test_supports_drop_trigger(self): + """DROP TRIGGER is supported in all versions.""" + dialect = PostgresDialect((9, 5, 0)) + assert dialect.supports_drop_trigger() is True + def test_supports_trigger_referencing_pg9(self): """PostgreSQL 9.5 does not support REFERENCING clause.""" dialect = PostgresDialect((9, 5, 0)) @@ -180,6 +196,41 @@ def test_create_trigger_with_referencing_pg10(self): sql, params = dialect.format_create_trigger_statement(expr) assert "OLD TABLE AS old NEW TABLE AS new" in sql + def test_create_trigger_no_level(self): + """Test CREATE TRIGGER without FOR EACH clause (level=None).""" + from rhosocial.activerecord.backend.expression.statements import TriggerLevel # noqa: F401 + dialect = PostgresDialect((14, 0, 0)) + expr = CreateTriggerExpression( + dialect, + trigger_name="simple_trigger", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.INSERT], + function_name="simple_func", + level=None, + ) + sql, params = dialect.format_create_trigger_statement(expr) + assert "FOR EACH" not in sql + + def test_create_trigger_with_condition(self): + """Test CREATE TRIGGER with WHEN condition expression.""" + from rhosocial.activerecord.backend.expression import Column, Literal + dialect = PostgresDialect((14, 0, 0)) + condition = Column(dialect, "status") == Literal(dialect, "ACTIVE") + expr = CreateTriggerExpression( + dialect, + trigger_name="check_status", + table_name="users", + timing=TriggerTiming.BEFORE, + events=[TriggerEvent.UPDATE], + function_name="validate_status", + condition=condition, + ) + sql, params = dialect.format_create_trigger_statement(expr) + assert "WHEN" in sql + assert "%s" in sql + assert params == ("ACTIVE",) + def test_create_trigger_instead_of(self): """Test CREATE TRIGGER INSTEAD OF for views.""" dialect = PostgresDialect((14, 0, 0)) @@ -229,3 +280,31 @@ def test_drop_trigger_if_exists(self): ) sql, params = dialect.format_drop_trigger_statement(expr) assert "DROP TRIGGER IF EXISTS" in sql + + +class TestPostgresTriggerMixinDirect: + """Test PostgresTriggerMixin directly (not through PostgresDialect).""" + + class _Host: + version = (9, 5, 0) + + class _LowHost: + version = (9, 4, 0) + + class _TriggerMixin(_Host, PostgresTriggerMixin): + pass + + class _TriggerMixinLow(_LowHost, PostgresTriggerMixin): + pass + + def test_supports_trigger(self): + assert self._TriggerMixin().supports_trigger() is True + + def test_supports_create_trigger(self): + assert self._TriggerMixin().supports_create_trigger() is True + + def test_supports_drop_trigger(self): + assert self._TriggerMixin().supports_drop_trigger() is True + + def test_supports_trigger_if_not_exists_low(self): + assert self._TriggerMixinLow().supports_trigger_if_not_exists() is False diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py index 1ec1aa56..50058992 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py @@ -7,7 +7,7 @@ - Chained type conversions - Type compatibility warnings """ -import pytest +import pytest # noqa: F401 import warnings from rhosocial.activerecord.backend.expression.core import Column, Literal @@ -20,8 +20,8 @@ check_cast_compatibility, get_compatible_types, get_intermediate_type, - WARNED_CASTS, - DIRECT_COMPATIBLE_CASTS, + WARNED_CASTS, # noqa: F401 + DIRECT_COMPATIBLE_CASTS, # noqa: F401 ) @@ -257,7 +257,7 @@ def test_money_to_float_via_numeric(self): # This should trigger a warning with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - expr = col.cast("float8") + expr = col.cast("float8") # noqa: F841 # The warning should suggest using numeric as intermediate if len(w) > 0: assert "numeric" in str(w[0].message) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_view_execution.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_view_execution.py index 36f881d9..3ce72c2f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_view_execution.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_view_execution.py @@ -6,19 +6,19 @@ against an actual PostgreSQL database. """ import pytest -from rhosocial.activerecord.backend.impl.postgres.backend import PostgresBackend -from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.backend import PostgresBackend # noqa: F401 +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect # noqa: F401 from rhosocial.activerecord.backend.expression import ( - Column, Literal, FunctionCall, TableExpression, QueryExpression, + Column, Literal, FunctionCall, TableExpression, QueryExpression, # noqa: F401 CreateViewExpression, DropViewExpression, CreateMaterializedViewExpression, DropMaterializedViewExpression, - RefreshMaterializedViewExpression, CreateTableExpression, DropTableExpression, - InsertExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, - TableConstraint, TableConstraintType, ForeignKeyConstraint, ValuesSource + RefreshMaterializedViewExpression, CreateTableExpression, DropTableExpression, # noqa: F401 + InsertExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, # noqa: F401 + TableConstraint, TableConstraintType, ForeignKeyConstraint, ValuesSource # noqa: F401 ) -from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate, RawSQLExpression -from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause, WhereClause -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate, RawSQLExpression # noqa: F401 +from rhosocial.activerecord.backend.expression.query_parts import GroupByHavingClause, WhereClause # noqa: F401 +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # noqa: F401 from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -27,7 +27,7 @@ def postgres_view_backend(postgres_backend): """Provides a PostgresBackend instance with test data for view tests.""" backend = postgres_backend - dialect = backend.dialect + dialect = backend.dialect # noqa: F841 # Drop existing objects if they exist backend.execute("DROP MATERIALIZED VIEW IF EXISTS user_order_mv", (), diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py new file mode 100644 index 00000000..da3160ac --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_integration.py @@ -0,0 +1,226 @@ +# tests/.../feature/backend/postgres/test_xml_integration.py +""" +Integration tests for PostgreSQL XML type with real database. + +These tests require a live PostgreSQL connection and test: +- xml storage and retrieval +- PostgresXMLAdapter output accepted by PostgreSQL +- XML function expressions executed by PostgreSQL +- Sync/async round-trip behavior +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import core +from rhosocial.activerecord.backend.expression.functions.xml import xmlparse +from rhosocial.activerecord.backend.impl.postgres.adapters.xml import PostgresXMLAdapter +from rhosocial.activerecord.backend.impl.postgres.functions.xml import ( + xml_is_well_formed, + xpath_exists, + xpath_query, +) +from rhosocial.activerecord.backend.impl.postgres.types.xml import PostgresXML + + +XML_TABLE = "test_xml_types" +ASYNC_XML_TABLE = "test_xml_types_async" + + +def _xml_literal(value): + adapter = PostgresXMLAdapter() + database_value = adapter.to_database(value, str) + if database_value is None: + return "NULL" + escaped = database_value.replace("'", "''") + return f"'{escaped}'::xml" + + +def _execute_expression(backend, expression, alias="result"): + sql, params = expression.to_sql() + return backend.fetch_one(f"SELECT {sql} AS {alias}", params) + + +def _execute_text_expression(backend, expression, alias="result"): + sql, params = expression.to_sql() + return backend.fetch_one(f"SELECT ({sql})::text AS {alias}", params) + + +class TestSyncXMLIntegration: + """Synchronous integration tests for PostgreSQL XML type.""" + + @pytest.fixture + def xml_test_table(self, postgres_backend): + """Create a table containing an xml column for sync tests.""" + postgres_backend.execute(f"DROP TABLE IF EXISTS {XML_TABLE}") + postgres_backend.execute(f""" + CREATE TABLE {XML_TABLE} ( + id SERIAL PRIMARY KEY, + document xml + ) + """) + yield XML_TABLE + postgres_backend.execute(f"DROP TABLE IF EXISTS {XML_TABLE}") + + def test_insert_and_select_xml_value(self, postgres_backend, xml_test_table): + """Insert a PostgresXML document and verify PostgreSQL returns the XML text.""" + literal = _xml_literal(PostgresXML("value")) + + postgres_backend.execute( + f"INSERT INTO {xml_test_table} (document) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT document::text AS document FROM {xml_test_table} WHERE id = 1" + ) + + assert result["document"] == "value" + + def test_insert_xml_from_plain_str(self, postgres_backend, xml_test_table): + """Pass a plain XML string through the adapter and verify stored XML text.""" + literal = _xml_literal("value") + + postgres_backend.execute( + f"INSERT INTO {xml_test_table} (document) VALUES ({literal})" + ) + result = postgres_backend.fetch_one( + f"SELECT document::text AS document FROM {xml_test_table}" + ) + + assert result["document"] == "value" + + def test_null_xml_round_trip(self, postgres_backend, xml_test_table): + """Insert a NULL XML value and verify the fetched value is None.""" + postgres_backend.execute( + f"INSERT INTO {xml_test_table} (document) VALUES (NULL)" + ) + result = postgres_backend.fetch_one( + f"SELECT document FROM {xml_test_table} WHERE id = 1" + ) + + assert result["document"] is None + + def test_xml_xpath_filter_matches_document(self, postgres_backend, xml_test_table): + """Insert two XML documents and verify XPath filtering matches one row.""" + matching = _xml_literal("target") + other = _xml_literal("other") + + postgres_backend.execute( + f"INSERT INTO {xml_test_table} (document) VALUES ({matching}), ({other})" + ) + result = postgres_backend.fetch_one(f""" + SELECT COUNT(*) AS match_count + FROM {xml_test_table} + WHERE xpath_exists('/root/item[text()="target"]', document) + """) + + assert result["match_count"] == 1 + + def test_invalid_xml_rejected_by_postgres(self, postgres_backend, xml_test_table): + """Insert malformed XML and verify PostgreSQL rejects it during execution.""" + with pytest.raises(Exception): # noqa: B017 + postgres_backend.execute( + f"INSERT INTO {xml_test_table} (document) VALUES (''::xml)" + ) + + +class TestSyncXMLFunctionsIntegration: + """Synchronous integration tests for PostgreSQL XML functions.""" + + def test_xmlparse_executes(self, postgres_backend): + """Execute xmlparse and verify PostgreSQL returns the parsed XML text.""" + expr = xmlparse( + postgres_backend.dialect, + "value", + document=True, + ) + result = _execute_text_expression(postgres_backend, expr) + + assert result["result"] == "value" + + def test_xpath_query_executes(self, postgres_backend): + """Execute xpath query and verify PostgreSQL returns the matching XML node.""" + xml_expr = core.Subquery( + postgres_backend.dialect, + "'value'::xml", + ) + expr = xpath_query(postgres_backend.dialect, "/root/item/text()", xml_expr) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] == ["value"] + + def test_xpath_exists_executes(self, postgres_backend): + """Execute xpath_exists and verify PostgreSQL reports a matching node.""" + xml_expr = core.Subquery( + postgres_backend.dialect, + "'value'::xml", + ) + expr = xpath_exists(postgres_backend.dialect, "/root/item", xml_expr) + result = _execute_expression(postgres_backend, expr) + + assert result["result"] is True + + def test_xml_is_well_formed_executes(self, postgres_backend): + """Execute xml_is_well_formed and verify valid XML returns True.""" + expr = xml_is_well_formed(postgres_backend.dialect, "") + result = _execute_expression(postgres_backend, expr) + + assert result["result"] is True + + +class TestAsyncXMLIntegration: + """Asynchronous integration tests for PostgreSQL XML type.""" + + @pytest_asyncio.fixture + async def async_xml_test_table(self, async_postgres_backend): + """Create a table containing an xml column for async tests.""" + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_XML_TABLE}") + await async_postgres_backend.execute(f""" + CREATE TABLE {ASYNC_XML_TABLE} ( + id SERIAL PRIMARY KEY, + document xml + ) + """) + yield ASYNC_XML_TABLE + await async_postgres_backend.execute(f"DROP TABLE IF EXISTS {ASYNC_XML_TABLE}") + + @pytest.mark.asyncio + async def test_async_xml_round_trip( + self, async_postgres_backend, async_xml_test_table + ): + """Insert a PostgresXML document asynchronously and verify XML text.""" + literal = _xml_literal(PostgresXML("async")) + + await async_postgres_backend.execute( + f"INSERT INTO {async_xml_test_table} (document) VALUES ({literal})" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT document::text AS document FROM {async_xml_test_table}" + ) + + assert result["document"] == "async" + + @pytest.mark.asyncio + async def test_async_null_xml_round_trip( + self, async_postgres_backend, async_xml_test_table + ): + """Insert a NULL XML value asynchronously and verify fetched value is None.""" + await async_postgres_backend.execute( + f"INSERT INTO {async_xml_test_table} (document) VALUES (NULL)" + ) + result = await async_postgres_backend.fetch_one( + f"SELECT document FROM {async_xml_test_table}" + ) + + assert result["document"] is None + + @pytest.mark.asyncio + async def test_async_xpath_exists_executes(self, async_postgres_backend): + """Execute xpath_exists asynchronously and verify PostgreSQL returns True.""" + xml_expr = core.Subquery( + async_postgres_backend.dialect, + "'async'::xml", + ) + expr = xpath_exists(async_postgres_backend.dialect, "/root/item", xml_expr) + sql, params = expr.to_sql() + result = await async_postgres_backend.fetch_one(f"SELECT {sql} AS result", params) + + assert result["result"] is True diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py index 2376d92b..776e0b33 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_xml_types.py @@ -9,12 +9,12 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression import core +from rhosocial.activerecord.backend.expression import XMLParseExpression, core +from rhosocial.activerecord.backend.expression.functions.xml import xmlparse from rhosocial.activerecord.backend.impl.postgres.types.xml import ( PostgresXML, ) from rhosocial.activerecord.backend.impl.postgres.functions.xml import ( - xmlparse, xpath_query, xpath_exists, xml_is_well_formed, @@ -167,27 +167,28 @@ def test_xmlparse_document(self): """Test xmlparse with DOCUMENT.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', document=True) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'XMLPARSE' in sql - assert 'DOCUMENT' in sql + assert sql == "XMLPARSE(DOCUMENT %s)" + assert params == ('',) def test_xmlparse_content(self): """Test xmlparse with CONTENT.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', document=False) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'XMLPARSE' in sql - assert 'CONTENT' in sql + assert sql == "XMLPARSE(CONTENT %s)" + assert params == ('',) def test_xmlparse_preserve_whitespace(self): """Test xmlparse with preserve whitespace.""" dialect = PostgresDialect((14, 0, 0)) result = xmlparse(dialect, '', preserve_whitespace=True) - assert isinstance(result, core.FunctionCall) + assert isinstance(result, XMLParseExpression) sql, params = result.to_sql() - assert 'PRESERVE WHITESPACE' in sql + assert sql == "XMLPARSE(DOCUMENT %s PRESERVE WHITESPACE)" + assert params == ('',) def test_xpath_query_basic(self): """Test xpath_query basic.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py index 34ce6b4b..e3215a19 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py @@ -1,6 +1,6 @@ import ipaddress -import json +import json # noqa: F401 import pytest from psycopg.types.json import Jsonb @@ -116,10 +116,10 @@ def test_network_from_database_none(): # Batch Operation Tests # ============================================================================= -from rhosocial.activerecord.backend.impl.postgres.adapters.base import ( +from rhosocial.activerecord.backend.impl.postgres.adapters.base import ( # noqa: E402 PostgresListAdapter, - PostgresJSONBAdapter, - PostgresNetworkAddressAdapter, + PostgresJSONBAdapter, # noqa: F811 + PostgresNetworkAddressAdapter, # noqa: F811 ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py index fe024660..b35e7df3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py @@ -1,4 +1,4 @@ -# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py +# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_async_column_mapping_backend.py # noqa: E501 import pytest import pytest_asyncio from datetime import datetime diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py index e0530a7a..f4110306 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli.py @@ -244,7 +244,7 @@ def test_parse_args_named_procedure_transaction_auto(self): """Test named-procedure with --transaction auto.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'auto']): + with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'auto']): # noqa: E501 args = parse_args() assert args.command == 'named-procedure' @@ -254,7 +254,7 @@ def test_parse_args_named_procedure_transaction_step(self): """Test named-procedure with --transaction step.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'step']): + with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'step']): # noqa: E501 args = parse_args() assert args.command == 'named-procedure' @@ -264,7 +264,7 @@ def test_parse_args_named_procedure_transaction_none(self): """Test named-procedure with --transaction none.""" from rhosocial.activerecord.backend.impl.postgres.__main__ import parse_args - with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'none']): + with patch.object(sys, 'argv', ['postgres', 'named-procedure', 'myapp.procedures.test', '--transaction', 'none']): # noqa: E501 args = parse_args() assert args.command == 'named-procedure' diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py index 32064623..059422fc 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py @@ -1,4 +1,4 @@ -# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py +# /Users/vistart/PycharmProjects/rhosocial/python-activerecord-postgres/tests/rhosocial/activerecord_postgres_test/feature/backend/test_column_mapping_backend.py # noqa: E501 import pytest from datetime import datetime import uuid diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_config.py index 50e06e98..defa213f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_config.py @@ -86,7 +86,7 @@ def test_validation_invalid_session_attrs(): with pytest.raises(ValueError, match="Invalid target_session_attrs"): config.validate() -@pytest.mark.parametrize("valid_session_attr", ["any", "primary", "standby", "prefer-standby", "read-write", "read-only"]) +@pytest.mark.parametrize("valid_session_attr", ["any", "primary", "standby", "prefer-standby", "read-write", "read-only"]) # noqa: E501 def test_validation_valid_session_attrs(valid_session_attr): """Test validation succeeds for valid target_session_attrs.""" config = PostgresConnectionConfig( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_connection_resilience.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_connection_resilience.py index 93be3c45..6f757709 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_connection_resilience.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_connection_resilience.py @@ -18,7 +18,7 @@ Therefore, tests focus on actual query execution and reconnection behavior. """ import pytest -import pytest_asyncio +import pytest_asyncio # noqa: F401 import asyncio import time import logging diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py index 2b5506c2..2b9afaff 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py @@ -5,7 +5,7 @@ This module tests the PostgreSQL-specific LIKE syntax for CREATE TABLE statements, including INCLUDING/EXCLUDING options. """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -95,7 +95,7 @@ def test_like_with_multiple_including_options(self): ) sql, params = create_expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, INCLUDING INDEXES)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, INCLUDING INDEXES)' # noqa: E501 assert params == () def test_like_with_including_and_excluding_options(self): @@ -115,7 +115,7 @@ def test_like_with_including_and_excluding_options(self): ) sql, params = create_expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, EXCLUDING INDEXES, EXCLUDING COMMENTS)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, EXCLUDING INDEXES, EXCLUDING COMMENTS)' # noqa: E501 assert params == () def test_like_with_list_format_options(self): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect.py index c33b8368..dfcc03dc 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect.py @@ -5,8 +5,8 @@ This module tests PostgreSQL dialect formatting using real database. Each test has sync and async versions for complete coverage. """ -import pytest -import pytest_asyncio +import pytest # noqa: F401 +import pytest_asyncio # noqa: F401 class TestPostgreSQLDialectBackend: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py index 84813be1..cf057a1e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_dialect_function_support.py @@ -5,7 +5,6 @@ This module tests the supports_functions() method and version-dependent function availability detection in PostgresDialect. """ -import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.function_versions import FunctionSupportInfo @@ -169,7 +168,7 @@ def test_bit_count_requires_pg_9_5(self): def test_xml_functions_require_pg_8_3(self): """Test that XML functions require PostgreSQL 8.3+.""" - xml_functions = ["xmlparse", "xpath_query", "xpath_exists"] + xml_functions = ["xpath_query", "xpath_exists"] dialect_old = PostgresDialect(version=(8, 2, 99)) result_old = dialect_old.supports_functions() @@ -181,6 +180,51 @@ def test_xml_functions_require_pg_8_3(self): for func in xml_functions: assert result_new.get(func).supported + def test_sqlxml_expression_constructors_are_capabilities(self): + """Test that SQL/XML constructors are exposed as capabilities.""" + constructors = { + "xmlagg", + "xmlattributes", + "xmlcomment", + "xmlconcat", + "xmlelement", + "xmlexists", + "xmlforest", + "xmlparse", + "xmlpi", + "xmlquery", + "xmlroot", + "xmlserialize", + "xmltable", + } + + dialect_old = PostgresDialect(version=(8, 2, 99)) + result_old = dialect_old.supports_functions() + assert constructors.isdisjoint(result_old) + assert dialect_old.supports_xmlparse() is False + assert dialect_old.supports_xmlserialize() is False + assert dialect_old.supports_xmlelement() is False + assert dialect_old.supports_xmlagg() is False + assert dialect_old.supports_xmlexists() is False + assert dialect_old.supports_xmltable() is False + + dialect_new = PostgresDialect(version=(10, 0, 0)) + result_new = dialect_new.supports_functions() + assert constructors.isdisjoint(result_new) + assert dialect_new.supports_xmlparse() is True + assert dialect_new.supports_xmlserialize() is True + assert dialect_new.supports_xmlelement() is True + assert dialect_new.supports_xmlattributes() is True + assert dialect_new.supports_xmlforest() is True + assert dialect_new.supports_xmlconcat() is True + assert dialect_new.supports_xmlcomment() is True + assert dialect_new.supports_xmlpi() is True + assert dialect_new.supports_xmlroot() is True + assert dialect_new.supports_xmlagg() is True + assert dialect_new.supports_xmlquery() is False + assert dialect_new.supports_xmlexists() is True + assert dialect_new.supports_xmltable() is True + def test_xml_is_well_formed_requires_pg_9_1(self): """Test that xml_is_well_formed requires PostgreSQL 9.1+.""" dialect_old = PostgresDialect(version=(9, 0, 99)) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_extension_ddl.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_extension_ddl.py index a1bdff63..b33aa858 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_extension_ddl.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_extension_ddl.py @@ -289,5 +289,5 @@ def test_create_extension_without_if_not_exists_fails_on_duplicate(self, postgre ) sql, params = expr.to_sql() - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 backend.execute(sql, params) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py new file mode 100644 index 00000000..6f0a7343 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py @@ -0,0 +1,567 @@ +"""Advanced real PostgreSQL partition capability tests.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Iterable + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + ExplainExpression, + ExplainFormat, + ExplainOptions, + InsertExpression, + Literal, + LogicalPredicate, + QueryExpression, + RawSQLExpression, + TableExpression, + UpdateExpression, + ValuesSource, +) +from rhosocial.activerecord.backend.expression.statements import PartitionClause, PartitionStrategy +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresAttachPartitionExpression, + PostgresCreatePartitionExpression, + PostgresDetachPartitionExpression, + PostgresPartitionMetadataExpression, +) + + +LIST_PARENT = "ar_partition_adv_list_events" +LIST_ACTIVE = "ar_partition_adv_list_active" +LIST_CLOSED = "ar_partition_adv_list_closed" +HASH_PARENT = "ar_partition_adv_hash_events" +HASH_PARTITIONS = tuple(f"ar_partition_adv_hash_p{i}" for i in range(4)) +RANGE_PARENT = "ar_partition_adv_range_events" +RANGE_JAN = "ar_partition_adv_range_jan" +RANGE_FEB = "ar_partition_adv_range_feb" +RANGE_TABLES = (RANGE_JAN, RANGE_FEB, RANGE_PARENT) +SCHEMA_A = "ar_partition_schema_a" +SCHEMA_B = "ar_partition_schema_b" +SCHEMA_PARENT = "partition_events" +SCHEMA_CHILD = "partition_events_p2026" + + +def _drop_table_expression(dialect, table_name: str, *, schema: str | None = None): + table = TableExpression(dialect, table_name, schema_name=schema) if schema else table_name + return DropTableExpression(dialect=dialect, table=table, if_exists=True, cascade=True) + + +def _create_list_parent_expression(dialect): + return CreateTableExpression( + dialect=dialect, + table=LIST_PARENT, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("status", "TEXT NOT NULL"), + ColumnDefinition("payload", "TEXT NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.LIST, + keys=[Column(dialect, "status")], + ), + ) + + +def _create_list_partition_sql(dialect, partition_name: str, values: Iterable[str]): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=LIST_PARENT, + partition_type="LIST", + partition_values={"values": list(values)}, + ) + return expr.to_sql() + + +def _create_hash_parent_expression(dialect): + return CreateTableExpression( + dialect=dialect, + table=HASH_PARENT, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("bucket", "BIGINT NOT NULL"), + ColumnDefinition("payload", "TEXT NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.HASH, + keys=[Column(dialect, "bucket")], + ), + ) + + +def _create_hash_partition_sql(dialect, partition_name: str, remainder: int): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=HASH_PARENT, + partition_type="HASH", + partition_values={"modulus": 4, "remainder": remainder}, + ) + return expr.to_sql() + + +def _create_range_parent_expression(dialect): + return CreateTableExpression( + dialect=dialect, + table=RANGE_PARENT, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("payload", "TEXT NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + + +def _create_range_partition_sql(dialect, partition_name: str, start: str, end: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=RANGE_PARENT, + partition_type="RANGE", + partition_values={"from": start, "to": end}, + ) + return expr.to_sql() + + +def _insert_rows_expression(dialect, table_name: str, columns: list[str], rows: list[list[Any]]): + return InsertExpression( + dialect=dialect, + into=table_name, + columns=columns, + source=ValuesSource( + dialect, + [[Literal(dialect, value) for value in row] for row in rows], + ), + ) + + +def _select_tableoid_payload_expression(dialect, table_name: str): + return QueryExpression( + dialect, + select=[ + RawSQLExpression(dialect, "tableoid::regclass::text AS partition_name"), + Column(dialect, "payload"), + ], + from_=TableExpression(dialect, table_name), + ) + + +def _metadata_sql(dialect, parent_table: str, *, schema: str | None = None, include_partitions: bool = True): + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table=parent_table, + schema=schema, + include_partitions=include_partitions, + ) + return expr.to_sql() + + +def _json_explain_sql(dialect, query): + expr = ExplainExpression( + dialect, + statement=query, + options=ExplainOptions(format=ExplainFormat.JSON), + ) + return expr.to_sql() + + +def _extract_json_plan(rows): + assert rows + row = rows[0] + raw = row.get("QUERY PLAN") or row.get("query plan") or next(iter(row.values())) + if isinstance(raw, str): + import json + + raw = json.loads(raw) + assert isinstance(raw, list) + return raw[0]["Plan"] + + +def _flatten_plan_nodes(node): + nodes = [node] + for child in node.get("Plans", []) or []: + nodes.extend(_flatten_plan_nodes(child)) + return nodes + + +def _drop_advanced_tables(backend): + for table_name in (LIST_ACTIVE, LIST_CLOSED, LIST_PARENT, *HASH_PARTITIONS, HASH_PARENT, *RANGE_TABLES): + backend.execute(*_drop_table_expression(backend.dialect, table_name).to_sql()) + + +async def _async_drop_advanced_tables(backend): + for table_name in (LIST_ACTIVE, LIST_CLOSED, LIST_PARENT, *HASH_PARTITIONS, HASH_PARENT, *RANGE_TABLES): + await backend.execute(*_drop_table_expression(backend.dialect, table_name).to_sql()) + + +def _create_range_environment(backend): + dialect = backend.dialect + for table_name in RANGE_TABLES: + backend.execute(*_drop_table_expression(dialect, table_name).to_sql()) + backend.execute(*_create_range_parent_expression(dialect).to_sql()) + backend.execute(*_create_range_partition_sql(dialect, RANGE_JAN, "2026-01-01", "2026-02-01")) + backend.execute(*_create_range_partition_sql(dialect, RANGE_FEB, "2026-02-01", "2026-03-01")) + + +async def _async_create_range_environment(backend): + dialect = backend.dialect + for table_name in RANGE_TABLES: + await backend.execute(*_drop_table_expression(dialect, table_name).to_sql()) + await backend.execute(*_create_range_parent_expression(dialect).to_sql()) + await backend.execute(*_create_range_partition_sql(dialect, RANGE_JAN, "2026-01-01", "2026-02-01")) + await backend.execute(*_create_range_partition_sql(dialect, RANGE_FEB, "2026-02-01", "2026-03-01")) + + +@pytest.fixture +def postgres_range_partition_environment(postgres_backend): + """Create a small range partition environment for advanced behavior tests.""" + if not postgres_backend.dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + _create_range_environment(postgres_backend) + yield RANGE_PARENT + _drop_advanced_tables(postgres_backend) + + +@pytest_asyncio.fixture +async def async_postgres_range_partition_environment(async_postgres_backend): + """Async range partition environment for advanced behavior tests.""" + if not async_postgres_backend.dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + await _async_create_range_environment(async_postgres_backend) + yield RANGE_PARENT + await _async_drop_advanced_tables(async_postgres_backend) + + +class TestPostgreSQLAdvancedPartitionOperations: + """Synchronous advanced PostgreSQL partition behavior tests.""" + + def test_list_partition_routes_rows_and_reports_metadata(self, postgres_backend): + """LIST partitions should route rows and appear in metadata.""" + dialect = postgres_backend.dialect + if not dialect.supports_list_table_partitioning(): + pytest.skip("PostgreSQL scenario does not support LIST partitioning") + _drop_advanced_tables(postgres_backend) + try: + postgres_backend.execute(*_create_list_parent_expression(dialect).to_sql()) + postgres_backend.execute(*_create_list_partition_sql(dialect, LIST_ACTIVE, ["active", "pending"])) + postgres_backend.execute(*_create_list_partition_sql(dialect, LIST_CLOSED, ["closed", "archived"])) + postgres_backend.execute( + *_insert_rows_expression( + dialect, + LIST_PARENT, + ["id", "status", "payload"], + [[1, "active", "active-row"], [2, "closed", "closed-row"]], + ).to_sql() + ) + rows = postgres_backend.fetch_all(*_select_tableoid_payload_expression(dialect, LIST_PARENT).to_sql()) + by_payload = {row["payload"]: row["partition_name"] for row in rows} + assert by_payload == {"active-row": LIST_ACTIVE, "closed-row": LIST_CLOSED} + metadata = postgres_backend.fetch_all(*_metadata_sql(dialect, LIST_PARENT)) + assert {row["name"] for row in metadata} == {LIST_ACTIVE, LIST_CLOSED} + finally: + _drop_advanced_tables(postgres_backend) + + def test_hash_partition_accepts_rows_and_reports_metadata(self, postgres_backend): + """HASH partitions should accept rows and expose all remainder partitions.""" + dialect = postgres_backend.dialect + if not dialect.supports_hash_table_partitioning(): + pytest.skip("PostgreSQL scenario does not support HASH partitioning") + _drop_advanced_tables(postgres_backend) + try: + postgres_backend.execute(*_create_hash_parent_expression(dialect).to_sql()) + for remainder, partition_name in enumerate(HASH_PARTITIONS): + postgres_backend.execute(*_create_hash_partition_sql(dialect, partition_name, remainder)) + postgres_backend.execute( + *_insert_rows_expression( + dialect, + HASH_PARENT, + ["id", "bucket", "payload"], + [[1, 10, "one"], [2, 20, "two"], [3, 30, "three"]], + ).to_sql() + ) + metadata = postgres_backend.fetch_all(*_metadata_sql(dialect, HASH_PARENT)) + count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_adv_hash_events" + )["count"] + assert count == 3 + assert {row["name"] for row in metadata} == set(HASH_PARTITIONS) + assert all("modulus 4" in str(row["bound"]).lower() for row in metadata) + finally: + _drop_advanced_tables(postgres_backend) + + def test_default_attach_and_detach_workflow(self, postgres_backend, postgres_range_partition_environment): + """DEFAULT ATTACH should create a catch-all partition, then DETACH should remove it.""" + dialect = postgres_backend.dialect + if not dialect.supports_default_partition(): + pytest.skip("DEFAULT partition requires PostgreSQL 11+") + + default_partition_name = "ar_partition_adv_default" + + # Create a table with matching structure for DEFAULT attachment + from rhosocial.activerecord.backend.expression.statements import DropTableExpression, TableConstraint + create_default = CreateTableExpression( + dialect=dialect, + table=default_partition_name, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("payload", "TEXT NOT NULL"), + ], + ) + postgres_backend.execute(*create_default.to_sql()) + + try: + # Attach as DEFAULT partition + attach = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name=default_partition_name, + parent_table=RANGE_PARENT, + partition_type="RANGE", + partition_values={"default": True}, + ) + sql, params = attach.to_sql() + assert "DEFAULT" in sql + assert "FOR VALUES" not in sql + postgres_backend.execute(sql, params) + + # Verify in metadata + metadata = postgres_backend.fetch_all( + *_metadata_sql(dialect, RANGE_PARENT) + ) + names = {row["name"] for row in metadata} + assert default_partition_name in names + + # Detach it back + detach = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name=default_partition_name, + parent_table=RANGE_PARENT, + ) + postgres_backend.execute(*detach.to_sql()) + + metadata_after = postgres_backend.fetch_all( + *_metadata_sql(dialect, RANGE_PARENT) + ) + names_after = {row["name"] for row in metadata_after} + assert default_partition_name not in names_after + finally: + postgres_backend.execute( + f'DROP TABLE IF EXISTS "{default_partition_name}" CASCADE' + ) + + def test_metadata_include_partitions_false_and_schema_filter(self, postgres_backend): + """Metadata should distinguish schemas and parent-only queries.""" + dialect = postgres_backend.dialect + if not dialect.supports_partition_metadata_introspection(): + pytest.skip("PostgreSQL scenario does not support partition metadata") + for schema in (SCHEMA_A, SCHEMA_B): + postgres_backend.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + postgres_backend.execute(f'CREATE SCHEMA "{schema}"') + try: + for schema in (SCHEMA_A, SCHEMA_B): + parent = CreateTableExpression( + dialect=dialect, + table=TableExpression(dialect, SCHEMA_PARENT, schema_name=schema), + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + postgres_backend.execute(*parent.to_sql()) + child = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=SCHEMA_CHILD, + parent_table=SCHEMA_PARENT, + partition_type="RANGE", + partition_values={"from": "2026-01-01", "to": "2027-01-01"}, + schema=schema, + ) + postgres_backend.execute(*child.to_sql()) + rows_a = postgres_backend.fetch_all( + *_metadata_sql(dialect, SCHEMA_PARENT, schema=SCHEMA_A) + ) + rows_b_parent_only = postgres_backend.fetch_all( + *_metadata_sql(dialect, SCHEMA_PARENT, schema=SCHEMA_B, include_partitions=False) + ) + assert {row["name"] for row in rows_a} == {SCHEMA_CHILD} + assert len(rows_b_parent_only) == 1 + assert rows_b_parent_only[0]["name"] is None + assert rows_b_parent_only[0]["bound"] is None + finally: + for schema in (SCHEMA_A, SCHEMA_B): + postgres_backend.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + + def test_partition_key_update_moves_row_between_partitions( + self, + postgres_backend, + postgres_range_partition_environment, + ): + """Updating the partition key should move rows between partitions on PG 11+.""" + dialect = postgres_backend.dialect + if not dialect.supports_partition_key_update(): + pytest.skip("PostgreSQL scenario does not support partition key row movement") + postgres_backend.execute( + *_insert_rows_expression( + dialect, + RANGE_PARENT, + ["id", "created_at", "payload"], + [[1, "2026-01-15", "jan"]], + ).to_sql() + ) + update = UpdateExpression( + dialect=dialect, + table=RANGE_PARENT, + assignments={"created_at": Literal(dialect, "2026-02-15")}, + where=Column(dialect, "id") == Literal(dialect, 1), + ) + postgres_backend.execute(*update.to_sql()) + row = postgres_backend.fetch_one( + "SELECT tableoid::regclass::text AS partition_name FROM ar_partition_adv_range_events WHERE id = %s", + (1,), + ) + assert row["partition_name"] == RANGE_FEB + + invalid_update = UpdateExpression( + dialect=dialect, + table=RANGE_PARENT, + assignments={"created_at": Literal(dialect, "2026-03-15")}, + where=Column(dialect, "id") == Literal(dialect, 1), + ) + with pytest.raises(Exception): + postgres_backend.execute(*invalid_update.to_sql()) + + def test_json_explain_pruning_reports_target_partition( + self, + postgres_backend, + postgres_range_partition_environment, + ): + """EXPLAIN FORMAT JSON should expose pruning to the target partition.""" + dialect = postgres_backend.dialect + postgres_backend.execute( + *_insert_rows_expression( + dialect, + RANGE_PARENT, + ["id", "created_at", "payload"], + [[1, "2026-01-15", "jan"], [2, "2026-02-15", "feb"]], + ).to_sql() + ) + query = QueryExpression( + dialect, + select=[Column(dialect, "payload")], + from_=TableExpression(dialect, RANGE_PARENT), + where=LogicalPredicate( + dialect, + "AND", + Column(dialect, "created_at") >= Literal(dialect, "2026-02-01"), + Column(dialect, "created_at") < Literal(dialect, "2026-03-01"), + ), + ) + rows = postgres_backend.fetch_all(*_json_explain_sql(dialect, query)) + nodes = _flatten_plan_nodes(_extract_json_plan(rows)) + relation_names = {node.get("Relation Name") for node in nodes if node.get("Relation Name")} + assert RANGE_FEB in relation_names + assert RANGE_JAN not in relation_names + + def test_concurrent_detach_executes_or_reports_environment_limit( + self, + postgres_backend, + postgres_range_partition_environment, + ): + """DETACH CONCURRENTLY should execute on PG 14+ outside restricted transactions.""" + dialect = postgres_backend.dialect + if not dialect.supports_concurrent_detach(): + pytest.skip("DETACH CONCURRENTLY requires PostgreSQL 14+") + sql, params = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name=RANGE_JAN, + parent_table=RANGE_PARENT, + concurrently=True, + ).to_sql() + try: + postgres_backend.execute(sql, params) + except Exception as exc: + if "transaction block" in str(exc).lower(): + pytest.skip("backend connection wraps DDL in a transaction block") + raise + metadata = postgres_backend.fetch_all(*_metadata_sql(dialect, RANGE_PARENT)) + assert RANGE_JAN not in {row["name"] for row in metadata} + + +class TestAsyncPostgreSQLAdvancedPartitionOperations: + """Asynchronous advanced PostgreSQL partition behavior tests.""" + + @pytest.mark.asyncio + async def test_list_partition_routes_rows_and_reports_metadata(self, async_postgres_backend): + """LIST partitions should route rows asynchronously.""" + dialect = async_postgres_backend.dialect + if not dialect.supports_list_table_partitioning(): + pytest.skip("PostgreSQL scenario does not support LIST partitioning") + await _async_drop_advanced_tables(async_postgres_backend) + try: + await async_postgres_backend.execute(*_create_list_parent_expression(dialect).to_sql()) + await async_postgres_backend.execute(*_create_list_partition_sql(dialect, LIST_ACTIVE, ["active", "pending"])) + await async_postgres_backend.execute(*_create_list_partition_sql(dialect, LIST_CLOSED, ["closed", "archived"])) + await async_postgres_backend.execute( + *_insert_rows_expression( + dialect, + LIST_PARENT, + ["id", "status", "payload"], + [[1, "active", "active-row"], [2, "closed", "closed-row"]], + ).to_sql() + ) + rows = await async_postgres_backend.fetch_all(*_select_tableoid_payload_expression(dialect, LIST_PARENT).to_sql()) + by_payload = {row["payload"]: row["partition_name"] for row in rows} + assert by_payload == {"active-row": LIST_ACTIVE, "closed-row": LIST_CLOSED} + finally: + await _async_drop_advanced_tables(async_postgres_backend) + + @pytest.mark.asyncio + async def test_partition_key_update_moves_row_between_partitions( + self, + async_postgres_backend, + async_postgres_range_partition_environment, + ): + """Updating the partition key should move rows asynchronously on PG 11+.""" + dialect = async_postgres_backend.dialect + if not dialect.supports_partition_key_update(): + pytest.skip("PostgreSQL scenario does not support partition key row movement") + await async_postgres_backend.execute( + *_insert_rows_expression( + dialect, + RANGE_PARENT, + ["id", "created_at", "payload"], + [[1, "2026-01-15", "jan"]], + ).to_sql() + ) + update = UpdateExpression( + dialect=dialect, + table=RANGE_PARENT, + assignments={"created_at": Literal(dialect, "2026-02-15")}, + where=Column(dialect, "id") == Literal(dialect, 1), + ) + await async_postgres_backend.execute(*update.to_sql()) + row = await async_postgres_backend.fetch_one( + "SELECT tableoid::regclass::text AS partition_name FROM ar_partition_adv_range_events WHERE id = %s", + (1,), + ) + assert row["partition_name"] == RANGE_FEB diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py new file mode 100644 index 00000000..5ded526f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -0,0 +1,1676 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +"""Real PostgreSQL partition operation tests. + +These tests execute against the configured PostgreSQL scenarios. They cover +common operational needs for declarative partitioning and keep synchronous and +asynchronous test method names identical across test classes. +""" +from datetime import datetime +from typing import Optional, Sequence + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + CreateIndexExpression, + DeleteExpression, + FunctionCall, + InsertExpression, + Literal, + LogicalPredicate, + OrderByClause, + QualifiedIdentifierExpression, + QueryExpression, + TableExpression, + ValuesSource, + WildcardExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + PartitionClause, + PartitionStrategy, + TableConstraint, + TableConstraintType, + TruncateExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresAttachPartitionExpression, + PostgresCreatePartitionExpression, + PostgresDetachPartitionExpression, + PostgresPartitionMetadataExpression, + PostgresPgPartmanCreateParentExpression, + PostgresPgPartmanDeleteConfigExpression, + PostgresPgPartmanRunMaintenanceExpression, + PostgresPgPartmanUpdateConfigExpression, +) +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType +from rhosocial.activerecord_postgres_test.feature.backend.utils import ( + ensure_extension_installed, + async_ensure_extension_installed, +) + + +PARTITION_TABLES = ( + "ar_partition_events_p2026_01", + "ar_partition_events_p2026_02", + "ar_partition_events_p2026_03", + "ar_partition_events_default", + "ar_partition_events_archive", + "ar_partition_events", +) + +PARTMAN_TABLES = ( + "ar_partman_events", +) + + +def _qualified(table_name: str) -> str: + return f"public.{table_name}" + + +def _create_partitioned_parent_sql(dialect, table_name: str): + expr = CreateTableExpression( + dialect=dialect, + table=table_name, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("payload", "TEXT"), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + return expr.to_sql() + + +def _create_range_partition_sql(dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table="ar_partition_events", + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + +def _create_default_partition_sql(dialect, partition_name: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table="ar_partition_events", + partition_type="RANGE", + partition_values={"default": True}, + ) + return expr.to_sql() + + +def _delete_partition_events_for_range_expression(dialect, start, end): + return DeleteExpression( + dialect=dialect, + tables="ar_partition_events", + where=(Column(dialect, "created_at") >= Literal(dialect, start)) + & (Column(dialect, "created_at") < Literal(dialect, end)), + ) + + +def _insert_partition_events_expression(dialect, rows): + return InsertExpression( + dialect=dialect, + into="ar_partition_events", + columns=["id", "created_at", "payload"], + source=ValuesSource( + dialect, + [[Literal(dialect, value) for value in row] for row in rows], + ), + ) + + +def _detach_partition_sql(dialect, partition_name: str): + expr = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table="ar_partition_events", + ) + return expr.to_sql() + + +def _attach_partition_sql(dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table="ar_partition_events", + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + +def _drop_table_sql(dialect, table_name: str): + expr = DropTableExpression( + dialect=dialect, + table=table_name, + if_exists=True, + cascade=True, + ) + return expr.to_sql() + + +def _truncate_table_sql(dialect, table_name: str): + expr = TruncateExpression( + dialect=dialect, + table_name=table_name, + ) + return expr.to_sql() + + +def _partition_metadata_sql(dialect, table_name: str): + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table=table_name, + ) + return expr.to_sql() + + +PRODUCTION_PARTITION_TABLE = "ar_partition_ops_events" +PRODUCTION_DETACHED_TABLE = "ar_partition_ops_events_p2026" +PRODUCTION_PARTITION_TABLES = ( + "ar_partition_ops_events_p2026", + "ar_partition_ops_events_p2027_q1", + "ar_partition_ops_events_p2027_04", + "ar_partition_ops_events_p2027_w18", + "ar_partition_ops_events", +) +PRODUCTION_PARTITIONS = ( + ("ar_partition_ops_events_p2026", "2026-01-01 00:00:00.000000", "2027-01-01 00:00:00.000000"), + ("ar_partition_ops_events_p2027_q1", "2027-01-01 00:00:00.000000", "2027-04-01 00:00:00.000000"), + ("ar_partition_ops_events_p2027_04", "2027-04-01 00:00:00.000000", "2027-05-01 00:00:00.000000"), + ("ar_partition_ops_events_p2027_w18", "2027-05-01 00:00:00.000000", "2027-05-08 00:00:00.000000"), +) + + +def _create_production_parent_sql(dialect): + expr = CreateTableExpression( + dialect=dialect, + table=PRODUCTION_PARTITION_TABLE, + columns=[ + ColumnDefinition("id", "BIGINT NOT NULL"), + ColumnDefinition("created_at", "TIMESTAMP(6) NOT NULL"), + ColumnDefinition("tenant_id", "BIGINT NOT NULL"), + ColumnDefinition("payload", "TEXT NOT NULL"), + ], + table_constraints=[ + TableConstraint( + TableConstraintType.PRIMARY_KEY, + columns=["id", "created_at"], + ), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + return expr.to_sql() + + +def _create_production_partition_sql(dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=PRODUCTION_PARTITION_TABLE, + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + +def _detach_production_partition_sql(dialect, partition_name: str): + expr = PostgresDetachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=PRODUCTION_PARTITION_TABLE, + ) + return expr.to_sql() + + +def _attach_production_partition_sql(dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresAttachPartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=PRODUCTION_PARTITION_TABLE, + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + +def _create_production_index_sql(dialect, table_name: str): + expr = CreateIndexExpression( + dialect, + f"idx_{table_name}_tenant_created_at", + table_name, + ["tenant_id", "created_at"], + if_not_exists=True, + ) + return expr.to_sql() + + +def _insert_production_events_expression(dialect, rows): + return InsertExpression( + dialect=dialect, + into=PRODUCTION_PARTITION_TABLE, + columns=["id", "created_at", "tenant_id", "payload"], + source=ValuesSource( + dialect, + [[Literal(dialect, value) for value in row] for row in rows], + ), + ) + + +def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Optional[int] = None): + predicate = (Column(dialect, "created_at") >= Literal(dialect, start)) & ( + Column(dialect, "created_at") < Literal(dialect, end) + ) + if tenant_id is not None: + predicate = LogicalPredicate( + dialect, + "AND", + Column(dialect, "tenant_id") == Literal(dialect, tenant_id), + predicate, + ) + return QueryExpression( + dialect, + select=[Column(dialect, "payload")], + from_=TableExpression(dialect, PRODUCTION_PARTITION_TABLE), + where=predicate, + order_by=OrderByClause(dialect, [(Column(dialect, "id"), "ASC")]), + ) + + +def _production_range_query_expression(dialect, start, end, *, tenant_id: int): + return QueryExpression( + dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, PRODUCTION_PARTITION_TABLE), + where=LogicalPredicate( + dialect, + "AND", + Column(dialect, "tenant_id") == Literal(dialect, tenant_id), + (Column(dialect, "created_at") >= Literal(dialect, start)) + & (Column(dialect, "created_at") < Literal(dialect, end)), + ), + ) + + +def _select_count_expression(dialect, table_name: str): + return QueryExpression( + dialect, + select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + from_=TableExpression(dialect, table_name), + ) + + +def _production_partition_metadata_sql(dialect): + expr = PostgresPartitionMetadataExpression( + dialect=dialect, + parent_table=PRODUCTION_PARTITION_TABLE, + ) + return expr.to_sql() + + +def _production_partition_metadata(backend): + return backend.fetch_all(*_production_partition_metadata_sql(backend.dialect)) + + +async def _async_production_partition_metadata(backend): + return await backend.fetch_all(*_production_partition_metadata_sql(backend.dialect)) + + +def _assert_production_metadata(rows, expected_names: Sequence[str]): + assert {row["name"] for row in rows} == set(expected_names) + assert rows[0]["partition_key"] == "RANGE (created_at)" + by_name = {row["name"]: row for row in rows} + for name in expected_names: + assert by_name[name]["bound"] + + +def _combined_plan(result) -> str: + return "\n".join(row.line for row in result.rows).upper() + + +def _create_production_partitioned_table(backend, partitions): + dialect = backend.dialect + if not dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + if backend.get_server_version() < (11, 0, 0): + pytest.skip("production partition primary key scenario requires PostgreSQL 11+") + for table_name in PRODUCTION_PARTITION_TABLES: + backend.execute(*_drop_table_sql(dialect, table_name)) + backend.execute(*_create_production_parent_sql(dialect)) + for partition_name, from_value, to_value in partitions: + backend.execute( + *_create_production_partition_sql( + dialect, + partition_name, + from_value, + to_value, + ) + ) + for table_name, _, _ in partitions: + backend.execute(*_create_production_index_sql(dialect, table_name)) + + +async def _async_create_production_partitioned_table(backend, partitions): + dialect = backend.dialect + if not dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + if await backend.get_server_version() < (11, 0, 0): + pytest.skip("production partition primary key scenario requires PostgreSQL 11+") + for table_name in PRODUCTION_PARTITION_TABLES: + await backend.execute(*_drop_table_sql(dialect, table_name)) + await backend.execute(*_create_production_parent_sql(dialect)) + for partition_name, from_value, to_value in partitions: + await backend.execute( + *_create_production_partition_sql( + dialect, + partition_name, + from_value, + to_value, + ) + ) + for table_name, _, _ in partitions: + await backend.execute(*_create_production_index_sql(dialect, table_name)) + + +def _drop_production_partitioned_table(backend): + for table_name in PRODUCTION_PARTITION_TABLES: + backend.execute(*_drop_table_sql(backend.dialect, table_name)) + + +async def _async_drop_production_partitioned_table(backend): + for table_name in PRODUCTION_PARTITION_TABLES: + await backend.execute(*_drop_table_sql(backend.dialect, table_name)) + + +def _pg_partman_schema(backend) -> str: + row = backend.fetch_one( + """ + SELECT n.nspname AS schema_name + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = %s + """, + ("pg_partman",), + ) + if row is None: + pytest.skip("pg_partman extension is not installed") + return row["schema_name"] + + +async def _async_pg_partman_schema(backend) -> str: + row = await backend.fetch_one( + """ + SELECT n.nspname AS schema_name + FROM pg_extension e + JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = %s + """, + ("pg_partman",), + ) + if row is None: + pytest.skip("pg_partman extension is not installed") + return row["schema_name"] + + +def _pg_partman_create_parent_sql(dialect, partman_schema: str): + expr = PostgresPgPartmanCreateParentExpression( + dialect=dialect, + parent_table=_qualified("ar_partman_events"), + control="created_at", + interval="1 month", + partition_type="range", + premake=1, + schema=partman_schema, + ) + return expr.to_sql() + + +def _pg_partman_run_maintenance_sql(dialect, partman_schema: str): + expr = PostgresPgPartmanRunMaintenanceExpression( + dialect=dialect, + parent_table=_qualified("ar_partman_events"), + schema=partman_schema, + ) + return expr.to_sql() + + +def _pg_partman_global_run_maintenance_sql(dialect, partman_schema: str): + expr = PostgresPgPartmanRunMaintenanceExpression( + dialect=dialect, + schema=partman_schema, + ) + return expr.to_sql() + + +def _pg_partman_update_config_sql(dialect, partman_schema: str): + expr = PostgresPgPartmanUpdateConfigExpression( + dialect=dialect, + parent_table=_qualified("ar_partman_events"), + automatic_maintenance="on", + infinite_time_partitions=True, + schema=partman_schema, + ) + return expr.to_sql() + + +def _pg_partman_delete_config_sql(dialect, partman_schema: str): + expr = PostgresPgPartmanDeleteConfigExpression( + dialect=dialect, + parent_table=_qualified("ar_partman_events"), + schema=partman_schema, + ) + return expr.to_sql() + + +def _pg_partman_config_table_sql(dialect, partman_schema: str) -> str: + sql, _ = QualifiedIdentifierExpression( + dialect=dialect, + schema=partman_schema, + name="part_config", + ).to_sql() + return sql + + +@pytest.fixture +def partitioned_event_table(postgres_backend): + """Create a range-partitioned event table with two initial partitions.""" + dialect = postgres_backend.dialect + if not dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + + for table_name in PARTITION_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + postgres_backend.execute(sql, params) + + sql, params = _create_partitioned_parent_sql(dialect, "ar_partition_events") + postgres_backend.execute(sql, params) + + for partition_name, from_value, to_value in ( + ("ar_partition_events_p2026_01", "2026-01-01", "2026-02-01"), + ("ar_partition_events_p2026_02", "2026-02-01", "2026-03-01"), + ): + sql, params = _create_range_partition_sql(dialect, partition_name, from_value, to_value) + postgres_backend.execute(sql, params) + + yield "ar_partition_events" + + for table_name in PARTITION_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + postgres_backend.execute(sql, params) + + +@pytest_asyncio.fixture +async def async_partitioned_event_table(async_postgres_backend): + """Async counterpart for range-partitioned event table setup.""" + dialect = async_postgres_backend.dialect + if not dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + + for table_name in PARTITION_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + await async_postgres_backend.execute(sql, params) + + sql, params = _create_partitioned_parent_sql(dialect, "ar_partition_events") + await async_postgres_backend.execute(sql, params) + + for partition_name, from_value, to_value in ( + ("ar_partition_events_p2026_01", "2026-01-01", "2026-02-01"), + ("ar_partition_events_p2026_02", "2026-02-01", "2026-03-01"), + ): + sql, params = _create_range_partition_sql(dialect, partition_name, from_value, to_value) + await async_postgres_backend.execute(sql, params) + + yield "ar_partition_events" + + for table_name in PARTITION_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + await async_postgres_backend.execute(sql, params) + + +@pytest.fixture +def postgres_production_year_partition_table(postgres_backend): + """Create a production-like table with only the 2026 year partition.""" + _create_production_partitioned_table(postgres_backend, [PRODUCTION_PARTITIONS[0]]) + yield PRODUCTION_PARTITION_TABLE + _drop_production_partitioned_table(postgres_backend) + + +@pytest_asyncio.fixture +async def async_postgres_production_year_partition_table(async_postgres_backend): + """Create the async production-like table with only the 2026 partition.""" + await _async_create_production_partitioned_table( + async_postgres_backend, + [PRODUCTION_PARTITIONS[0]], + ) + yield PRODUCTION_PARTITION_TABLE + await _async_drop_production_partitioned_table(async_postgres_backend) + + +@pytest.fixture +def postgres_production_partition_table(postgres_backend): + """Create a production-like table with year, quarter, month, and week partitions.""" + _create_production_partitioned_table(postgres_backend, PRODUCTION_PARTITIONS) + yield PRODUCTION_PARTITION_TABLE + _drop_production_partitioned_table(postgres_backend) + + +@pytest_asyncio.fixture +async def async_postgres_production_partition_table(async_postgres_backend): + """Create the async production-like table with multiple future partitions.""" + await _async_create_production_partitioned_table( + async_postgres_backend, + PRODUCTION_PARTITIONS, + ) + yield PRODUCTION_PARTITION_TABLE + await _async_drop_production_partitioned_table(async_postgres_backend) + + +@pytest.fixture +def pg_partman_table(postgres_backend_single): + """Create an isolated table for pg_partman maintenance tests.""" + ensure_extension_installed(postgres_backend_single, "pg_partman") + dialect = postgres_backend_single.dialect + + partman_schema = _pg_partman_schema(postgres_backend_single) + sql, params = _pg_partman_delete_config_sql(dialect, partman_schema) + postgres_backend_single.execute(sql, params) + for table_name in PARTMAN_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + postgres_backend_single.execute(sql, params) + + sql, params = _create_partitioned_parent_sql(dialect, "ar_partman_events") + postgres_backend_single.execute(sql, params) + + yield "ar_partman_events" + + sql, params = _pg_partman_delete_config_sql(dialect, partman_schema) + postgres_backend_single.execute(sql, params) + for table_name in PARTMAN_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + postgres_backend_single.execute(sql, params) + + +@pytest_asyncio.fixture +async def async_pg_partman_table(async_postgres_backend_single): + """Async counterpart for pg_partman table setup.""" + await async_ensure_extension_installed(async_postgres_backend_single, "pg_partman") + dialect = async_postgres_backend_single.dialect + + partman_schema = await _async_pg_partman_schema(async_postgres_backend_single) + sql, params = _pg_partman_delete_config_sql(dialect, partman_schema) + await async_postgres_backend_single.execute(sql, params) + for table_name in PARTMAN_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + await async_postgres_backend_single.execute(sql, params) + + sql, params = _create_partitioned_parent_sql(dialect, "ar_partman_events") + await async_postgres_backend_single.execute(sql, params) + + yield "ar_partman_events" + + sql, params = _pg_partman_delete_config_sql(dialect, partman_schema) + await async_postgres_backend_single.execute(sql, params) + for table_name in PARTMAN_TABLES: + sql, params = _drop_table_sql(dialect, table_name) + await async_postgres_backend_single.execute(sql, params) + + +class TestPostgreSQLPartitionOperations: + """Synchronous real backend tests for common partition operations.""" + + def test_partition_routing_and_pruning_metadata(self, postgres_backend, partitioned_event_table): + """Rows route to expected partitions and pg metadata records the parent.""" + postgres_backend.execute( + """ + INSERT INTO ar_partition_events (id, created_at, payload) + VALUES (%s, %s, %s), (%s, %s, %s) + """, + (1, "2026-01-15", "jan", 2, "2026-02-15", "feb"), + ) + + rows = postgres_backend.fetch_all( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + ORDER BY id + """ + ) + assert [row["partition_name"] for row in rows] == [ + "ar_partition_events_p2026_01", + "ar_partition_events_p2026_02", + ] + + sql, params = _partition_metadata_sql(postgres_backend.dialect, partitioned_event_table) + metadata = postgres_backend.fetch_all(sql, params) + assert metadata[0]["partition_key"] == "RANGE (created_at)" + assert {row["name"] for row in metadata} == { + "ar_partition_events_p2026_01", + "ar_partition_events_p2026_02", + } + + def test_add_partition_for_future_range(self, postgres_backend, partitioned_event_table): + """A future range partition can be added for normal operations.""" + dialect = postgres_backend.dialect + sql, params = _create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-03-01", + "2026-04-01", + ) + postgres_backend.execute(sql, params) + postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(3, "2026-03-15", "mar")], + ).to_sql() + ) + + row = postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name + FROM ar_partition_events + WHERE id = %s + """, + (3,), + ) + assert row["partition_name"] == "ar_partition_events_p2026_03" + + def test_default_partition_catches_overflow_and_blocks_conflicting_range( + self, + postgres_backend, + partitioned_event_table, + ): + """DEFAULT partition catches overflow until operators split a concrete range. + + Scenario: a DEFAULT partition keeps out-of-range events writable while + operators prepare the next explicit range partition. + + Steps: create a DEFAULT partition, insert an April row into it, prove a + conflicting April partition cannot be added, delete the conflicting row, + then add the April range partition and insert a replacement row. + + Assertions: overflow rows route to the DEFAULT partition; PostgreSQL + rejects a concrete partition whose range overlaps data already stored in + DEFAULT; after cleanup, the new concrete range accepts the replacement row. + + Production value: this captures the default-partition maintenance runbook + and proves that cleanup is required before splitting catch-all data. + """ + dialect = postgres_backend.dialect + if not dialect.supports_default_partition(): + pytest.skip("PostgreSQL scenario does not support DEFAULT partitions") + postgres_backend.execute( + *_create_default_partition_sql(dialect, "ar_partition_events_default") + ) + postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(30, "2026-04-15", "default-overflow")], + ).to_sql() + ) + + row = postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + WHERE id = %s + """, + (30,), + ) + assert row["partition_name"] == "ar_partition_events_default" + assert row["payload"] == "default-overflow" + + with pytest.raises(Exception): + postgres_backend.execute( + *_create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-04-01", + "2026-05-01", + ) + ) + postgres_backend.execute( + *_delete_partition_events_for_range_expression( + dialect, + "2026-04-01", + "2026-05-01", + ).to_sql() + ) + postgres_backend.execute( + *_create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-04-01", + "2026-05-01", + ) + ) + postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(31, "2026-04-16", "split-range")], + ).to_sql() + ) + + row = postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + WHERE id = %s + """, + (31,), + ) + assert row["partition_name"] == "ar_partition_events_p2026_03" + assert row["payload"] == "split-range" + + def test_detach_partition_for_archive_and_reattach(self, postgres_backend, partitioned_event_table): + """A partition can be detached for archive work and attached back.""" + dialect = postgres_backend.dialect + postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (1, "2026-01-15", "jan"), + ) + + sql, params = _detach_partition_sql(dialect, "ar_partition_events_p2026_01") + postgres_backend.execute(sql, params) + + parent_count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + )["count"] + archive_count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events_p2026_01" + )["count"] + assert parent_count == 0 + assert archive_count == 1 + + sql, params = _attach_partition_sql( + dialect, + "ar_partition_events_p2026_01", + "2026-01-01", + "2026-02-01", + ) + postgres_backend.execute(sql, params) + + parent_count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + )["count"] + assert parent_count == 1 + + def test_truncate_partition_keeps_partition_available(self, postgres_backend, partitioned_event_table): + """A single partition can be truncated without dropping the parent.""" + postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (1, "2026-01-15", "jan"), + ) + sql, params = _truncate_table_sql(postgres_backend.dialect, "ar_partition_events_p2026_01") + postgres_backend.execute(sql, params) + + count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + )["count"] + assert count == 0 + + postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (2, "2026-01-20", "jan-again"), + ) + count = postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + )["count"] + assert count == 1 + + +class TestAsyncPostgreSQLPartitionOperations: + """Asynchronous real backend tests for common partition operations.""" + + @pytest.mark.asyncio + async def test_partition_routing_and_pruning_metadata( + self, + async_postgres_backend, + async_partitioned_event_table, + ): + """Rows route to expected partitions and pg metadata records the parent.""" + await async_postgres_backend.execute( + """ + INSERT INTO ar_partition_events (id, created_at, payload) + VALUES (%s, %s, %s), (%s, %s, %s) + """, + (1, "2026-01-15", "jan", 2, "2026-02-15", "feb"), + ) + + rows = await async_postgres_backend.fetch_all( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + ORDER BY id + """ + ) + assert [row["partition_name"] for row in rows] == [ + "ar_partition_events_p2026_01", + "ar_partition_events_p2026_02", + ] + + sql, params = _partition_metadata_sql( + async_postgres_backend.dialect, + async_partitioned_event_table, + ) + metadata = await async_postgres_backend.fetch_all(sql, params) + assert metadata[0]["partition_key"] == "RANGE (created_at)" + assert {row["name"] for row in metadata} == { + "ar_partition_events_p2026_01", + "ar_partition_events_p2026_02", + } + + @pytest.mark.asyncio + async def test_add_partition_for_future_range( + self, + async_postgres_backend, + async_partitioned_event_table, + ): + """A future range partition can be added for normal operations.""" + dialect = async_postgres_backend.dialect + sql, params = _create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-03-01", + "2026-04-01", + ) + await async_postgres_backend.execute(sql, params) + await async_postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(3, "2026-03-15", "mar")], + ).to_sql() + ) + + row = await async_postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name + FROM ar_partition_events + WHERE id = %s + """, + (3,), + ) + assert row["partition_name"] == "ar_partition_events_p2026_03" + + @pytest.mark.asyncio + async def test_default_partition_catches_overflow_and_blocks_conflicting_range( + self, + async_postgres_backend, + async_partitioned_event_table, + ): + """DEFAULT partition catches overflow until operators split a concrete range. + + Scenario: a DEFAULT partition keeps out-of-range events writable while + operators prepare the next explicit range partition. + + Steps: create a DEFAULT partition, insert an April row into it, prove a + conflicting April partition cannot be added, delete the conflicting row, + then add the April range partition and insert a replacement row. + + Assertions: overflow rows route to the DEFAULT partition; PostgreSQL + rejects a concrete partition whose range overlaps data already stored in + DEFAULT; after cleanup, the new concrete range accepts the replacement row. + + Production value: this captures the default-partition maintenance runbook + and proves that cleanup is required before splitting catch-all data. + """ + dialect = async_postgres_backend.dialect + if not dialect.supports_default_partition(): + pytest.skip("PostgreSQL scenario does not support DEFAULT partitions") + await async_postgres_backend.execute( + *_create_default_partition_sql(dialect, "ar_partition_events_default") + ) + await async_postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(30, "2026-04-15", "default-overflow")], + ).to_sql() + ) + + row = await async_postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + WHERE id = %s + """, + (30,), + ) + assert row["partition_name"] == "ar_partition_events_default" + assert row["payload"] == "default-overflow" + + with pytest.raises(Exception): + await async_postgres_backend.execute( + *_create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-04-01", + "2026-05-01", + ) + ) + await async_postgres_backend.execute( + *_delete_partition_events_for_range_expression( + dialect, + "2026-04-01", + "2026-05-01", + ).to_sql() + ) + await async_postgres_backend.execute( + *_create_range_partition_sql( + dialect, + "ar_partition_events_p2026_03", + "2026-04-01", + "2026-05-01", + ) + ) + await async_postgres_backend.execute( + *_insert_partition_events_expression( + dialect, + [(31, "2026-04-16", "split-range")], + ).to_sql() + ) + + row = await async_postgres_backend.fetch_one( + """ + SELECT tableoid::regclass::text AS partition_name, payload + FROM ar_partition_events + WHERE id = %s + """, + (31,), + ) + assert row["partition_name"] == "ar_partition_events_p2026_03" + assert row["payload"] == "split-range" + + @pytest.mark.asyncio + async def test_detach_partition_for_archive_and_reattach( + self, + async_postgres_backend, + async_partitioned_event_table, + ): + """A partition can be detached for archive work and attached back.""" + dialect = async_postgres_backend.dialect + await async_postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (1, "2026-01-15", "jan"), + ) + + sql, params = _detach_partition_sql(dialect, "ar_partition_events_p2026_01") + await async_postgres_backend.execute(sql, params) + + parent_count = (await async_postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + ))["count"] + archive_count = (await async_postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events_p2026_01" + ))["count"] + assert parent_count == 0 + assert archive_count == 1 + + sql, params = _attach_partition_sql( + dialect, + "ar_partition_events_p2026_01", + "2026-01-01", + "2026-02-01", + ) + await async_postgres_backend.execute(sql, params) + + parent_count = (await async_postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + ))["count"] + assert parent_count == 1 + + @pytest.mark.asyncio + async def test_truncate_partition_keeps_partition_available( + self, + async_postgres_backend, + async_partitioned_event_table, + ): + """A single partition can be truncated without dropping the parent.""" + await async_postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (1, "2026-01-15", "jan"), + ) + sql, params = _truncate_table_sql(async_postgres_backend.dialect, "ar_partition_events_p2026_01") + await async_postgres_backend.execute(sql, params) + + count = (await async_postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + ))["count"] + assert count == 0 + + await async_postgres_backend.execute( + "INSERT INTO ar_partition_events (id, created_at, payload) VALUES (%s, %s, %s)", + (2, "2026-01-20", "jan-again"), + ) + count = (await async_postgres_backend.fetch_one( + "SELECT COUNT(*) AS count FROM ar_partition_events" + ))["count"] + assert count == 1 + + +class TestPostgreSQLProductionTimePartitionOperations: + """Synchronous production-style time partition operation scenarios.""" + + def test_initial_year_partition_uses_microsecond_boundaries( + self, + postgres_backend, + postgres_production_year_partition_table, + ): + """Validate the initial annual partition used during table rollout. + + Scenario: the production table starts with only the 2026 calendar-year + partition. The partition key is `created_at TIMESTAMP(6)`, and the + primary key includes `(id, created_at)` to model a real time-partitioned + table constraint. + + Steps: create the parent table with only the 2026 partition, insert the + 2026 lower bound and the last microsecond before 2027, then try to + insert the first timestamp of 2027 before a future partition exists. + + Assertions: metadata lists only the annual partition; microsecond + boundary rows are queryable through the parent table; out-of-range + future data is rejected by PostgreSQL. + + Production value: this proves the annual rollout can cover a complete + calendar year while exposing missing future partitions at the database + boundary. + """ + assert postgres_production_year_partition_table == PRODUCTION_PARTITION_TABLE + _assert_production_metadata( + _production_partition_metadata(postgres_backend), + [PRODUCTION_DETACHED_TABLE], + ) + postgres_backend.execute( + *_insert_production_events_expression( + postgres_backend.dialect, + [ + [1, datetime(2026, 1, 1, 0, 0, 0, 0), 10, "year-start"], + [2, datetime(2026, 12, 31, 23, 59, 59, 999999), 10, "year-end"], + ], + ).to_sql() + ) + + rows = postgres_backend.fetch_all( + *_select_production_payloads_expression( + postgres_backend.dialect, + datetime(2026, 1, 1), + datetime(2027, 1, 1), + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["year-start", "year-end"] + + with pytest.raises(Exception): + postgres_backend.execute( + *_insert_production_events_expression( + postgres_backend.dialect, + [[3, datetime(2027, 1, 1, 0, 0, 0, 0), 10, "missing-partition"]], + ).to_sql() + ) + + def test_precreate_future_partitions_before_traffic_arrives( + self, + postgres_backend, + postgres_production_year_partition_table, + ): + """Pre-create future partitions with mixed operational granularities. + + Scenario: operators prepare future partitions before traffic arrives, + and the future granularity may shift from yearly to quarterly, monthly, + or weekly partitions. + + Steps: start from the initial 2026 annual partition, create the 2027 Q1, + April 2027, and ISO-week-like 2027 week 18 partitions, then insert rows + into each future window. + + Assertions: metadata reflects all newly created partitions; future rows + are accepted; parent-table queries can read the full pre-created range. + + Production value: this verifies rolling partition pre-creation so + traffic can cross time boundaries without emergency DDL. + """ + for partition_name, from_value, to_value in PRODUCTION_PARTITIONS[1:]: + postgres_backend.execute( + *_create_production_partition_sql( + postgres_backend.dialect, + partition_name, + from_value, + to_value, + ) + ) + + _assert_production_metadata( + _production_partition_metadata(postgres_backend), + [name for name, _, _ in PRODUCTION_PARTITIONS], + ) + postgres_backend.execute( + *_insert_production_events_expression( + postgres_backend.dialect, + [ + [11, datetime(2027, 2, 15, 8, 0, 0, 123456), 10, "q1"], + [12, datetime(2027, 4, 15, 8, 0, 0, 123456), 10, "month"], + [13, datetime(2027, 5, 3, 8, 0, 0, 123456), 10, "week"], + ], + ).to_sql() + ) + + rows = postgres_backend.fetch_all( + *_select_production_payloads_expression( + postgres_backend.dialect, + datetime(2027, 1, 1), + datetime(2027, 5, 8), + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["q1", "month", "week"] + + def test_query_continuous_partitions_and_explain_uses_index( + self, + postgres_backend, + postgres_production_partition_table, + ): + """Query a continuous time range and inspect pruning/index usage. + + Scenario: production searches often span several continuous partitions + while filtering by tenant and time range, so both pruning and business + indexes matter. + + Steps: insert rows across yearly, quarterly, monthly, and weekly + partitions, query `[2027-02-01, 2027-05-08)` for `tenant_id=10`, and run + EXPLAIN for the same QueryExpression. + + Assertions: the query returns only target-tenant rows in the continuous + range; the plan includes target partitions and excludes the 2026 cold + partition; disabling sequential scans exposes an index path. + + Production value: this proves PostgreSQL partition pruning does not + replace business indexes, and continuous time-range searches should keep + the `(tenant_id, created_at)` composite index. + """ + postgres_backend.execute( + *_insert_production_events_expression( + postgres_backend.dialect, + [ + [21, datetime(2026, 6, 1), 10, "old-year"], + [22, datetime(2027, 2, 15), 10, "q1"], + [23, datetime(2027, 4, 15), 10, "month"], + [24, datetime(2027, 5, 3), 10, "week"], + [25, datetime(2027, 5, 3), 20, "other-tenant"], + ], + ).to_sql() + ) + + rows = postgres_backend.fetch_all( + *_select_production_payloads_expression( + postgres_backend.dialect, + datetime(2027, 2, 1), + datetime(2027, 5, 8), + tenant_id=10, + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["q1", "month", "week"] + + query = _production_range_query_expression( + postgres_backend.dialect, + datetime(2027, 2, 1), + datetime(2027, 5, 8), + tenant_id=10, + ) + postgres_backend.execute("SET enable_seqscan = off") + try: + plan = _combined_plan(postgres_backend.explain(query)) + finally: + postgres_backend.execute("RESET enable_seqscan") + assert "AR_PARTITION_OPS_EVENTS_P2026" not in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_Q1" in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_04" in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_W18" in plan + assert "INDEX" in plan + + def test_detach_expired_year_partition_for_cold_archive( + self, + postgres_backend, + postgres_production_partition_table, + ): + """Detach an expired year partition for cold archival while keeping data. + + Scenario: when an expired annual partition becomes cold data, + PostgreSQL can detach the child partition into a regular table without + deleting rows. + + Steps: insert cold 2026 data and hot 2027 data, detach the 2026 annual + partition, verify parent/detached-table visibility, then attach it back + as a recovery path. + + Assertions: metadata no longer lists the 2026 partition after detach; + the parent no longer returns cold data; the detached table keeps cold + data; reattach makes cold data visible through the parent again. + + Production value: this documents the PostgreSQL cold-archive workflow + for removing a partition from the hot parent without data loss. + """ + postgres_backend.execute( + *_insert_production_events_expression( + postgres_backend.dialect, + [ + [31, datetime(2026, 6, 1), 10, "cold-year"], + [32, datetime(2027, 2, 1), 10, "hot-quarter"], + ], + ).to_sql() + ) + postgres_backend.execute( + *_detach_production_partition_sql(postgres_backend.dialect, PRODUCTION_DETACHED_TABLE) + ) + + metadata = _production_partition_metadata(postgres_backend) + assert PRODUCTION_DETACHED_TABLE not in {row["name"] for row in metadata} + parent_count = postgres_backend.fetch_one( + *_select_count_expression(postgres_backend.dialect, PRODUCTION_PARTITION_TABLE).to_sql() + )["count"] + archive_count = postgres_backend.fetch_one( + *_select_count_expression(postgres_backend.dialect, PRODUCTION_DETACHED_TABLE).to_sql() + )["count"] + assert parent_count == 1 + assert archive_count == 1 + + postgres_backend.execute( + *_attach_production_partition_sql( + postgres_backend.dialect, + PRODUCTION_DETACHED_TABLE, + "2026-01-01 00:00:00.000000", + "2027-01-01 00:00:00.000000", + ) + ) + parent_count = postgres_backend.fetch_one( + *_select_count_expression(postgres_backend.dialect, PRODUCTION_PARTITION_TABLE).to_sql() + )["count"] + assert parent_count == 2 + + +class TestAsyncPostgreSQLProductionTimePartitionOperations: + """Asynchronous production-style time partition operation scenarios.""" + + @pytest.mark.asyncio + async def test_initial_year_partition_uses_microsecond_boundaries( + self, + async_postgres_backend, + async_postgres_production_year_partition_table, + ): + """Validate the initial annual partition used during table rollout. + + Scenario: the production table starts with only the 2026 calendar-year + partition. The partition key is `created_at TIMESTAMP(6)`, and the + primary key includes `(id, created_at)` to model a real time-partitioned + table constraint. + + Steps: create the parent table with only the 2026 partition, insert the + 2026 lower bound and the last microsecond before 2027, then try to + insert the first timestamp of 2027 before a future partition exists. + + Assertions: metadata lists only the annual partition; microsecond + boundary rows are queryable through the parent table; out-of-range + future data is rejected by PostgreSQL. + + Production value: this proves the annual rollout can cover a complete + calendar year while exposing missing future partitions at the database + boundary. + """ + assert async_postgres_production_year_partition_table == PRODUCTION_PARTITION_TABLE + rows = await _async_production_partition_metadata(async_postgres_backend) + _assert_production_metadata(rows, [PRODUCTION_DETACHED_TABLE]) + await async_postgres_backend.execute( + *_insert_production_events_expression( + async_postgres_backend.dialect, + [ + [1, datetime(2026, 1, 1, 0, 0, 0, 0), 10, "year-start"], + [2, datetime(2026, 12, 31, 23, 59, 59, 999999), 10, "year-end"], + ], + ).to_sql() + ) + + rows = await async_postgres_backend.fetch_all( + *_select_production_payloads_expression( + async_postgres_backend.dialect, + datetime(2026, 1, 1), + datetime(2027, 1, 1), + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["year-start", "year-end"] + + with pytest.raises(Exception): + await async_postgres_backend.execute( + *_insert_production_events_expression( + async_postgres_backend.dialect, + [[3, datetime(2027, 1, 1, 0, 0, 0, 0), 10, "missing-partition"]], + ).to_sql() + ) + + @pytest.mark.asyncio + async def test_precreate_future_partitions_before_traffic_arrives( + self, + async_postgres_backend, + async_postgres_production_year_partition_table, + ): + """Pre-create future partitions with mixed operational granularities. + + Scenario: operators prepare future partitions before traffic arrives, + and the future granularity may shift from yearly to quarterly, monthly, + or weekly partitions. + + Steps: start from the initial 2026 annual partition, create the 2027 Q1, + April 2027, and ISO-week-like 2027 week 18 partitions, then insert rows + into each future window. + + Assertions: metadata reflects all newly created partitions; future rows + are accepted; parent-table queries can read the full pre-created range. + + Production value: this verifies rolling partition pre-creation so + traffic can cross time boundaries without emergency DDL. + """ + for partition_name, from_value, to_value in PRODUCTION_PARTITIONS[1:]: + await async_postgres_backend.execute( + *_create_production_partition_sql( + async_postgres_backend.dialect, + partition_name, + from_value, + to_value, + ) + ) + + rows = await _async_production_partition_metadata(async_postgres_backend) + _assert_production_metadata(rows, [name for name, _, _ in PRODUCTION_PARTITIONS]) + await async_postgres_backend.execute( + *_insert_production_events_expression( + async_postgres_backend.dialect, + [ + [11, datetime(2027, 2, 15, 8, 0, 0, 123456), 10, "q1"], + [12, datetime(2027, 4, 15, 8, 0, 0, 123456), 10, "month"], + [13, datetime(2027, 5, 3, 8, 0, 0, 123456), 10, "week"], + ], + ).to_sql() + ) + + rows = await async_postgres_backend.fetch_all( + *_select_production_payloads_expression( + async_postgres_backend.dialect, + datetime(2027, 1, 1), + datetime(2027, 5, 8), + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["q1", "month", "week"] + + @pytest.mark.asyncio + async def test_query_continuous_partitions_and_explain_uses_index( + self, + async_postgres_backend, + async_postgres_production_partition_table, + ): + """Query a continuous time range and inspect pruning/index usage. + + Scenario: production searches often span several continuous partitions + while filtering by tenant and time range, so both pruning and business + indexes matter. + + Steps: insert rows across yearly, quarterly, monthly, and weekly + partitions, query `[2027-02-01, 2027-05-08)` for `tenant_id=10`, and run + EXPLAIN for the same QueryExpression. + + Assertions: the query returns only target-tenant rows in the continuous + range; the plan includes target partitions and excludes the 2026 cold + partition; disabling sequential scans exposes an index path. + + Production value: this proves PostgreSQL partition pruning does not + replace business indexes, and continuous time-range searches should keep + the `(tenant_id, created_at)` composite index. + """ + await async_postgres_backend.execute( + *_insert_production_events_expression( + async_postgres_backend.dialect, + [ + [21, datetime(2026, 6, 1), 10, "old-year"], + [22, datetime(2027, 2, 15), 10, "q1"], + [23, datetime(2027, 4, 15), 10, "month"], + [24, datetime(2027, 5, 3), 10, "week"], + [25, datetime(2027, 5, 3), 20, "other-tenant"], + ], + ).to_sql() + ) + + rows = await async_postgres_backend.fetch_all( + *_select_production_payloads_expression( + async_postgres_backend.dialect, + datetime(2027, 2, 1), + datetime(2027, 5, 8), + tenant_id=10, + ).to_sql() + ) + assert [row["payload"] for row in rows] == ["q1", "month", "week"] + + query = _production_range_query_expression( + async_postgres_backend.dialect, + datetime(2027, 2, 1), + datetime(2027, 5, 8), + tenant_id=10, + ) + await async_postgres_backend.execute("SET enable_seqscan = off") + try: + plan = _combined_plan(await async_postgres_backend.explain(query)) + finally: + await async_postgres_backend.execute("RESET enable_seqscan") + assert "AR_PARTITION_OPS_EVENTS_P2026" not in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_Q1" in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_04" in plan + assert "AR_PARTITION_OPS_EVENTS_P2027_W18" in plan + assert "INDEX" in plan + + @pytest.mark.asyncio + async def test_detach_expired_year_partition_for_cold_archive( + self, + async_postgres_backend, + async_postgres_production_partition_table, + ): + """Detach an expired year partition for cold archival while keeping data. + + Scenario: when an expired annual partition becomes cold data, + PostgreSQL can detach the child partition into a regular table without + deleting rows. + + Steps: insert cold 2026 data and hot 2027 data, detach the 2026 annual + partition, verify parent/detached-table visibility, then attach it back + as a recovery path. + + Assertions: metadata no longer lists the 2026 partition after detach; + the parent no longer returns cold data; the detached table keeps cold + data; reattach makes cold data visible through the parent again. + + Production value: this documents the PostgreSQL cold-archive workflow + for removing a partition from the hot parent without data loss. + """ + await async_postgres_backend.execute( + *_insert_production_events_expression( + async_postgres_backend.dialect, + [ + [31, datetime(2026, 6, 1), 10, "cold-year"], + [32, datetime(2027, 2, 1), 10, "hot-quarter"], + ], + ).to_sql() + ) + await async_postgres_backend.execute( + *_detach_production_partition_sql( + async_postgres_backend.dialect, + PRODUCTION_DETACHED_TABLE, + ) + ) + + metadata = await _async_production_partition_metadata(async_postgres_backend) + assert PRODUCTION_DETACHED_TABLE not in {row["name"] for row in metadata} + parent_count = (await async_postgres_backend.fetch_one( + *_select_count_expression(async_postgres_backend.dialect, PRODUCTION_PARTITION_TABLE).to_sql() + ))["count"] + archive_count = (await async_postgres_backend.fetch_one( + *_select_count_expression(async_postgres_backend.dialect, PRODUCTION_DETACHED_TABLE).to_sql() + ))["count"] + assert parent_count == 1 + assert archive_count == 1 + + await async_postgres_backend.execute( + *_attach_production_partition_sql( + async_postgres_backend.dialect, + PRODUCTION_DETACHED_TABLE, + "2026-01-01 00:00:00.000000", + "2027-01-01 00:00:00.000000", + ) + ) + parent_count = (await async_postgres_backend.fetch_one( + *_select_count_expression(async_postgres_backend.dialect, PRODUCTION_PARTITION_TABLE).to_sql() + ))["count"] + assert parent_count == 2 + + +class TestPostgreSQLPgPartmanOperations: + """Synchronous pg_partman real backend tests.""" + + def test_pg_partman_create_parent_and_run_maintenance( + self, + postgres_backend_single, + pg_partman_table, + ): + """pg_partman can register a parent table, update config, and maintain it. + + Scenario: production automation delegates future partition maintenance to + pg_partman while keeping configuration changes explicit. + + Steps: register the partitioned parent, update part_config options, run + scoped maintenance, then run global maintenance. + + Assertions: part_config stores the parent and updated options; maintenance + calls complete; partition metadata can be inspected after maintenance. + + Production value: this verifies the pg_partman runbook used by operators + to keep future partitions maintained without manual DDL for every window. + """ + partman_schema = _pg_partman_schema(postgres_backend_single) + sql, params = _pg_partman_create_parent_sql(postgres_backend_single.dialect, partman_schema) + options = ExecutionOptions(stmt_type=StatementType.DQL) + postgres_backend_single.execute(sql, params, options=options) + + postgres_backend_single.execute( + *_pg_partman_update_config_sql(postgres_backend_single.dialect, partman_schema) + ) + + config_table_sql = _pg_partman_config_table_sql(postgres_backend_single.dialect, partman_schema) + row = postgres_backend_single.fetch_one( + f""" + SELECT parent_table, automatic_maintenance, infinite_time_partitions + FROM {config_table_sql} + WHERE parent_table = %s + """, + (_qualified(pg_partman_table),), + ) + assert row is not None + assert row["automatic_maintenance"] == "on" + assert row["infinite_time_partitions"] is True + + sql, params = _pg_partman_run_maintenance_sql(postgres_backend_single.dialect, partman_schema) + postgres_backend_single.execute(sql, params, options=options) + sql, params = _pg_partman_global_run_maintenance_sql( + postgres_backend_single.dialect, + partman_schema, + ) + postgres_backend_single.execute(sql, params, options=options) + + metadata_sql, metadata_params = _partition_metadata_sql( + postgres_backend_single.dialect, + pg_partman_table, + ) + metadata = postgres_backend_single.fetch_all(metadata_sql, metadata_params) + assert metadata + assert all(row["bound"] for row in metadata) + + +class TestAsyncPostgreSQLPgPartmanOperations: + """Asynchronous pg_partman real backend tests.""" + + @pytest.mark.asyncio + async def test_pg_partman_create_parent_and_run_maintenance( + self, + async_postgres_backend_single, + async_pg_partman_table, + ): + """pg_partman can register a parent table, update config, and maintain it. + + Scenario: production automation delegates future partition maintenance to + pg_partman while keeping configuration changes explicit. + + Steps: register the partitioned parent, update part_config options, run + scoped maintenance, then run global maintenance. + + Assertions: part_config stores the parent and updated options; maintenance + calls complete; partition metadata can be inspected after maintenance. + + Production value: this verifies the pg_partman runbook used by operators + to keep future partitions maintained without manual DDL for every window. + """ + partman_schema = await _async_pg_partman_schema(async_postgres_backend_single) + sql, params = _pg_partman_create_parent_sql( + async_postgres_backend_single.dialect, + partman_schema, + ) + options = ExecutionOptions(stmt_type=StatementType.DQL) + await async_postgres_backend_single.execute(sql, params, options=options) + + await async_postgres_backend_single.execute( + *_pg_partman_update_config_sql( + async_postgres_backend_single.dialect, + partman_schema, + ) + ) + + config_table_sql = _pg_partman_config_table_sql( + async_postgres_backend_single.dialect, + partman_schema, + ) + row = await async_postgres_backend_single.fetch_one( + f""" + SELECT parent_table, automatic_maintenance, infinite_time_partitions + FROM {config_table_sql} + WHERE parent_table = %s + """, + (_qualified(async_pg_partman_table),), + ) + assert row is not None + assert row["automatic_maintenance"] == "on" + assert row["infinite_time_partitions"] is True + + sql, params = _pg_partman_run_maintenance_sql( + async_postgres_backend_single.dialect, + partman_schema, + ) + await async_postgres_backend_single.execute(sql, params, options=options) + sql, params = _pg_partman_global_run_maintenance_sql( + async_postgres_backend_single.dialect, + partman_schema, + ) + await async_postgres_backend_single.execute(sql, params, options=options) + + metadata_sql, metadata_params = _partition_metadata_sql( + async_postgres_backend_single.dialect, + async_pg_partman_table, + ) + metadata = await async_postgres_backend_single.fetch_all(metadata_sql, metadata_params) + assert metadata + assert all(row["bound"] for row in metadata) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py new file mode 100644 index 00000000..9f16564c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_datetime_interval_explain_examples.py @@ -0,0 +1,296 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/ +# test_postgres_datetime_interval_explain_examples.py +"""PostgreSQL EXPLAIN examples for datetime interval expressions and indexes.""" + +from decimal import Decimal + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + LogicalPredicate, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.functions import ( + date_add, + date_diff, + date_sub, + date_trunc, + extract, +) +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause +from rhosocial.activerecord.backend.impl.postgres import PostgresExplainResult +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + + +_SETUP_STMTS = [ + "DROP TABLE IF EXISTS explain_temporal_events CASCADE", + """CREATE TABLE explain_temporal_events ( + id SERIAL PRIMARY KEY, + category VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + started_at TIMESTAMP NOT NULL, + ended_at TIMESTAMP NOT NULL + )""", + "CREATE INDEX idx_temporal_created_at ON explain_temporal_events(created_at)", + """CREATE INDEX idx_temporal_started_ended + ON explain_temporal_events(started_at, ended_at)""", + """CREATE INDEX idx_temporal_category_created + ON explain_temporal_events(category, created_at)""", + """INSERT INTO explain_temporal_events + (category, created_at, started_at, ended_at) + SELECT + CASE + WHEN n IN (55, 58, 62) THEN 'deploy' + WHEN n % 3 = 0 THEN 'billing' + WHEN n % 3 = 1 THEN 'report' + ELSE 'maintenance' + END, + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day', + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day' + + INTERVAL '10 minutes', + TIMESTAMP '2026-01-01 00:00:00' + n * INTERVAL '1 day' + + INTERVAL '40 minutes' + FROM generate_series(0, 199) AS seq(n)""", + "ANALYZE explain_temporal_events", +] + +_CLEANUP_STMTS = ["DROP TABLE IF EXISTS explain_temporal_events CASCADE"] +_DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL) +_INDEX_SCAN_TERMS = ("INDEX SCAN", "BITMAP INDEX SCAN", "INDEX ONLY SCAN") +_DATETIME_INDEX_NAMES = ( + "IDX_TEMPORAL_CATEGORY_CREATED", + "IDX_TEMPORAL_CREATED_AT", +) + + +@pytest.fixture(scope="function") +def temporal_indexed_backend(postgres_backend): + for stmt in _SETUP_STMTS: + postgres_backend.execute(stmt) + yield postgres_backend + try: + for stmt in _CLEANUP_STMTS: + postgres_backend.execute(stmt) + except Exception: + pass + + +@pytest_asyncio.fixture(scope="function") +async def async_temporal_indexed_backend(async_postgres_backend): + for stmt in _SETUP_STMTS: + await async_postgres_backend.execute(stmt) + yield async_postgres_backend + try: + for stmt in _CLEANUP_STMTS: + await async_postgres_backend.execute(stmt) + except Exception: + pass + + +def _combined_plan(result: PostgresExplainResult) -> str: + return " ".join(row.line.upper() for row in result.rows) + + +def _assert_datetime_index_used(plan: str): + assert any(term in plan for term in _INDEX_SCAN_TERMS) + assert any(index_name in plan for index_name in _DATETIME_INDEX_NAMES) + + +def _range_filter(dialect, column_name: str, start: str, end: str): + return LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, ">=", Column(dialect, column_name), Literal(dialect, start) + ), + ComparisonPredicate(dialect, "<", Column(dialect, column_name), Literal(dialect, end)), + ) + + +def _category_created_filter(dialect): + return LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, "=", Column(dialect, "category"), Literal(dialect, "deploy") + ), + _range_filter( + dialect, + "created_at", + "2026-02-20 00:00:00", + "2026-03-10 00:00:00", + ), + ) + + +def _datetime_expression_query(dialect): + return QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + extract(dialect, "year", Column(dialect, "created_at")).as_( + "created_year" + ), + date_trunc(dialect, "month", Column(dialect, "created_at")).as_( + "created_month" + ), + date_add(dialect, Column(dialect, "started_at"), 30, "minute").as_( + "starts_plus_30m" + ), + date_sub(dialect, Column(dialect, "ended_at"), 1, "hour").as_( + "ended_minus_1h" + ), + date_diff( + dialect, "minute", Column(dialect, "started_at"), Column(dialect, "ended_at") + ).as_("duration_minutes"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + order_by=OrderByClause( + dialect, + [(Column(dialect, "category"), "ASC"), (Column(dialect, "created_at"), "ASC")], + ), + ) + + +def _assert_temporal_rows(rows): + assert rows is not None + assert len(rows) == 3 + assert rows[0]["created_year"] in (2026, Decimal("2026")) + assert rows[0]["created_month"] is not None + assert rows[0]["starts_plus_30m"] is not None + assert rows[0]["ended_minus_1h"] is not None + assert round(float(rows[0]["duration_minutes"])) == 30 + + +class TestSyncPostgresDateTimeIntervalExplainExamples: + def test_created_at_range_explain_returns_temporal_plan( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + result = temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "created_at")], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_range_filter( + dialect, + "created_at", + "2026-02-20 00:00:00", + "2026-03-10 00:00:00", + ), + ) + ) + + assert isinstance(result, PostgresExplainResult) + assert len(result.rows) > 0 + plan = _combined_plan(result) + assert "EXPLAIN_TEMPORAL_EVENTS" in plan + assert "SCAN" in plan + + def test_datetime_range_index_is_available_when_seqscan_disabled( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + result = temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "category"), + Column(dialect, "created_at"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + ) + ) + finally: + temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(result) + _assert_datetime_index_used(plan) + assert result.is_index_used is True + assert result.is_full_scan is False + + def test_datetime_interval_expressions_work_with_indexed_filter( + self, temporal_indexed_backend + ): + dialect = temporal_indexed_backend.dialect + query = _datetime_expression_query(dialect) + temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + explain_result = temporal_indexed_backend.explain( + query + ) + finally: + temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(explain_result) + _assert_datetime_index_used(plan) + assert explain_result.is_index_used is True + + query_result = temporal_indexed_backend.execute( + *query.to_sql(), + options=_DQL_OPTIONS, + ) + _assert_temporal_rows(query_result.data) + + +class TestAsyncPostgresDateTimeIntervalExplainExamples: + @pytest.mark.asyncio + async def test_datetime_range_index_is_available_when_seqscan_disabled( + self, async_temporal_indexed_backend + ): + dialect = async_temporal_indexed_backend.dialect + await async_temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + result = await async_temporal_indexed_backend.explain( + QueryExpression( + dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "category"), + Column(dialect, "created_at"), + ], + from_=TableExpression(dialect, "explain_temporal_events"), + where=_category_created_filter(dialect), + ) + ) + finally: + await async_temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(result) + _assert_datetime_index_used(plan) + assert result.is_index_used is True + + @pytest.mark.asyncio + async def test_datetime_interval_expressions_work_with_indexed_filter( + self, async_temporal_indexed_backend + ): + dialect = async_temporal_indexed_backend.dialect + query = _datetime_expression_query(dialect) + await async_temporal_indexed_backend.execute("SET enable_seqscan = off") + try: + explain_result = await async_temporal_indexed_backend.explain( + query + ) + finally: + await async_temporal_indexed_backend.execute("RESET enable_seqscan") + + plan = _combined_plan(explain_result) + _assert_datetime_index_used(plan) + assert explain_result.is_index_used is True + + query_result = await async_temporal_indexed_backend.execute( + *query.to_sql(), + options=_DQL_OPTIONS, + ) + _assert_temporal_rows(query_result.data) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index bf683660..fe763442 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression # noqa: F401 from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -17,7 +17,7 @@ TableConstraint, TableConstraintType, ) -from typing import Tuple, Any +from typing import Tuple, Any # noqa: F401 @pytest.fixture @@ -69,7 +69,7 @@ def test_postgres_format_default_constraint_string_escaping(dialect): default_value="test's value", ) - sql, params = dialect._format_default_constraint(constraint) + sql, params = dialect.format_default_constraint(constraint) assert "test''s value" in sql assert "'; DROP" not in sql @@ -77,7 +77,7 @@ def test_postgres_format_default_constraint_string_escaping(dialect): def test_postgres_format_storage_options_string_escaping(dialect): """Test storage options string values are escaped.""" storage_opts = {"key": "value's"} - sql, params = dialect._format_storage_options(storage_opts) + sql, params = dialect.format_storage_options(storage_opts) assert "value''s" in sql assert "'; DROP" not in sql @@ -257,7 +257,7 @@ def test_create_index_with_string_where_clause(self, dialect): def test_create_index_with_to_sql_protocol(self, dialect): """Test CREATE INDEX with ToSQLProtocol expression (parameterized).""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression + from rhosocial.activerecord.backend.expression.bases import BaseExpression # noqa: F811 class MockWhereExpr(BaseExpression): def __init__(self): @@ -279,7 +279,7 @@ def to_sql(self): def test_create_index_where_clause_params_collected(self, dialect): """Test WHERE clause parameters are properly collected and returned.""" - from rhosocial.activerecord.backend.expression.bases import BaseExpression + from rhosocial.activerecord.backend.expression.bases import BaseExpression # noqa: F811 class MockWhereExpr(BaseExpression): def __init__(self): @@ -384,43 +384,51 @@ def test_drop_statistics_name_quoted(self, dialect): # ============================================================ -# _format_partition_value — single-quote escaping +# format_partition_value — single-quote escaping # ============================================================ +def _format_partition_value(dialect, value): + from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PartitionValue + + sql, params = dialect.format_partition_value(PartitionValue(dialect=dialect, value=value)) + assert params == () + return sql + + def test_partition_value_none(dialect): """None partition value returns NULL.""" - result = dialect._format_partition_value(None) + result = _format_partition_value(dialect, None) assert result == "NULL" def test_partition_value_maxvalue(dialect): """MAXVALUE is returned as-is (case-insensitive).""" - result = dialect._format_partition_value("MAXVALUE") + result = _format_partition_value(dialect, "MAXVALUE") assert result == "MAXVALUE" def test_partition_value_minvalue(dialect): """MINVALUE is returned as-is (case-insensitive).""" - result = dialect._format_partition_value("minvalue") + result = _format_partition_value(dialect, "minvalue") assert result == "MINVALUE" def test_partition_value_normal_string(dialect): """Normal string value is single-quoted.""" - result = dialect._format_partition_value("2024-01-01") + result = _format_partition_value(dialect, "2024-01-01") assert result == "'2024-01-01'" def test_partition_value_escaped_single_quote(dialect): """String value with single quote is properly escaped.""" - result = dialect._format_partition_value("it's") + result = _format_partition_value(dialect, "it's") assert result == "'it''s'" assert "'; DROP" not in result def test_partition_value_injection_blocked(dialect): """SQL injection in partition value is safely escaped (inside quotes).""" - result = dialect._format_partition_value("x'; DROP TABLE users--") + result = _format_partition_value(dialect, "x'; DROP TABLE users--") assert result.count("'") % 2 == 0 assert result.startswith("'") assert result.endswith("'") @@ -428,7 +436,7 @@ def test_partition_value_injection_blocked(dialect): def test_partition_value_integer(dialect): """Integer partition value is returned as str().""" - result = dialect._format_partition_value(42) + result = _format_partition_value(dialect, 42) assert result == "42" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 9474afa2..839cb1c0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -74,9 +74,16 @@ def get_own_protocol_methods(proto: type) -> set: def get_postgres_protocols(): """Get all protocols that PostgresDialect implements.""" - from typing import runtime_checkable + from typing import runtime_checkable # noqa: F401 protocols = [ + dialect_protocols.SQLXMLSupport, + dialect_protocols.SQLXMLParsingSupport, + dialect_protocols.SQLXMLSerializationSupport, + dialect_protocols.SQLXMLConstructionSupport, + dialect_protocols.SQLXMLAggregationSupport, + dialect_protocols.SQLXMLQueryingSupport, + dialect_protocols.CollationSupport, dialect_protocols.CTESupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, @@ -101,6 +108,7 @@ def get_postgres_protocols(): dialect_protocols.IndexSupport, dialect_protocols.SequenceSupport, dialect_protocols.TableSupport, + dialect_protocols.PartitionSupport, dialect_protocols.ConstraintSupport, dialect_protocols.TruncateSupport, dialect_protocols.IntrospectionSupport, @@ -159,12 +167,25 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresIndexSupport', 'IndexSupport'), ('TableSupport', 'PostgresTableSupport'), ('PostgresTableSupport', 'TableSupport'), + ('PartitionSupport', 'PostgresPartitionSupport'), + ('PostgresPartitionSupport', 'PartitionSupport'), ('ConstraintSupport', 'PostgresConstraintSupport'), ('PostgresConstraintSupport', 'ConstraintSupport'), ('LockingSupport', 'PostgresLockingSupport'), ('PostgresLockingSupport', 'LockingSupport'), ('TriggerSupport', 'PostgresTriggerSupport'), ('PostgresTriggerSupport', 'TriggerSupport'), + # SQLXMLSupport aggregates standard SQL/XML capability-family protocols + ('SQLXMLSupport', 'SQLXMLParsingSupport'), + ('SQLXMLParsingSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLSerializationSupport'), + ('SQLXMLSerializationSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLConstructionSupport'), + ('SQLXMLConstructionSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLAggregationSupport'), + ('SQLXMLAggregationSupport', 'SQLXMLSupport'), + ('SQLXMLSupport', 'SQLXMLQueryingSupport'), + ('SQLXMLQueryingSupport', 'SQLXMLSupport'), # PG enhanced protocols extend generic protocols with same method names ('JSONSupport', 'PostgresJSONBEnhancedSupport'), ('PostgresJSONBEnhancedSupport', 'JSONSupport'), @@ -180,8 +201,58 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresIndexSupport', 'PostgresExtendedStatisticsSupport'), ('PostgresParallelQuerySupport', 'PostgresQueryOptimizationSupport'), ('PostgresQueryOptimizationSupport', 'PostgresParallelQuerySupport'), - ('PostgresSQLSyntaxSupport', 'PostgresStoredProcedureSupport'), - ('PostgresStoredProcedureSupport', 'PostgresSQLSyntaxSupport'), + # Per-feature protocol overlaps (override base protocols) + ('CollationSupport', 'PostgresCollationSupport'), + ('PostgresCollationSupport', 'CollationSupport'), + ('CTESupport', 'PostgresCTESupport'), + ('PostgresCTESupport', 'CTESupport'), + ('FilterClauseSupport', 'PostgresFilterSupport'), + ('PostgresFilterSupport', 'FilterClauseSupport'), + ('WindowFunctionSupport', 'PostgresWindowSupport'), + ('PostgresWindowSupport', 'WindowFunctionSupport'), + ('ReturningSupport', 'PostgresReturningSupport'), + ('PostgresReturningSupport', 'ReturningSupport'), + ('AdvancedGroupingSupport', 'PostgresGroupingSupport'), + ('PostgresGroupingSupport', 'AdvancedGroupingSupport'), + ('ExplainSupport', 'PostgresExplainSupport'), + ('PostgresExplainSupport', 'ExplainSupport'), + ('MergeSupport', 'PostgresMergeSupport'), + ('PostgresMergeSupport', 'MergeSupport'), + ('OrderedSetAggregationSupport', 'PostgresOrderedSetAggSupport'), + ('PostgresOrderedSetAggSupport', 'OrderedSetAggregationSupport'), + ('UpsertSupport', 'PostgresUpsertSupport'), + ('PostgresUpsertSupport', 'UpsertSupport'), + ('LateralJoinSupport', 'PostgresLateralJoinSupport'), + ('PostgresLateralJoinSupport', 'LateralJoinSupport'), + ('JoinSupport', 'PostgresJoinSupport'), + ('PostgresJoinSupport', 'JoinSupport'), + ('SetOperationSupport', 'PostgresSetOperationSupport'), + ('PostgresSetOperationSupport', 'SetOperationSupport'), + ('ViewSupport', 'PostgresViewSupport'), + ('PostgresViewSupport', 'ViewSupport'), + ('SchemaSupport', 'PostgresSchemaSupport'), + ('PostgresSchemaSupport', 'SchemaSupport'), + ('SequenceSupport', 'PostgresSequenceSupport'), + ('PostgresSequenceSupport', 'SequenceSupport'), + ('TruncateSupport', 'PostgresTruncateSupport'), + ('PostgresTruncateSupport', 'TruncateSupport'), + ('TransactionControlSupport', 'PostgresTransactionSupport'), + ('PostgresTransactionSupport', 'TransactionControlSupport'), + ('SQLXMLSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLSupport'), + ('SQLXMLParsingSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLParsingSupport'), + ('SQLXMLSerializationSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLSerializationSupport'), + ('SQLXMLConstructionSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLConstructionSupport'), + ('SQLXMLAggregationSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLAggregationSupport'), + ('SQLXMLQueryingSupport', 'PostgresXMLSupport'), + ('PostgresXMLSupport', 'SQLXMLQueryingSupport'), + # PostgresFeaturesSupport shares some stored procedure methods + ('PostgresFeaturesSupport', 'PostgresStoredProcedureSupport'), + ('PostgresStoredProcedureSupport', 'PostgresFeaturesSupport'), } violations = [] @@ -207,6 +278,7 @@ class TestPostgresProtocolDerivation: PROTOCOL_DERIVATIONS = [ ("PostgresTableSupport", "TableSupport"), + ("PostgresPartitionSupport", "PartitionSupport"), ("PostgresIndexSupport", "IndexSupport"), ("PostgresLockingSupport", "LockingSupport"), ("PostgresTriggerSupport", "TriggerSupport"), @@ -264,8 +336,10 @@ class TestPostgresExpressionDialectSeparation: ("PostgresCreatePartitionExpression", "format_create_partition_statement"), ("PostgresDetachPartitionExpression", "format_detach_partition_statement"), ("PostgresAttachPartitionExpression", "format_attach_partition_statement"), + ("PostgresPartitionMetadataExpression", "format_partition_metadata_query"), ("PostgresCreateStatisticsExpression", "format_create_statistics_statement"), ("PostgresDropStatisticsExpression", "format_drop_statistics_statement"), + ("PostgresAlterIndexExpression", "format_alter_index_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -328,9 +402,30 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresArrayEnhancedSupport, postgres_mixins.PostgresArrayEnhancedMixin), # Additional protocols (postgres_protocols.PostgresQueryOptimizationSupport, postgres_mixins.PostgresQueryOptimizationMixin), - (postgres_protocols.PostgresSQLSyntaxSupport, postgres_mixins.PostgresSQLSyntaxMixin), (postgres_protocols.PostgresLogicalReplicationSupport, postgres_mixins.PostgresLogicalReplicationMixin), (postgres_protocols.PostgresParallelQuerySupport, postgres_mixins.PostgresParallelQueryMixin), + # Per-feature protocols + (postgres_protocols.PostgresCTESupport, postgres_mixins.PostgresCTEMixin), + (postgres_protocols.PostgresWindowSupport, postgres_mixins.PostgresWindowMixin), + (postgres_protocols.PostgresFilterSupport, postgres_mixins.PostgresFilterMixin), + (postgres_protocols.PostgresReturningSupport, postgres_mixins.PostgresReturningMixin), + (postgres_protocols.PostgresGroupingSupport, postgres_mixins.PostgresGroupingMixin), + (postgres_protocols.PostgresExplainSupport, postgres_mixins.PostgresExplainMixin), + (postgres_protocols.PostgresMergeSupport, postgres_mixins.PostgresMergeMixin), + (postgres_protocols.PostgresUpsertSupport, postgres_mixins.PostgresUpsertMixin), + (postgres_protocols.PostgresLateralJoinSupport, postgres_mixins.PostgresLateralJoinMixin), + (postgres_protocols.PostgresSetOperationSupport, postgres_mixins.PostgresSetOperationMixin), + (postgres_protocols.PostgresILIKESupport, postgres_mixins.PostgresILIKEMixin), + (postgres_protocols.PostgresJoinSupport, postgres_mixins.PostgresJoinMixin), + (postgres_protocols.PostgresTruncateSupport, postgres_mixins.PostgresTruncateMixin), + (postgres_protocols.PostgresSchemaSupport, postgres_mixins.PostgresSchemaMixin), + (postgres_protocols.PostgresSequenceSupport, postgres_mixins.PostgresSequenceMixin), + (postgres_protocols.PostgresTransactionSupport, postgres_mixins.PostgresTransactionMixin), + (postgres_protocols.PostgresViewSupport, postgres_mixins.PostgresViewMixin), + (postgres_protocols.PostgresXMLSupport, postgres_mixins.PostgresXMLMixin), + (postgres_protocols.PostgresCollationSupport, postgres_mixins.PostgresCollationMixin), + (postgres_protocols.PostgresOrderedSetAggSupport, postgres_mixins.PostgresOrderedSetAggMixin), + (postgres_protocols.PostgresFeaturesSupport, postgres_mixins.PostgresFeaturesMixin), # Extension protocols (postgres_protocols.PostgresPgvectorSupport, postgres_mixins.PostgresPgvectorMixin), (postgres_protocols.PostgresPostGISSupport, postgres_mixins.PostgresPostGISMixin), @@ -351,6 +446,17 @@ def test_expression_delegates_to_dialect(self, expr_name, format_method): (postgres_protocols.PostgresBloomSupport, postgres_mixins.PostgresBloomMixin), (postgres_protocols.PostgresBtreeGinSupport, postgres_mixins.PostgresBtreeGinMixin), (postgres_protocols.PostgresBtreeGistSupport, postgres_mixins.PostgresBtreeGistMixin), + # Unpaired extension protocols + (postgres_protocols.PostgresAddressStandardizerSupport, postgres_mixins.PostgresAddressStandardizerMixin), + (postgres_protocols.PostgresHypoPgSupport, postgres_mixins.PostgresHypoPgMixin), + (postgres_protocols.PostgresOrafceSupport, postgres_mixins.PostgresOrafceMixin), + (postgres_protocols.PostgresPgCronSupport, postgres_mixins.PostgresPgCronMixin), + (postgres_protocols.PostgresPgLogicalSupport, postgres_mixins.PostgresPgLogicalMixin), + (postgres_protocols.PostgresPgPartmanSupport, postgres_mixins.PostgresPgPartmanMixin), + (postgres_protocols.PostgresPgRepackSupport, postgres_mixins.PostgresPgRepackMixin), + (postgres_protocols.PostgresPgSurgerySupport, postgres_mixins.PostgresPgSurgeryMixin), + (postgres_protocols.PostgresPgWalinspectSupport, postgres_mixins.PostgresPgWalinspectMixin), + (postgres_protocols.PostgresPgauditSupport, postgres_mixins.PostgresPgauditMixin), ] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py index 969db48b..19325d28 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py @@ -16,11 +16,11 @@ - SKIP LOCKED: PostgreSQL 9.5+ """ import pytest -import pytest_asyncio +import pytest_asyncio # noqa: F401 +from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( LockStrength, - PostgresForUpdateClause, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -62,13 +62,13 @@ def test_supports_for_key_share(self, postgres_backend_single): assert dialect.supports_for_key_share() is False -class TestPostgresForUpdateClauseFormatting: +class TestForUpdateClauseFormatting: """Test FOR UPDATE clause SQL formatting.""" def test_for_update_basic(self, postgres_backend_single): """Test basic FOR UPDATE clause.""" dialect = postgres_backend_single.dialect - clause = PostgresForUpdateClause(dialect) + clause = ForUpdateClause(dialect) sql, params = clause.to_sql() assert sql == "FOR UPDATE" assert params == () @@ -79,7 +79,7 @@ def test_for_share_basic(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) sql, params = clause.to_sql() assert sql == "FOR SHARE" assert params == () @@ -90,7 +90,7 @@ def test_for_no_key_update_basic(self, postgres_backend_single): if not dialect.supports_for_no_key_update(): pytest.skip("FOR NO KEY UPDATE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.NO_KEY_UPDATE) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.NO_KEY_UPDATE}) sql, params = clause.to_sql() assert sql == "FOR NO KEY UPDATE" assert params == () @@ -101,7 +101,7 @@ def test_for_key_share_basic(self, postgres_backend_single): if not dialect.supports_for_key_share(): pytest.skip("FOR KEY SHARE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.KEY_SHARE) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.KEY_SHARE}) sql, params = clause.to_sql() assert sql == "FOR KEY SHARE" assert params == () @@ -109,7 +109,7 @@ def test_for_key_share_basic(self, postgres_backend_single): def test_for_update_with_nowait(self, postgres_backend_single): """Test FOR UPDATE with NOWAIT.""" dialect = postgres_backend_single.dialect - clause = PostgresForUpdateClause(dialect, nowait=True) + clause = ForUpdateClause(dialect, nowait=True) sql, params = clause.to_sql() assert sql == "FOR UPDATE NOWAIT" assert params == () @@ -120,7 +120,7 @@ def test_for_share_with_nowait(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, nowait=True) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, nowait=True) sql, params = clause.to_sql() assert sql == "FOR SHARE NOWAIT" assert params == () @@ -131,7 +131,7 @@ def test_for_update_with_skip_locked(self, postgres_backend_single): if not dialect.supports_for_update_skip_locked(): pytest.skip("SKIP LOCKED not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, skip_locked=True) + clause = ForUpdateClause(dialect, skip_locked=True) sql, params = clause.to_sql() assert sql == "FOR UPDATE SKIP LOCKED" assert params == () @@ -144,7 +144,7 @@ def test_for_share_with_skip_locked(self, postgres_backend_single): if not dialect.supports_for_update_skip_locked(): pytest.skip("SKIP LOCKED not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, skip_locked=True) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, skip_locked=True) sql, params = clause.to_sql() assert sql == "FOR SHARE SKIP LOCKED" assert params == () @@ -152,7 +152,7 @@ def test_for_share_with_skip_locked(self, postgres_backend_single): def test_for_update_with_of_columns(self, postgres_backend_single): """Test FOR UPDATE with OF columns.""" dialect = postgres_backend_single.dialect - clause = PostgresForUpdateClause(dialect, of_columns=["users", "orders"]) + clause = ForUpdateClause(dialect, of_columns=["users", "orders"]) sql, params = clause.to_sql() assert sql == 'FOR UPDATE OF "users", "orders"' assert params == () @@ -163,7 +163,7 @@ def test_for_share_with_of_columns(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE, of_columns=["users"]) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, of_columns=["users"]) sql, params = clause.to_sql() assert sql == 'FOR SHARE OF "users"' assert params == () @@ -177,7 +177,7 @@ def test_unsupported_lock_strength_raises_error(self, postgres_backend_single): # Skip if this version actually supports it pytest.skip("This test requires a PostgreSQL version < 9.0") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) with pytest.raises(UnsupportedFeatureError): clause.to_sql() @@ -202,7 +202,7 @@ async def test_async_supports_for_share(self, async_postgres_backend_single): async def test_async_for_update_basic(self, async_postgres_backend_single): """Test basic FOR UPDATE clause (async).""" dialect = async_postgres_backend_single.dialect - clause = PostgresForUpdateClause(dialect) + clause = ForUpdateClause(dialect) sql, params = clause.to_sql() assert sql == "FOR UPDATE" assert params == () @@ -213,7 +213,7 @@ async def test_async_for_share_basic(self, async_postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = PostgresForUpdateClause(dialect, strength=LockStrength.SHARE) + clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) sql, params = clause.to_sql() assert sql == "FOR SHARE" assert params == () \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_type_compatibility.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_type_compatibility.py index ec30e242..a784aaf8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_type_compatibility.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_type_compatibility.py @@ -4,13 +4,13 @@ This module tests the extended type compatibility checking functionality including warnings and suggestions. """ -import pytest +import pytest # noqa: F401 import warnings from rhosocial.activerecord.backend.impl.postgres.type_compatibility import ( WARNED_CASTS, INTERMEDIATE_SUGGESTIONS, - TYPE_CATEGORIES, + TYPE_CATEGORIES, # noqa: F401 check_cast_compatibility, get_type_category, get_intermediate_type, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version.py index 529542ed..87b9d09a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version.py @@ -1,6 +1,6 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/test_version.py import pytest -import pytest_asyncio +import pytest_asyncio # noqa: F401 from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version_features.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version_features.py index 5d924633..db52d62b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version_features.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_version_features.py @@ -4,7 +4,7 @@ This module tests the version-specific feature detection methods added to the PostgreSQL dialect mixins. """ -import pytest +import pytest # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_async_transaction_backend.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_async_transaction_backend.py index 32a6f12f..3ac01641 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_async_transaction_backend.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_async_transaction_backend.py @@ -74,7 +74,7 @@ async def test_nested_transaction_rollback_inner(async_postgres_backend, setup_t try: async with async_postgres_backend.transaction(): - await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 50)) + await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 50)) # noqa: E501 raise ValueError("Rollback inner transaction") except ValueError: pass @@ -92,7 +92,7 @@ async def test_nested_transaction_rollback_outer(async_postgres_backend, setup_t await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("outer", 60)) async with async_postgres_backend.transaction(): - await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 70)) + await async_postgres_backend.execute("INSERT INTO test_table (name, age) VALUES (%s, %s)", ("inner", 70)) # noqa: E501 raise ValueError("Rollback outer transaction") except ValueError: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_advanced_features.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_advanced_features.py index 7d2f485e..1cf34cab 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_advanced_features.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_advanced_features.py @@ -8,7 +8,7 @@ import pytest_asyncio from decimal import Decimal -from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode +from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode # noqa: F401 class TestSyncDeferrableTransaction: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_deferrable.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_deferrable.py index a067b7a6..1194d58b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_deferrable.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_deferrable.py @@ -8,7 +8,7 @@ import pytest_asyncio from decimal import Decimal -from rhosocial.activerecord.backend.errors import TransactionError +from rhosocial.activerecord.backend.errors import TransactionError # noqa: F401 class TestSyncTransactionDeferrable: @@ -220,7 +220,7 @@ async def test_async_get_deferred_constraints(self, async_postgres_backend, asyn assert "async_deferred_fk" in deferred @pytest.mark.asyncio - async def test_async_deferred_constraints_cleared_after_commit(self, async_postgres_backend, async_test_table_with_deferrable_fk): + async def test_async_deferred_constraints_cleared_after_commit(self, async_postgres_backend, async_test_table_with_deferrable_fk): # noqa: E501 """Test that deferred constraints list is cleared after commit (async).""" tx_manager = async_postgres_backend.transaction_manager @@ -240,7 +240,7 @@ async def test_async_deferred_constraints_cleared_after_commit(self, async_postg assert len(tx_manager.get_deferred_constraints()) == 0 @pytest.mark.asyncio - async def test_async_deferred_constraints_cleared_after_rollback(self, async_postgres_backend, async_test_table_with_deferrable_fk): + async def test_async_deferred_constraints_cleared_after_rollback(self, async_postgres_backend, async_test_table_with_deferrable_fk): # noqa: E501 """Test that deferred constraints list is cleared after rollback (async).""" tx_manager = async_postgres_backend.transaction_manager diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_isolation.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_isolation.py index e1dfb5bb..4952ecf8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_isolation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/transaction/test_transaction_isolation.py @@ -184,7 +184,7 @@ async def test_async_set_serializable_isolation_level(self, async_postgres_backe assert len(rows) == 1 @pytest.mark.asyncio - async def test_async_cannot_change_isolation_during_active_transaction(self, async_postgres_backend, async_test_table): + async def test_async_cannot_change_isolation_during_active_transaction(self, async_postgres_backend, async_test_table): # noqa: E501 """Test that isolation level cannot be changed during active transaction (async).""" tx_manager = async_postgres_backend.transaction_manager tx_manager.isolation_level = IsolationLevel.READ_COMMITTED diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py index 4cb69dea..92417f78 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py @@ -5,5 +5,5 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.basic.conftest import * +from rhosocial.activerecord.testsuite.feature.basic.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py index f92b1c1d..d3354c8f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/conftest.py @@ -5,4 +5,4 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import * +from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py index cdd54c73..8f7e8cf3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py @@ -6,9 +6,9 @@ ensuring PostgreSQL backend compatibility for connection pool context awareness. """ from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import ( - sync_pool_and_model, - async_pool_and_model, + sync_pool_and_model, # noqa: F401 + async_pool_and_model, # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context import * +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py index 61731018..eb4e162b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py @@ -6,9 +6,9 @@ ensuring PostgreSQL backend compatibility for connection pool CRUD operations. """ from rhosocial.activerecord.testsuite.feature.basic.connection.conftest import ( - sync_pool_for_crud, - async_pool_for_crud, + sync_pool_for_crud, # noqa: F401 + async_pool_for_crud, # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud import * +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/__init__.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/__init__.py deleted file mode 100644 index 73b154af..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/__init__.py -"""PostgreSQL-specific test fixtures.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py index ddd0b5ed..5c05e000 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models.py @@ -7,11 +7,11 @@ - Uses native JSONB for JSON fields """ import json -from datetime import datetime -from decimal import Decimal +from datetime import datetime # noqa: F401 +from decimal import Decimal # noqa: F401 from typing import ClassVar, Dict, List, Optional, Any -from pydantic import Field +from pydantic import Field # noqa: F401 from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.base.field_proxy import FieldProxy @@ -31,12 +31,12 @@ def __init__(self): super().__init__() self._register_type(dict, str) - def _do_to_database(self, value: Dict, target_type: type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: + def _do_to_database(self, value: Dict, target_type: type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: # noqa: E501 if value is None: return None return json.dumps(value) - def _do_from_database(self, value: str, target_type: type, options: Optional[Dict[str, Any]] = None) -> Optional[Dict]: + def _do_from_database(self, value: str, target_type: type, options: Optional[Dict[str, Any]] = None) -> Optional[Dict]: # noqa: E501 if value is None: return None if isinstance(value, dict): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/bulk_users.sql b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/bulk_users.sql new file mode 100644 index 00000000..0e765626 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/schema/bulk_users.sql @@ -0,0 +1,6 @@ +CREATE TABLE bulk_users ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + age INTEGER DEFAULT 0, + email VARCHAR(255) DEFAULT '' +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py index ef61fd01..9c3e81ba 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py @@ -12,7 +12,7 @@ # by the provider in the current backend (`tests/providers/basic.py`). # The `basic_model_fixtures` is a fixture that provides the necessary setup for the tests. -from rhosocial.activerecord.testsuite.feature.basic.conftest import mapped_models_fixtures +from rhosocial.activerecord.testsuite.feature.basic.conftest import mapped_models_fixtures # noqa: F401 # The `TestBasicMappedModels` class contains the actual test cases. -from rhosocial.activerecord.testsuite.feature.basic.test_example_basic_fixtures import * +from rhosocial.activerecord.testsuite.feature.basic.test_example_basic_fixtures import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py new file mode 100644 index 00000000..e0936eda --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py @@ -0,0 +1,11 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py +""" +Bridge file for bulk operations tests from the testsuite. +""" + +from rhosocial.activerecord.testsuite.feature.basic.conftest import ( + bulk_user_class, # noqa: F401 + async_bulk_user_class, # noqa: F401 +) + +from rhosocial.activerecord.testsuite.feature.basic.test_bulk_operations import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py index a4c1b714..c434da79 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py @@ -15,16 +15,16 @@ # in some IDEs. These fixtures are defined in the testsuite package and are # parameterized to run against the scenarios defined in `providers/scenarios.py`. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - user_class, - type_case_class, - type_test_model, - validated_user_class, - validated_user, - async_user_class, - async_type_case_class, - async_validated_user_class, - async_validated_user + user_class, # noqa: F401 + type_case_class, # noqa: F401 + type_test_model, # noqa: F401 + validated_user_class, # noqa: F401 + validated_user, # noqa: F401 + async_user_class, # noqa: F401 + async_type_case_class, # noqa: F401 + async_validated_user_class, # noqa: F401 + async_validated_user # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_crud import * +from rhosocial.activerecord.testsuite.feature.basic.test_crud import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py index cfb83205..e5503f88 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py @@ -13,11 +13,11 @@ # The `mapped_models_fixtures` is a fixture that provides the necessary setup for the tests. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - mapped_models_fixtures, - mixed_models_fixtures, - async_mapped_models_fixtures, - async_mixed_models_fixtures, + mapped_models_fixtures, # noqa: F401 + mixed_models_fixtures, # noqa: F401 + async_mapped_models_fixtures, # noqa: F401 + async_mixed_models_fixtures, # noqa: F401 ) # The test classes contain the actual test cases. -from rhosocial.activerecord.testsuite.feature.basic.test_field_column_mapping import * +from rhosocial.activerecord.testsuite.feature.basic.test_field_column_mapping import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py index eedbd7e4..ed3bdd00 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py @@ -15,9 +15,9 @@ # in some IDEs. These fixtures are defined in the testsuite package and are # parameterized to run against the scenarios defined in `providers/scenarios.py`. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - type_test_model, - async_type_test_model + type_test_model, # noqa: F401 + async_type_test_model # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_fields import * +from rhosocial.activerecord.testsuite.feature.basic.test_fields import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py index a1581ecb..752b0a1b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py @@ -9,8 +9,8 @@ # IMPORTANT: These imports are required for pytest discovery of testsuite # fixtures and shared test classes. Do not remove them as unused imports. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - async_pydantic_validated_model, - pydantic_validated_model, + async_pydantic_validated_model, # noqa: F401 + pydantic_validated_model, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.test_pydantic_native_validation import * +from rhosocial.activerecord.testsuite.feature.basic.test_pydantic_native_validation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py index 8ad07393..f9841654 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py @@ -8,9 +8,9 @@ # Import the fixture that provides the configured model and backend for type adapter tests. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - type_adapter_fixtures, - async_type_adapter_fixtures, + type_adapter_fixtures, # noqa: F401 + async_type_adapter_fixtures, # noqa: F401 ) # Import all tests from the generic testsuite file. -from rhosocial.activerecord.testsuite.feature.basic.test_type_adapter import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.basic.test_type_adapter import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py index 69500ed4..b522ba20 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py @@ -15,9 +15,9 @@ # in some IDEs. These fixtures are defined in the testsuite package and are # parameterized to run against the scenarios defined in `providers/scenarios.py`. from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - validated_user, - async_validated_user + validated_user, # noqa: F401 + async_validated_user # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_validation import * +from rhosocial.activerecord.testsuite.feature.basic.test_validation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py index 522a22fc..4486e093 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py @@ -5,6 +5,6 @@ Imports tests from testsuite and makes them discoverable by pytest. """ from rhosocial.activerecord.testsuite.feature.basic.worker.conftest import ( - user_class_for_worker, + user_class_for_worker, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.worker.test_connection_management import * +from rhosocial.activerecord.testsuite.feature.basic.worker.test_connection_management import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py index 734b11b3..c75a4e8c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py @@ -5,7 +5,7 @@ Imports tests from testsuite and makes them discoverable by pytest. """ from rhosocial.activerecord.testsuite.feature.basic.worker.conftest import ( - user_class_for_worker, - async_user_class_for_worker, + user_class_for_worker, # noqa: F401 + async_user_class_for_worker, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.worker.test_parallel_crud import * +from rhosocial.activerecord.testsuite.feature.basic.worker.test_parallel_crud import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py index af80c997..53d0a0d0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py @@ -5,6 +5,6 @@ Imports tests from testsuite and makes them discoverable by pytest. """ from rhosocial.activerecord.testsuite.feature.basic.worker.conftest import ( - user_class_for_worker, + user_class_for_worker, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.worker.test_worker_pool_lifecycle import * +from rhosocial.activerecord.testsuite.feature.basic.worker.test_worker_pool_lifecycle import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py index 746effed..0d4b8bd2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py @@ -79,7 +79,7 @@ def _load_scenarios_from_config(): register_scenario(scenario_name, config) except ImportError: - raise ImportError("PyYAML is required to load scenario configuration files") + raise ImportError("PyYAML is required to load scenario configuration files") # noqa: B904 _load_scenarios_from_config() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py index ba6bd101..a3b41ee4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py @@ -13,13 +13,13 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Dict, Any, Tuple -from datetime import datetime +from datetime import datetime # noqa: F401 import pytest -import pytest_asyncio +import pytest_asyncio # noqa: F401 from rhosocial.activerecord.connection.pool import ( - PoolConfig, + PoolConfig, # noqa: F401 BackendPool, AsyncBackendPool, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/events/conftest.py index d30b91a9..412a154d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/events/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/conftest.py @@ -5,4 +5,4 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.events.conftest import * +from rhosocial.activerecord.testsuite.feature.events.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py index 7f6036c8..02d222d2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py @@ -15,9 +15,9 @@ # in some IDEs. These fixtures are defined in the testsuite package and are # parameterized to run against the scenarios defined in `providers/scenarios.py`. from rhosocial.activerecord.testsuite.feature.events.conftest import ( - event_model, - event_tracking_model, # Assuming event_tracking_model is also needed here + event_model, # noqa: F401 + event_tracking_model, # Assuming event_tracking_model is also needed here # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.events.test_handlers import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.events.test_handlers import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py index d8dc61b3..00b29520 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py @@ -15,9 +15,9 @@ # in some IDEs. These fixtures are defined in the testsuite package and are # parameterized to run against the scenarios defined in `providers/scenarios.py`. from rhosocial.activerecord.testsuite.feature.events.conftest import ( - event_model, - event_tracking_model, # Assuming event_tracking_model is also needed here + event_model, # noqa: F401 + event_tracking_model, # Assuming event_tracking_model is also needed here # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.events.test_lifecycle import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.events.test_lifecycle import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/conftest.py index 83cdf011..afd426f8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/conftest.py @@ -5,4 +5,4 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.mixins.conftest import * +from rhosocial.activerecord.testsuite.feature.mixins.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models.py index c9e68569..1d712f0d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models.py @@ -8,7 +8,7 @@ configuring them with a live database connection at test time. """ from typing import Optional -from datetime import datetime +from datetime import datetime # noqa: F401 from pydantic import Field diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py index e80e3ad5..a63c2d24 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py @@ -6,4 +6,4 @@ ensuring PostgreSQL backend compatibility. """ # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.mixins.test_combined_articles import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.mixins.test_combined_articles import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py index 9a595326..9fd9601a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py @@ -6,4 +6,4 @@ ensuring PostgreSQL backend compatibility. """ # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.mixins.test_optimistic_lock import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.mixins.test_optimistic_lock import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py index cc66c4ed..e440e286 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py @@ -6,4 +6,4 @@ ensuring PostgreSQL backend compatibility. """ # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.mixins.test_soft_delete import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.mixins.test_soft_delete import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py index fccdb994..d776bdbf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py @@ -6,4 +6,4 @@ ensuring PostgreSQL backend compatibility. """ # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.mixins.test_timestamps import * \ No newline at end of file +from rhosocial.activerecord.testsuite.feature.mixins.test_timestamps import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/query/conftest.py index 49dfe193..070acde9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/conftest.py @@ -5,4 +5,4 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.query.conftest import * +from rhosocial.activerecord.testsuite.feature.query.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py index 86492b05..e3dcb692 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/conftest.py @@ -5,4 +5,4 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.query.connection.conftest import * +from rhosocial.activerecord.testsuite.feature.query.connection.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py index 4363b19d..403c1a9e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py @@ -6,9 +6,9 @@ ensuring PostgreSQL backend compatibility for ActiveQuery connection pool context awareness. """ from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( - sync_pool_and_model, - async_pool_and_model, + sync_pool_and_model, # noqa: F401 + async_pool_and_model, # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context import * +from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py index 94bfc1f9..1483e319 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py @@ -6,9 +6,9 @@ ensuring PostgreSQL backend compatibility for CTEQuery connection pool context awareness. """ from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( - sync_pool_and_model, - async_pool_and_model, + sync_pool_and_model, # noqa: F401 + async_pool_and_model, # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context import * +from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py index 3eef9b3e..f6acecbf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py @@ -6,9 +6,9 @@ ensuring PostgreSQL backend compatibility for SetOperationQuery connection pool context awareness. """ from rhosocial.activerecord.testsuite.feature.query.connection.conftest import ( - sync_pool_and_model, - async_pool_and_model, + sync_pool_and_model, # noqa: F401 + async_pool_and_model, # noqa: F401 ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context import * +from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/schema/profiles.sql b/tests/rhosocial/activerecord_postgres_test/feature/query/schema/profiles.sql new file mode 100644 index 00000000..6b40e508 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/schema/profiles.sql @@ -0,0 +1,10 @@ +-- tests/rhosocial/activerecord_postgres_test/feature/query/schema/profiles.sql +-- Schema for Profile model +CREATE TABLE profiles ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + bio TEXT, + avatar_url VARCHAR(512), + created_at TIMESTAMP(6), + updated_at TIMESTAMP(6) +); diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py index 007e1a14..75f400d0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_aggregate import * +from rhosocial.activerecord.testsuite.feature.query.test_active_query_aggregate import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py index 10e13466..c8e67b7d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_basic import * +from rhosocial.activerecord.testsuite.feature.query.test_active_query_basic import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py index 0831fe27..4f750e5b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_join import * +from rhosocial.activerecord.testsuite.feature.query.test_active_query_join import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py index 838e33f8..af663f25 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_range import * +from rhosocial.activerecord.testsuite.feature.query.test_active_query_range import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py index e83b7d0d..04171825 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_set_operation import * +from rhosocial.activerecord.testsuite.feature.query.test_active_query_set_operation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py index 80185914..12c99783 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_aggregate_queries import * +from rhosocial.activerecord.testsuite.feature.query.test_aggregate_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py index 686f99cb..ef4556d1 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_async_queries import * +from rhosocial.activerecord.testsuite.feature.query.test_async_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py index 8bcb95eb..f56b9765 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_basic import * +from rhosocial.activerecord.testsuite.feature.query.test_basic import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py new file mode 100644 index 00000000..93b67fbd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py @@ -0,0 +1,69 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py +""" +Tests for expression-level COLLATE support on PostgreSQL. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.impl.postgres import PostgresCollation, PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresCollationExpression: + def test_column_collate_generates_sql(self, dialect): + expr = Column(dialect, "name", table="users").collate(PostgresCollation.C) + + sql, params = expr.to_sql() + + assert sql == '"users"."name" COLLATE "C"' + assert params == () + + def test_literal_collate_preserves_parameter_binding(self, dialect): + expr = Literal(dialect, "Alice").collate(PostgresCollation.POSIX) + + sql, params = expr.to_sql() + + assert sql == '%s COLLATE "POSIX"' + assert params == ("Alice",) + + def test_schema_qualified_collation_generates_sql(self, dialect): + expr = Column(dialect, "name").collate(PostgresCollation.C.value, schema="pg_catalog") + + sql, params = expr.to_sql() + + assert sql == '"name" COLLATE "pg_catalog"."C"' + assert params == () + + def test_rejects_unsupported_collation(self, dialect): + expr = Column(dialect, "name").collate("unknown_ci") + + with pytest.raises(ValueError, match="Unsupported PostgreSQL collation"): + expr.to_sql() + + def test_rejects_version_sensitive_collation_on_older_version(self): + dialect = PostgresDialect(version=(9, 6, 0)) + expr = Column(dialect, "name").collate(PostgresCollation.UND_X_ICU) + + with pytest.raises(ValueError, match="requires PostgreSQL 10.0"): + expr.to_sql() + + def test_collate_executes_order_by(self, annotated_query_fixtures): + SearchableItem = ( + annotated_query_fixtures[0] + if isinstance(annotated_query_fixtures, tuple) + else annotated_query_fixtures + ) + SearchableItem(name="b", tags=[]).save() + SearchableItem(name="A", tags=[]).save() + SearchableItem(name="c", tags=[]).save() + + results = SearchableItem.query().order_by( + SearchableItem.c.name.collate(PostgresCollation.C) + ).all() + + assert [item.name for item in results] == ["A", "b", "c"] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py index b7cd841b..ce5c074a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cross_database_compatibility import * +from rhosocial.activerecord.testsuite.feature.query.test_cross_database_compatibility import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py index ed539837..1ce77f68 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cte_query_active_query import * +from rhosocial.activerecord.testsuite.feature.query.test_cte_query_active_query import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py index c07229ae..2610511f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cte_query_set_operation import * +from rhosocial.activerecord.testsuite.feature.query.test_cte_query_set_operation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py new file mode 100644 index 00000000..2c23a774 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py +""" +Bridge file for eager loading caching tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_caching import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py new file mode 100644 index 00000000..c90a39ef --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py +""" +Bridge file for eager loading nested tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_nested import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py new file mode 100644 index 00000000..37b45c60 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py +""" +Bridge file for async eager loading scenario tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_scenarios_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py new file mode 100644 index 00000000..ff5df366 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py +""" +Bridge file for sync eager loading scenario tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_scenarios_sync import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py new file mode 100644 index 00000000..c298cef3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py +""" +Bridge file for eager loading with_all tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_all import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py new file mode 100644 index 00000000..433a7051 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py +""" +Bridge file for eager loading with_modifier tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_modifier import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py new file mode 100644 index 00000000..df162324 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py +""" +Bridge file for eager loading with_one tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_one import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py index 3142622c..d30ddc60 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_error_handling import * +from rhosocial.activerecord.testsuite.feature.query.test_error_handling import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py index 4ca9877d..79aa6592 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_example_query_fixtures import * +from rhosocial.activerecord.testsuite.feature.query.test_example_query_fixtures import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py index 1c48a678..56a0a911 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_joins import * +from rhosocial.activerecord.testsuite.feature.query.test_joins import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py index f3ac80e4..fd09e85d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_logging_data_summarization import * +from rhosocial.activerecord.testsuite.feature.query.test_logging_data_summarization import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py index 9d429e03..ad5b3b08 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_query_optimization import * +from rhosocial.activerecord.testsuite.feature.query.test_query_optimization import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py index f1383dc4..f6027323 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_range_queries import * +from rhosocial.activerecord.testsuite.feature.query.test_range_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py index 99429332..62bd6a32 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_range_query_detailed import * +from rhosocial.activerecord.testsuite.feature.query.test_range_query_detailed import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py index 1a38e704..def8f01d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_relational_validation import * +from rhosocial.activerecord.testsuite.feature.query.test_relational_validation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py index 189eecd3..fbf463d4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_relations_basic import * +from rhosocial.activerecord.testsuite.feature.query.test_relations_basic import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with.py deleted file mode 100644 index 6d813ee4..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with.py +++ /dev/null @@ -1,8 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with.py -""" -Bridge file for relations_with tests from the testsuite. - -This file imports the generic tests from the testsuite package and makes them -discoverable by pytest in this project's test run. -""" -from rhosocial.activerecord.testsuite.feature.query.test_relations_with import * diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py new file mode 100644 index 00000000..854df473 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py +""" +Bridge file for async relation 'with' tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_relations_with_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py new file mode 100644 index 00000000..9d4a3e0e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py +""" +Bridge file for sync relation 'with' tests. +""" +from rhosocial.activerecord.testsuite.feature.query.test_relations_with_sync import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py index e1792d50..fc7158fb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_set_operation import * +from rhosocial.activerecord.testsuite.feature.query.test_set_operation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py index 1a71a986..b9eb5614 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_special_queries import * +from rhosocial.activerecord.testsuite.feature.query.test_special_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py index 80a9df04..75205a5d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_sync_queries import * +from rhosocial.activerecord.testsuite.feature.query.test_sync_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py index 3636e357..aa5d6cc7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py @@ -5,4 +5,4 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_window_functions import * +from rhosocial.activerecord.testsuite.feature.query.test_window_functions import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py index b5c81dbb..7bfe5ecb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py @@ -5,7 +5,7 @@ Imports tests from testsuite and makes them discoverable by pytest. """ from rhosocial.activerecord.testsuite.feature.query.worker.conftest import ( - order_fixtures_for_worker, - async_order_fixtures_for_worker, + order_fixtures_for_worker, # noqa: F401 + async_order_fixtures_for_worker, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.query.worker.test_parallel_queries import * +from rhosocial.activerecord.testsuite.feature.query.worker.test_parallel_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py index 11f422aa..c7590e84 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py @@ -6,7 +6,7 @@ Note: Renamed to avoid conflict with feature/backend/test_transaction_isolation.py """ from rhosocial.activerecord.testsuite.feature.query.worker.conftest import ( - order_fixtures_for_worker, - async_order_fixtures_for_worker, + order_fixtures_for_worker, # noqa: F401 + async_order_fixtures_for_worker, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.query.worker.test_transaction_isolation import * +from rhosocial.activerecord.testsuite.feature.query.worker.test_transaction_isolation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py index 2768adfd..cb3209b8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py @@ -5,4 +5,53 @@ This file imports fixtures from the corresponding testsuite, making them available to the tests in this directory. """ -from rhosocial.activerecord.testsuite.feature.relation.conftest import * +from rhosocial.activerecord.testsuite.feature.relation.conftest import * # noqa: F403 + +import pytest_asyncio + + +@pytest_asyncio.fixture(scope="function") +async def async_user_class(async_user_post_comment_classes): + user, _, _ = async_user_post_comment_classes + return user + + +@pytest_asyncio.fixture(scope="function") +async def async_post_class(async_user_post_comment_classes): + _, post, _ = async_user_post_comment_classes + return post + + +@pytest_asyncio.fixture(scope="function") +async def async_comment_class(async_user_post_comment_classes): + _, _, comment = async_user_post_comment_classes + return comment + + +@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS) # noqa: F405 +async def async_user_post_comment_classes(request): + from rhosocial.activerecord.testsuite.core.registry import get_provider_registry + + scenario = request.param + provider_registry = get_provider_registry() + provider_class = provider_registry.get_provider(PROVIDER_KEY) # noqa: F405 + provider = provider_class() + user = provider.setup_async_user_model(scenario) + post = provider.setup_async_post_model(scenario) + comment = provider.setup_async_comment_model(scenario) + await provider._ensure_user_post_comment_async_schema() + yield user, post, comment + await provider.cleanup_after_test_async(scenario) + + +@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS) # noqa: F405 +async def async_relation_boundary_context(request): + from rhosocial.activerecord.testsuite.core.registry import get_provider_registry + + scenario = request.param + provider_registry = get_provider_registry() + provider_class = provider_registry.get_provider(PROVIDER_KEY) # noqa: F405 + provider = provider_class() + owner, profile, post = provider.setup_async_relation_boundary_fixtures(scenario) + yield provider, scenario, owner, profile, post + await provider.cleanup_after_test_async(scenario) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py new file mode 100644 index 00000000..7e44d5a1 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_boundary import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py new file mode 100644 index 00000000..95cbd862 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_boundary_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py index 9d6f1696..ccd3017a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py @@ -3,11 +3,11 @@ This test file runs the relation cache tests from the testsuite using the PostgreSQL backend. """ -import pytest +import pytest # noqa: F401 # Import the test classes from the testsuite -from rhosocial.activerecord.testsuite.feature.relation.test_cache import TestRelationCache +from rhosocial.activerecord.testsuite.feature.relation.test_cache import TestRelationCache # noqa: F401 # This will cause pytest to run all the tests in the imported class diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py new file mode 100644 index 00000000..4a4b246f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_cache_clearing import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py new file mode 100644 index 00000000..21564139 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_derived_field import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py new file mode 100644 index 00000000..af5beacd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py new file mode 100644 index 00000000..67251dc4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_json import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py new file mode 100644 index 00000000..0130588f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_json_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py new file mode 100644 index 00000000..47c04e0f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py + +from rhosocial.activerecord.testsuite.feature.relation.test_descriptor_compat import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py new file mode 100644 index 00000000..8fa56f6d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py + +from rhosocial.activerecord.testsuite.feature.relation.test_descriptors_async import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py new file mode 100644 index 00000000..ebd76ceb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_integration import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py new file mode 100644 index 00000000..5bc443dc --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_integration_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py new file mode 100644 index 00000000..bbd9a806 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_modifier_behavior import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py new file mode 100644 index 00000000..6538d2b1 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_modifier_behavior_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py new file mode 100644 index 00000000..eb29123d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_modifier_warning import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py new file mode 100644 index 00000000..b7207a9f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_modifier_warning_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py index b325aa79..edb06654 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py @@ -2,4 +2,4 @@ Tests for nested relationship access functionality. Imports tests from the standardized testsuite. """ -from rhosocial.activerecord.testsuite.feature.relation.test_nested_relationship_access import TestNestedRelationshipAccess # noqa: F401 +from rhosocial.activerecord.testsuite.feature.relation.test_nested_relationship_access import TestNestedRelationshipAccess # noqa: E501, F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py new file mode 100644 index 00000000..37e7e8e9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_path_validation import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py new file mode 100644 index 00000000..599860af --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_path_validation_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py new file mode 100644 index 00000000..0cebf814 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py + +from rhosocial.activerecord.testsuite.feature.relation.test_validator import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py new file mode 100644 index 00000000..fdb8bca6 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_with_method import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py new file mode 100644 index 00000000..de09d14a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.test_with_method_async import * # noqa: F401,F403